mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
Update TUI v3 plan and land parity fixes with smoke tests
This commit is contained in:
1 parent
e7ea8a4187
commit
d3920ccc4c
306 files changed
+51128
-425
No files matched your search
+89
-16
@@ -18,6 +18,21 @@ from .widgets.action_bar import ActionBar
|
||||
from .screens.settings import SettingsScreen
|
||||
from .screens.inspector import MaximizedInspectorScreen
|
||||
|
||||
|
||||
def render_qr_ascii(data: str) -> str:
|
||||
"""Render ``data`` as an ASCII QR code."""
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
matrix = qr.get_matrix()
|
||||
lines: list[str] = []
|
||||
for row in matrix:
|
||||
lines.append("".join("##" if cell else " " for cell in row))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class CommandProcessor:
|
||||
"""Handles logic for palette commands in TUI v3."""
|
||||
def __init__(self, app: SeedPassTuiV3):
|
||||
@@ -36,18 +51,24 @@ class CommandProcessor:
|
||||
args = parts[1:]
|
||||
|
||||
if cmd == "help":
|
||||
self.app.notify("v3 commands: stats, lock, refresh, settings, maximize")
|
||||
self.app.notify(
|
||||
"v3 commands: help, stats, session-status, lock, unlock <password>, refresh, search <query>, open <id>, settings, maximize, copy, archive, restore"
|
||||
)
|
||||
elif cmd == "stats":
|
||||
self.app.notify("Calculating stats...")
|
||||
# We reuse the existing stats logic
|
||||
if "vault" in self.app.services:
|
||||
stats = self.app.services["vault"].stats()
|
||||
self.app.notify(f"Total entries: {stats.get('total_entries', 0)}")
|
||||
elif cmd == "session-status":
|
||||
self.app.action_session_status()
|
||||
elif cmd == "lock":
|
||||
if "vault" in self.app.services:
|
||||
self.app.services["vault"].lock()
|
||||
self.app.session_locked = True
|
||||
self.app.notify("Vault locked")
|
||||
self.app.action_lock()
|
||||
elif cmd == "unlock":
|
||||
if len(args) != 1:
|
||||
self.app.notify("Usage: unlock <password>", severity="warning")
|
||||
return
|
||||
self.app.action_unlock(args[0])
|
||||
elif cmd == "refresh":
|
||||
self.app.action_refresh()
|
||||
elif cmd == "search":
|
||||
@@ -139,7 +160,7 @@ class SeedPassTuiV3(App[None]):
|
||||
# Shared Reactive State
|
||||
active_fingerprint = reactive("")
|
||||
selected_entry_id = reactive[int | None](None)
|
||||
session_locked = reactive(True)
|
||||
session_locked = reactive(False)
|
||||
|
||||
# Internal state for sensitive actions
|
||||
_pending_sensitive_confirm: tuple[str, int, float] | None = None
|
||||
@@ -194,10 +215,18 @@ class SeedPassTuiV3(App[None]):
|
||||
def action_refresh_ui_quiet(self) -> None:
|
||||
"""Background refresh for dynamic elements (2FA)."""
|
||||
try:
|
||||
# We only refresh the inspector if it's currently showing a 2FA board
|
||||
board = self.query_one("#board-container")
|
||||
board = self.screen.query_one("#board-container")
|
||||
if board and hasattr(board, "children") and board.children:
|
||||
current_board = board.children[0]
|
||||
# Ensure selection/inspector stay synchronized if a selection is set
|
||||
# but the board is still idle after screen transitions.
|
||||
if (
|
||||
self.selected_entry_id is not None
|
||||
and current_board.__class__.__name__ == "IdleBoard"
|
||||
):
|
||||
board.update_entry(self.selected_entry_id)
|
||||
return
|
||||
# Refresh dynamic TOTP countdown only for the active TOTP board.
|
||||
if current_board.__class__.__name__ == "TotpBoard":
|
||||
current_board.refresh()
|
||||
except Exception:
|
||||
@@ -209,28 +238,28 @@ class SeedPassTuiV3(App[None]):
|
||||
return
|
||||
# Notify sidebar and grid to refresh
|
||||
try:
|
||||
self.query_one("#profile-tree")._refresh_tree()
|
||||
self.query_one("#entry-data-table")._refresh_data()
|
||||
self.screen.query_one("#profile-tree")._refresh_tree()
|
||||
self.screen.query_one("#entry-data-table")._refresh_data()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None:
|
||||
"""Update inspectors when an entry is selected."""
|
||||
try:
|
||||
self.query_one("#board-container").update_entry(new_id)
|
||||
self.screen.query_one("#board-container").update_entry(new_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
"""Force a global UI refresh."""
|
||||
self.query_one("#profile-tree")._refresh_tree()
|
||||
self.query_one("#entry-data-table")._refresh_data()
|
||||
self.screen.query_one("#profile-tree")._refresh_tree()
|
||||
self.screen.query_one("#entry-data-table")._refresh_data()
|
||||
self.notify("UI Refreshed")
|
||||
|
||||
def action_search(self, query: str) -> None:
|
||||
"""Search entries and update grid."""
|
||||
try:
|
||||
self.query_one("#entry-data-table")._refresh_data(query)
|
||||
self.screen.query_one("#entry-data-table")._refresh_data(query)
|
||||
self.notify(f"Search results for: {query}")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -238,7 +267,7 @@ class SeedPassTuiV3(App[None]):
|
||||
def action_open_palette(self) -> None:
|
||||
"""Toggle the command palette."""
|
||||
try:
|
||||
self.query_one("#palette").toggle()
|
||||
self.screen.query_one("#palette").toggle()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -253,6 +282,50 @@ class SeedPassTuiV3(App[None]):
|
||||
return
|
||||
self.push_screen(MaximizedInspectorScreen())
|
||||
|
||||
def action_session_status(self) -> None:
|
||||
"""Display vault lock status."""
|
||||
state = "locked" if self.session_locked else "unlocked"
|
||||
self.notify(f"Session status: {state}")
|
||||
|
||||
def action_lock(self) -> None:
|
||||
"""Lock the vault if service support is available."""
|
||||
vault = self.services.get("vault")
|
||||
if vault is None:
|
||||
self.notify("Vault service unavailable", severity="error")
|
||||
return
|
||||
locker = getattr(vault, "lock", None)
|
||||
if not callable(locker):
|
||||
self.notify("Vault service does not support lock", severity="error")
|
||||
return
|
||||
try:
|
||||
locker()
|
||||
self.session_locked = True
|
||||
self.notify("Vault locked")
|
||||
except Exception as e:
|
||||
self.notify(f"Lock failed: {e}", severity="error")
|
||||
|
||||
def action_unlock(self, password: str) -> None:
|
||||
"""Unlock the vault with a password."""
|
||||
vault = self.services.get("vault")
|
||||
if vault is None:
|
||||
self.notify("Vault service unavailable", severity="error")
|
||||
return
|
||||
unlocker = getattr(vault, "unlock", None)
|
||||
if not callable(unlocker):
|
||||
self.notify("Vault service does not support unlock", severity="error")
|
||||
return
|
||||
try:
|
||||
try:
|
||||
from seedpass.core.api import UnlockRequest
|
||||
|
||||
unlocker(UnlockRequest(password=password))
|
||||
except Exception:
|
||||
unlocker(password)
|
||||
self.session_locked = False
|
||||
self.notify("Vault unlocked")
|
||||
except Exception as e:
|
||||
self.notify(f"Unlock failed: {e}", severity="error")
|
||||
|
||||
def action_reveal_selected(self, confirm: bool = False) -> None:
|
||||
"""Handle reveal shortcut (v)."""
|
||||
if self.session_locked:
|
||||
@@ -410,7 +483,7 @@ class SeedPassTuiV3(App[None]):
|
||||
|
||||
# 2. Update standard inspector board
|
||||
try:
|
||||
board_cont = self.query_one("#board-container")
|
||||
board_cont = self.screen.query_one("#board-container")
|
||||
if board_cont.children:
|
||||
board = board_cont.children[0]
|
||||
if hasattr(board, "reveal_data"):
|
||||
|
||||
@@ -39,6 +39,13 @@ class EntryDataTable(DataTable):
|
||||
)
|
||||
self._refresh_data()
|
||||
|
||||
def on_focus(self) -> None:
|
||||
"""Refresh table when focused so newly added entries appear immediately."""
|
||||
try:
|
||||
self._refresh_data()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _refresh_data(self, query: str = "") -> None:
|
||||
self.clear()
|
||||
app = self.app
|
||||
|
||||
@@ -54,10 +54,23 @@ class ProfileTree(Tree):
|
||||
|
||||
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||
"""Handle profile selection."""
|
||||
fp = event.node.data
|
||||
if fp and isinstance(fp, str):
|
||||
self.app.active_fingerprint = fp
|
||||
self.app.notify(f"Switched to profile: {fp[:8]}")
|
||||
value = event.node.data
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
|
||||
if value.startswith("managed:") or value.startswith("agent:"):
|
||||
_, raw_id = value.split(":", 1)
|
||||
try:
|
||||
entry_id = int(raw_id)
|
||||
except ValueError:
|
||||
self.app.notify("Invalid tree entry target", severity="error")
|
||||
return
|
||||
self.app.selected_entry_id = entry_id
|
||||
self.app.notify(f"Opened Entry #{entry_id} from profile tree")
|
||||
return
|
||||
|
||||
self.app.active_fingerprint = value
|
||||
self.app.notify(f"Switched to profile: {value[:8]}")
|
||||
|
||||
class SidebarContainer(Static):
|
||||
"""Container for the sidebar tree and toggle."""
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("textual")
|
||||
from textual.widgets import DataTable
|
||||
|
||||
from seedpass.tui_v3 import launch_tui3
|
||||
|
||||
|
||||
class V3EntryService:
|
||||
def __init__(self) -> None:
|
||||
self._entries: dict[int, dict] = {
|
||||
1: {"id": 1, "kind": "password", "label": "Email", "length": 16},
|
||||
2: {"id": 2, "kind": "managed_account", "label": "Managed Ops"},
|
||||
3: {"id": 3, "kind": "nostr", "label": "Agent Nostr"},
|
||||
}
|
||||
self.copied: list[str] = []
|
||||
|
||||
def search_entries(
|
||||
self,
|
||||
query: str,
|
||||
kinds: list[str] | None = None,
|
||||
*,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
):
|
||||
_ = (include_archived, archived_only)
|
||||
q = (query or "").strip().lower()
|
||||
rows = []
|
||||
for entry_id in sorted(self._entries.keys()):
|
||||
entry = self._entries[entry_id]
|
||||
kind = str(entry.get("kind", "password"))
|
||||
if kinds and kind not in kinds:
|
||||
continue
|
||||
label = str(entry.get("label", ""))
|
||||
if q and q not in label.lower():
|
||||
continue
|
||||
rows.append(
|
||||
(
|
||||
entry_id,
|
||||
label,
|
||||
entry.get("username"),
|
||||
entry.get("url"),
|
||||
bool(entry.get("archived", False)),
|
||||
SimpleNamespace(value=kind),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def retrieve_entry(self, entry_id: int):
|
||||
return dict(self._entries.get(int(entry_id), {}))
|
||||
|
||||
def generate_password(self, length: int, entry_id: int) -> str:
|
||||
return f"pw-{entry_id}-{length}"
|
||||
|
||||
def archive_entry(self, entry_id: int) -> None:
|
||||
self._entries[int(entry_id)]["archived"] = True
|
||||
|
||||
def restore_entry(self, entry_id: int) -> None:
|
||||
self._entries[int(entry_id)]["archived"] = False
|
||||
|
||||
def copy_to_clipboard(self, value: str) -> bool:
|
||||
self.copied.append(value)
|
||||
return True
|
||||
|
||||
|
||||
class V3ProfileService:
|
||||
def list_profiles(self) -> list[str]:
|
||||
return ["EFBE51E70ED1B53A"]
|
||||
|
||||
|
||||
class V3VaultService:
|
||||
def __init__(self) -> None:
|
||||
self.locked = False
|
||||
self.last_unlock_password: str | None = None
|
||||
self._manager = SimpleNamespace(parent_seed="PARENT_SEED")
|
||||
|
||||
def lock(self) -> None:
|
||||
self.locked = True
|
||||
|
||||
def unlock(self, request) -> SimpleNamespace:
|
||||
if hasattr(request, "password"):
|
||||
self.last_unlock_password = str(request.password)
|
||||
else:
|
||||
self.last_unlock_password = str(request)
|
||||
self.locked = False
|
||||
return SimpleNamespace(status="ok", duration=0.01)
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
return {"total_entries": 3}
|
||||
|
||||
|
||||
def _build_app() -> tuple[object, V3EntryService, V3VaultService]:
|
||||
holder: dict[str, object] = {}
|
||||
entry = V3EntryService()
|
||||
vault = V3VaultService()
|
||||
profile = V3ProfileService()
|
||||
|
||||
def _hook(app):
|
||||
holder["app"] = app
|
||||
|
||||
launched = launch_tui3(
|
||||
fingerprint="EFBE51E70ED1B53A",
|
||||
entry_service_factory=lambda: entry,
|
||||
profile_service_factory=lambda: profile,
|
||||
vault_service_factory=lambda: vault,
|
||||
app_hook=_hook,
|
||||
)
|
||||
assert launched is True
|
||||
app = holder.get("app")
|
||||
assert app is not None
|
||||
return app, entry, vault
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui3_palette_lock_unlock_and_session_status() -> None:
|
||||
app, _entry, vault = _build_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.session_locked is False
|
||||
app.processor.execute("session-status")
|
||||
app.processor.execute("lock")
|
||||
await pilot.pause()
|
||||
assert app.session_locked is True
|
||||
assert vault.locked is True
|
||||
app.processor.execute("unlock hunter2")
|
||||
await pilot.pause()
|
||||
assert app.session_locked is False
|
||||
assert vault.locked is False
|
||||
assert vault.last_unlock_password == "hunter2"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui3_sidebar_child_nodes_open_entries() -> None:
|
||||
app, _entry, _vault = _build_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
tree = app.screen.query_one("#profile-tree")
|
||||
tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="managed:2")))
|
||||
await pilot.pause()
|
||||
assert app.selected_entry_id == 2
|
||||
assert app.active_fingerprint == "EFBE51E70ED1B53A"
|
||||
tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="agent:3")))
|
||||
await pilot.pause()
|
||||
assert app.selected_entry_id == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui3_grid_focus_refresh_enables_selection_after_runtime_add() -> None:
|
||||
app, entry, _vault = _build_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
entry._entries[4] = {
|
||||
"id": 4,
|
||||
"kind": "password",
|
||||
"label": "Runtime Added",
|
||||
"length": 18,
|
||||
}
|
||||
table = app.screen.query_one("#entry-data-table", DataTable)
|
||||
table.focus()
|
||||
await pilot.pause()
|
||||
table._refresh_data()
|
||||
await pilot.pause()
|
||||
assert table.row_count >= 4
|
||||
Reference in new issue
Block a user