From 403c09a8063b771eef0e6769677a3fd62f74e97d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:09:46 -0500 Subject: [PATCH] Add TUI v2 phase 2 document edit and archive actions --- docs/tui_v2_plan.md | 10 +- memory-update.md | 2 + src/seedpass/tui_v2/app.py | 227 +++++++++++++++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 17 deletions(-) diff --git a/docs/tui_v2_plan.md b/docs/tui_v2_plan.md index 731b1e78..e0eb711c 100644 --- a/docs/tui_v2_plan.md +++ b/docs/tui_v2_plan.md @@ -41,17 +41,17 @@ Goal: Introduce a modern, modular TUI (`seedpass tui2`) without rewriting core l - [ ] Add CI smoke command for `seedpass tui2 --check`. ### Phase 1: Read-only shell -- [ ] Textual app skeleton with 3-pane layout: +- [x] Textual app skeleton with 3-pane layout: - filters panel - entry list panel - details panel - [ ] Unlock/profile context hookup. -- [ ] Entry list/search/detail read paths. -- [ ] Status line for sync/profile/lock state. +- [x] Entry list/search/detail read paths. +- [x] Status line for sync/profile/lock state. ### Phase 2: Core editing workflows -- [ ] Add/modify/archive flows for common entry kinds. -- [ ] Document editor panel with dirty-state and save/cancel. +- [x] Archive/restore action + document modify flow. +- [x] Document editor panel with dirty-state and save/cancel. - [ ] Keybindings + command palette. ### Phase 3: Graph workflows diff --git a/memory-update.md b/memory-update.md index 6b518524..aee5d71a 100644 --- a/memory-update.md +++ b/memory-update.md @@ -178,3 +178,5 @@ - `.venv` smoke run confirms `seedpass tui2 --check` emits runtime diagnostics. - 2026-03-01: TUI v2 phase 1 moved from placeholder to functional read-only Textual shell. `seedpass tui2` now passes a lazy `entry_service_factory` so vault/service init only happens when Textual is actually available, preventing unnecessary prompts on unavailable runtimes. - Added focused CLI tests in `src/tests/test_typer_cli.py` for `tui2 --check`, unavailable + no fallback exit path, and legacy fallback fingerprint forwarding. +- 2026-03-01: TUI v2 phase 2 slice added in `seedpass/tui_v2/app.py`: archive/restore action (`a`), document editor mode (`e`) with save/cancel (`Ctrl+S`/`Esc`), and a persistent status line for operation feedback. +- Document editor uses `TextArea` when available and falls back to single-line input if unavailable, keeping runtime compatibility across Textual versions. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index dd63d4e7..b17d54d1 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -33,12 +33,16 @@ def launch_tui2( if not runtime["textual_available"]: return False - # Textual shell (phase 1: read-only list/search/details). from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static + try: + from textual.widgets import TextArea + except Exception: # pragma: no cover - runtime fallback for older textual + TextArea = None + class EntryListItem(ListItem): def __init__(self, entry_index: int, text: str) -> None: super().__init__(Label(text)) @@ -47,20 +51,36 @@ def launch_tui2( class SeedPassTuiV2(App[None]): CSS = """ #body { height: 1fr; } - #left { width: 28; border: solid $primary; padding: 1; } + #status { height: 1; padding: 0 1; } + #left { width: 30; 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; } + #right-view { height: 1fr; } + #right-editor { height: 1fr; } + #doc-edit-label { margin-bottom: 1; } + #doc-edit-file-type { margin-bottom: 1; } + #doc-edit-tags { margin-bottom: 1; } + #doc-edit-help { margin-top: 1; } + #doc-edit-content { height: 1fr; } + #doc-edit-content-single { height: 1fr; } + .hidden { display: none; } """ - BINDINGS = [("q", "quit", "Quit")] - BINDINGS += [ + BINDINGS = [ + ("q", "quit", "Quit"), ("r", "refresh", "Refresh"), ("slash", "focus_search", "Search"), ("f", "cycle_filter", "Filter"), + ("a", "toggle_archive", "Archive/Restore"), + ("e", "edit_document", "Edit Document"), + ("ctrl+s", "save_document", "Save"), + ("escape", "cancel_document_edit", "Cancel"), ] + filter_kind: reactive[str] = reactive("all") + editing_document: reactive[bool] = reactive(False) def compose(self) -> ComposeResult: yield Header(show_clock=True) @@ -73,22 +93,53 @@ def launch_tui2( ) yield ListView(id="entry-list") with Vertical(id="right"): - yield Static("", id="entry-detail") + with Vertical(id="right-view"): + yield Static("", id="entry-detail") + with Vertical(id="right-editor", classes="hidden"): + yield Input(placeholder="Document title", id="doc-edit-label") + yield Input( + placeholder="File extension (txt, md, py, ...)", + id="doc-edit-file-type", + ) + yield Input( + placeholder="Tags (comma-separated)", id="doc-edit-tags" + ) + if TextArea is not None: + yield TextArea("", id="doc-edit-content") + else: + yield Input( + placeholder="Document content", + id="doc-edit-content-single", + ) + yield Static( + "Edit mode: Ctrl+S save, Esc cancel", + id="doc-edit-help", + ) + yield Static("Ready", id="status") yield Footer() def on_mount(self) -> None: + self._service = None + self._selected_entry_id: int | None = None + self._selected_entry: dict[str, Any] | None = None + self._last_query = "" + self._entry_ids_in_view: list[int] = [] try: self._service = ( entry_service_factory() if callable(entry_service_factory) else None ) except Exception as exc: self._service = None + self._set_status(f"Unable to initialize entry service: {exc}") self.query_one("#entry-detail", Static).update( f"Unable to initialize entry service: {exc}" ) self._update_filters_panel() self._load_entries() + def _set_status(self, message: str) -> None: + self.query_one("#status", Static).update(message) + def _current_filter_kinds(self) -> list[str] | None: if self.filter_kind == "all": return None @@ -102,17 +153,21 @@ def launch_tui2( ) text = "\n".join( [ - "TUI v2 (Phase 1)", + "TUI v2 (Phase 2)", 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", + "Navigation:", + "- / search", + "- f cycle kind filter", + "- r refresh", + "", + "Actions:", + "- a archive/restore", + "- e edit document", + "- Ctrl+S save doc", + "- Esc cancel edit", ] ) self.query_one("#filters", Static).update(text) @@ -124,6 +179,8 @@ def launch_tui2( return f"{idx:>4} {etype:<15} {label}{arch}" def _load_entries(self, query: str = "") -> None: + self._last_query = query + self._entry_ids_in_view = [] list_view = self.query_one("#entry-list", ListView) list_view.clear() if self._service is None: @@ -140,6 +197,7 @@ def launch_tui2( self.query_one("#entry-detail", Static).update( f"Failed to load entries: {exc}" ) + self._set_status("Failed to load entries") return for idx, label, _username, _url, archived, etype in results: @@ -148,10 +206,14 @@ def launch_tui2( idx, self._render_entry_label(idx, label, kind, bool(archived)), ) + self._entry_ids_in_view.append(int(idx)) list_view.append(item) if len(results) == 0: + self._selected_entry_id = None + self._selected_entry = None self.query_one("#entry-detail", Static).update("No entries match.") + self._set_status("No entries match current filter/search") else: if list_view.children: first = list_view.children[0] @@ -165,13 +227,53 @@ def launch_tui2( entry = self._service.retrieve_entry(entry_index) if not isinstance(entry, dict): self.query_one("#entry-detail", Static).update("Entry not found.") + self._set_status(f"Entry {entry_index} not found") return + self._selected_entry_id = int(entry_index) + self._selected_entry = dict(entry) body = json.dumps(entry, indent=2, sort_keys=True) self.query_one("#entry-detail", Static).update(body) + self._set_status(f"Selected entry {entry_index}") except Exception as exc: self.query_one("#entry-detail", Static).update( f"Failed to load entry {entry_index}: {exc}" ) + self._set_status(f"Failed to load entry {entry_index}") + + def _is_selected_document(self) -> bool: + if not isinstance(self._selected_entry, dict): + return False + kind = str( + self._selected_entry.get("kind") or self._selected_entry.get("type") + ) + return kind == "document" + + def _set_document_editor_visible(self, visible: bool) -> None: + view = self.query_one("#right-view", Vertical) + editor = self.query_one("#right-editor", Vertical) + if visible: + view.add_class("hidden") + editor.remove_class("hidden") + else: + editor.add_class("hidden") + view.remove_class("hidden") + self.editing_document = visible + + def _get_document_editor_text(self) -> str: + if TextArea is not None: + area = self.query_one("#doc-edit-content") + return str(getattr(area, "text", "")) + return self.query_one("#doc-edit-content-single", Input).value + + def _set_document_editor_text(self, content: str) -> None: + if TextArea is not None: + area = self.query_one("#doc-edit-content") + if hasattr(area, "load_text"): + area.load_text(content) + elif hasattr(area, "text"): + area.text = content + return + self.query_one("#doc-edit-content-single", Input).value = content def action_refresh(self) -> None: search = self.query_one("#search", Input).value @@ -198,11 +300,112 @@ def launch_tui2( self.filter_kind = order[(idx + 1) % len(order)] self.action_refresh() + def action_toggle_archive(self) -> None: + if self._service is None or self._selected_entry_id is None: + self._set_status("No entry selected") + return + if self.editing_document: + self._set_status("Finish document edit before archive/restore") + return + + try: + archived = bool( + self._selected_entry and self._selected_entry.get("archived") + ) + if archived: + self._service.restore_entry(self._selected_entry_id) + action = "restored" + else: + self._service.archive_entry(self._selected_entry_id) + action = "archived" + current_id = self._selected_entry_id + self._load_entries(self._last_query) + if current_id in self._entry_ids_in_view: + self._show_entry(current_id) + self._set_status(f"Entry {current_id} {action}") + except Exception as exc: + self._set_status(f"Archive/restore failed: {exc}") + + def action_edit_document(self) -> None: + if self._service is None: + self._set_status("Entry service unavailable") + return + if self._selected_entry_id is None or not self._is_selected_document(): + self._set_status("Select a document entry to edit") + return + if self.editing_document: + self._set_status("Document editor already open") + return + + entry = self._selected_entry or {} + self.query_one("#doc-edit-label", Input).value = str(entry.get("label", "")) + self.query_one("#doc-edit-file-type", Input).value = str( + entry.get("file_type", "txt") + ) + tags = entry.get("tags") + if isinstance(tags, list): + tags_text = ", ".join(str(tag) for tag in tags) + else: + tags_text = "" + self.query_one("#doc-edit-tags", Input).value = tags_text + self._set_document_editor_text(str(entry.get("content", ""))) + self._set_document_editor_visible(True) + if TextArea is not None: + self.query_one("#doc-edit-content").focus() + else: + self.query_one("#doc-edit-content-single", Input).focus() + self._set_status(f"Editing document {self._selected_entry_id}") + + def action_save_document(self) -> None: + if not self.editing_document: + self._set_status("Document editor not active") + return + if self._service is None or self._selected_entry_id is None: + self._set_status("No document selected") + return + + label = self.query_one("#doc-edit-label", Input).value.strip() + file_type = ( + self.query_one("#doc-edit-file-type", Input).value.strip().lstrip(".") + ) + tags_raw = self.query_one("#doc-edit-tags", Input).value.strip() + tags = [part.strip() for part in tags_raw.split(",") if part.strip()] + content = self._get_document_editor_text() + + try: + self._service.modify_entry( + self._selected_entry_id, + label=label or None, + content=content, + file_type=file_type or None, + tags=tags, + ) + current_id = self._selected_entry_id + self._set_document_editor_visible(False) + self._load_entries(self._last_query) + if current_id in self._entry_ids_in_view: + self._show_entry(current_id) + self._set_status(f"Saved document {current_id}") + except Exception as exc: + self._set_status(f"Failed to save document: {exc}") + + def action_cancel_document_edit(self) -> None: + if not self.editing_document: + return + self._set_document_editor_visible(False) + self._set_status("Canceled document edit") + def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "search": + if self.editing_document: + self._set_status("Finish document edit before searching") + return self._load_entries(query=event.value.strip()) def on_list_view_selected(self, event: ListView.Selected) -> None: + if self.editing_document: + self._set_status("Finish document edit before selecting another entry") + return item = event.item if isinstance(item, EntryListItem): self._show_entry(item.entry_index)