mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
Implement TUI v2 phase 1 read-only shell
This commit is contained in:
1 parent
8d3d2a3993
commit
4113bd1282
3 files changed
+214
-23
No files matched your search
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from .common import _get_services
|
||||
from .common import _get_entry_service, _get_services
|
||||
from seedpass.core.errors import SeedPassError
|
||||
from constants import GUI_BACKEND_CONFIG
|
||||
from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2
|
||||
@@ -127,7 +127,10 @@ def tui2(
|
||||
return
|
||||
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
launched = launch_tui2(fingerprint=fingerprint)
|
||||
launched = launch_tui2(
|
||||
fingerprint=fingerprint,
|
||||
entry_service_factory=lambda: _get_entry_service(ctx),
|
||||
)
|
||||
if launched:
|
||||
return
|
||||
|
||||
|
||||
+170
-21
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -19,7 +20,11 @@ def check_tui2_runtime() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def launch_tui2(*, fingerprint: str | None = None) -> bool:
|
||||
def launch_tui2(
|
||||
*,
|
||||
fingerprint: str | None = None,
|
||||
entry_service_factory: Any | None = None,
|
||||
) -> bool:
|
||||
"""Launch TUI v2 when runtime dependencies are available.
|
||||
|
||||
Returns ``True`` when launch succeeds, ``False`` when runtime is unavailable.
|
||||
@@ -28,35 +33,179 @@ def launch_tui2(*, fingerprint: str | None = None) -> bool:
|
||||
if not runtime["textual_available"]:
|
||||
return False
|
||||
|
||||
# Placeholder shell for incremental migration.
|
||||
# Textual shell (phase 1: read-only list/search/details).
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Footer, Header, Static
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static
|
||||
|
||||
class EntryListItem(ListItem):
|
||||
def __init__(self, entry_index: int, text: str) -> None:
|
||||
super().__init__(Label(text))
|
||||
self.entry_index = int(entry_index)
|
||||
|
||||
class SeedPassTuiV2(App[None]):
|
||||
CSS = """
|
||||
#body { height: 1fr; }
|
||||
#left { width: 28; border: solid $primary; padding: 1; }
|
||||
#center { width: 1fr; border: solid $primary; padding: 1; }
|
||||
#right { width: 1fr; border: solid $primary; padding: 1; }
|
||||
#search { margin-bottom: 1; }
|
||||
#entry-list { height: 1fr; }
|
||||
#entry-detail { height: 1fr; overflow: auto; }
|
||||
"""
|
||||
BINDINGS = [("q", "quit", "Quit")]
|
||||
BINDINGS += [
|
||||
("r", "refresh", "Refresh"),
|
||||
("slash", "focus_search", "Search"),
|
||||
("f", "cycle_filter", "Filter"),
|
||||
]
|
||||
filter_kind: reactive[str] = reactive("all")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
with Container():
|
||||
fp_line = f"Fingerprint: {fingerprint}" if fingerprint else "No fingerprint selected"
|
||||
yield Static(
|
||||
"\n".join(
|
||||
[
|
||||
"SeedPass TUI v2 scaffold",
|
||||
fp_line,
|
||||
"",
|
||||
"Next steps:",
|
||||
"- Implement list/search/details panes",
|
||||
"- Add document editor panel",
|
||||
"- Add graph links panel",
|
||||
"",
|
||||
"Press 'q' to quit.",
|
||||
]
|
||||
with Horizontal(id="body"):
|
||||
with Vertical(id="left"):
|
||||
yield Static("", id="filters")
|
||||
with Vertical(id="center"):
|
||||
yield Input(
|
||||
placeholder="Search entries (Enter to apply)", id="search"
|
||||
)
|
||||
)
|
||||
yield ListView(id="entry-list")
|
||||
with Vertical(id="right"):
|
||||
yield Static("", id="entry-detail")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
try:
|
||||
self._service = (
|
||||
entry_service_factory() if callable(entry_service_factory) else None
|
||||
)
|
||||
except Exception as exc:
|
||||
self._service = None
|
||||
self.query_one("#entry-detail", Static).update(
|
||||
f"Unable to initialize entry service: {exc}"
|
||||
)
|
||||
self._update_filters_panel()
|
||||
self._load_entries()
|
||||
|
||||
def _current_filter_kinds(self) -> list[str] | None:
|
||||
if self.filter_kind == "all":
|
||||
return None
|
||||
return [self.filter_kind]
|
||||
|
||||
def _update_filters_panel(self) -> None:
|
||||
fp_line = (
|
||||
f"Fingerprint: {fingerprint}"
|
||||
if fingerprint
|
||||
else "Fingerprint: (default)"
|
||||
)
|
||||
text = "\n".join(
|
||||
[
|
||||
"TUI v2 (Phase 1)",
|
||||
fp_line,
|
||||
"",
|
||||
f"Active filter: {self.filter_kind}",
|
||||
"Press 'f' to cycle filter",
|
||||
"Press '/' to focus search",
|
||||
"Press 'r' to refresh",
|
||||
"",
|
||||
"Planned next:",
|
||||
"- document editor pane",
|
||||
"- graph links pane",
|
||||
]
|
||||
)
|
||||
self.query_one("#filters", Static).update(text)
|
||||
|
||||
def _render_entry_label(
|
||||
self, idx: int, label: str, etype: str, archived: bool
|
||||
) -> str:
|
||||
arch = " [archived]" if archived else ""
|
||||
return f"{idx:>4} {etype:<15} {label}{arch}"
|
||||
|
||||
def _load_entries(self, query: str = "") -> None:
|
||||
list_view = self.query_one("#entry-list", ListView)
|
||||
list_view.clear()
|
||||
if self._service is None:
|
||||
self.query_one("#entry-detail", Static).update(
|
||||
"Entry service unavailable in this runtime."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
results = self._service.search_entries(
|
||||
query, kinds=self._current_filter_kinds()
|
||||
)
|
||||
except Exception as exc:
|
||||
self.query_one("#entry-detail", Static).update(
|
||||
f"Failed to load entries: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
for idx, label, _username, _url, archived, etype in results:
|
||||
kind = getattr(etype, "value", str(etype))
|
||||
item = EntryListItem(
|
||||
idx,
|
||||
self._render_entry_label(idx, label, kind, bool(archived)),
|
||||
)
|
||||
list_view.append(item)
|
||||
|
||||
if len(results) == 0:
|
||||
self.query_one("#entry-detail", Static).update("No entries match.")
|
||||
else:
|
||||
if list_view.children:
|
||||
first = list_view.children[0]
|
||||
if isinstance(first, EntryListItem):
|
||||
self._show_entry(first.entry_index)
|
||||
|
||||
def _show_entry(self, entry_index: int) -> None:
|
||||
if self._service is None:
|
||||
return
|
||||
try:
|
||||
entry = self._service.retrieve_entry(entry_index)
|
||||
if not isinstance(entry, dict):
|
||||
self.query_one("#entry-detail", Static).update("Entry not found.")
|
||||
return
|
||||
body = json.dumps(entry, indent=2, sort_keys=True)
|
||||
self.query_one("#entry-detail", Static).update(body)
|
||||
except Exception as exc:
|
||||
self.query_one("#entry-detail", Static).update(
|
||||
f"Failed to load entry {entry_index}: {exc}"
|
||||
)
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
search = self.query_one("#search", Input).value
|
||||
self._update_filters_panel()
|
||||
self._load_entries(query=search)
|
||||
|
||||
def action_focus_search(self) -> None:
|
||||
self.query_one("#search", Input).focus()
|
||||
|
||||
def action_cycle_filter(self) -> None:
|
||||
order = [
|
||||
"all",
|
||||
"password",
|
||||
"totp",
|
||||
"document",
|
||||
"key_value",
|
||||
"ssh",
|
||||
"pgp",
|
||||
"nostr",
|
||||
"seed",
|
||||
"managed_account",
|
||||
]
|
||||
idx = order.index(self.filter_kind) if self.filter_kind in order else 0
|
||||
self.filter_kind = order[(idx + 1) % len(order)]
|
||||
self.action_refresh()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "search":
|
||||
self._load_entries(query=event.value.strip())
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
item = event.item
|
||||
if isinstance(item, EntryListItem):
|
||||
self._show_entry(item.entry_index)
|
||||
|
||||
SeedPassTuiV2().run()
|
||||
return True
|
||||
|
||||
@@ -990,6 +990,45 @@ def test_tui_forward_fingerprint(monkeypatch):
|
||||
assert called.get("fp") == "abc"
|
||||
|
||||
|
||||
def test_tui2_check(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"check_tui2_runtime",
|
||||
lambda: {"status": "ok", "backend": "textual", "textual_available": True},
|
||||
)
|
||||
result = runner.invoke(app, ["tui2", "--check"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["backend"] == "textual"
|
||||
assert payload["textual_available"] is True
|
||||
|
||||
|
||||
def test_tui2_unavailable_without_fallback(monkeypatch):
|
||||
monkeypatch.setattr(cli, "launch_tui2", lambda **_: False)
|
||||
result = runner.invoke(app, ["tui2", "--no-fallback-legacy"])
|
||||
assert result.exit_code == 1
|
||||
assert "Install `textual`" in result.stderr
|
||||
|
||||
|
||||
def test_tui2_fallback_legacy(monkeypatch):
|
||||
called = {}
|
||||
monkeypatch.setattr(cli, "launch_tui2", lambda **_: False)
|
||||
|
||||
def fake_main(*, fingerprint=None):
|
||||
called["fp"] = fingerprint
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"importlib",
|
||||
SimpleNamespace(import_module=lambda _: SimpleNamespace(main=fake_main)),
|
||||
)
|
||||
result = runner.invoke(app, ["--fingerprint", "abc", "tui2"])
|
||||
assert result.exit_code == 0
|
||||
assert called.get("fp") == "abc"
|
||||
assert "falling back to legacy TUI" in result.stdout
|
||||
|
||||
|
||||
def test_gui_command(monkeypatch):
|
||||
called = {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user