Add TUI v2 phase 2 document edit and archive actions

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 16:09:46 -05:00
1 parent bf138af2cd
commit 403c09a806
3 files changed
+222 -17

No files matched your search

+5 -5
View File
@@ -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
+2
View File
@@ -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.
+215 -12
View File
@@ -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)