mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +00:00
tui2: add export-field actions and density/tree ui polish
This commit is contained in:
1 parent
93cf78b2c8
commit
a67ee2d9da
5 files changed
+519
-7
No files matched your search
@@ -416,3 +416,86 @@ Validation:
|
||||
Next recommended slice:
|
||||
|
||||
1. Phase 4 inspector board templates for advanced kinds (`ssh`, `pgp`, `nostr`, `key_value`) and stronger action-strip context hints.
|
||||
|
||||
### 2026-03-02 - Phase 4 advanced actions (copy workflow) landed
|
||||
|
||||
Implemented in `src/seedpass/tui_v2/app.py`:
|
||||
|
||||
1. Added palette command:
|
||||
- `copy <field> (optional: confirm)`
|
||||
|
||||
2. Added kind-aware copy field resolution for:
|
||||
- `password/stored_password`: `password`, `username`, `url`
|
||||
- `seed/managed_account`: `seed|phrase`
|
||||
- `totp`: `code`, `secret`
|
||||
- `ssh`: `public`, `private`
|
||||
- `pgp`: `private`, `fingerprint`
|
||||
- `nostr`: `npub`, `nsec`
|
||||
- `key_value`: `key`, `value`
|
||||
- `document/note`: `content|text`
|
||||
|
||||
3. Sensitive copy targets now require explicit confirmation (`confirm`) before clipboard action.
|
||||
|
||||
4. Updated command reference/help coverage to include `copy`.
|
||||
|
||||
Validation:
|
||||
|
||||
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
2. Result: `29 passed`.
|
||||
|
||||
Next recommended slice:
|
||||
|
||||
1. Extend Phase 4 with explicit file export actions for advanced key kinds (SSH/PGP/Nostr material export paths with confirmation and tests).
|
||||
|
||||
### 2026-03-02 - Phase 4 advanced actions (export workflow) landed
|
||||
|
||||
Implemented in `src/seedpass/tui_v2/app.py`:
|
||||
|
||||
1. Added palette command:
|
||||
- `export-field <field> <path> (optional: confirm)`
|
||||
|
||||
2. Reused kind-aware field resolution from copy workflow and added file export writing:
|
||||
- supports same kind/field matrix as `copy`.
|
||||
- writes UTF-8 payloads to absolute or relative paths (relative paths resolve from cwd).
|
||||
|
||||
3. Sensitive export targets now require explicit confirmation (`confirm`) before writing.
|
||||
|
||||
4. Updated command discoverability/help:
|
||||
- palette placeholder
|
||||
- help summary
|
||||
- full palette reference
|
||||
|
||||
Validation:
|
||||
|
||||
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
2. Result: `29 passed`.
|
||||
|
||||
Next recommended slice:
|
||||
|
||||
1. Continue Phase 5 polish: left tree navigation model + compact/expanded density toggle and final visual alignment pass to mockups.
|
||||
|
||||
### 2026-03-02 - Phase 5 polish (density + profile tree scaffold) landed
|
||||
|
||||
Implemented in `src/seedpass/tui_v2/app.py`:
|
||||
|
||||
1. Added density controls:
|
||||
- keybind: `d` (toggle compact/comfortable)
|
||||
- palette: `density <compact|comfortable>`
|
||||
- top ribbon, left panel, and grid heading now show active density mode
|
||||
|
||||
2. Added left-panel profile tree scaffold:
|
||||
- new `Profiles` section in left panel
|
||||
- shows known profiles from profile service when available
|
||||
- marks active fingerprint line
|
||||
|
||||
3. Expanded command discoverability:
|
||||
- palette placeholder/help/reference include `density` command.
|
||||
|
||||
Validation:
|
||||
|
||||
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
2. Result: `29 passed`.
|
||||
|
||||
Next recommended slice:
|
||||
|
||||
1. Continue Phase 5 by adding explicit keyboard navigation inside the left profile tree and wiring profile-selection actions to update active context.
|
||||
@@ -954,3 +954,76 @@
|
||||
- Result: `28 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` now records Phase 3 progress and sets Phase 4 advanced-kind board templates as next slice.
|
||||
|
||||
## 2026-03-02 UI refresh implementation slice: Phase 4 advanced-kind actions (copy workflow)
|
||||
- Implemented advanced-kind inspector action command in `src/seedpass/tui_v2/app.py`:
|
||||
- `copy <field> (optional: confirm)`
|
||||
- Added kind-specific copy field resolution:
|
||||
- password/stored_password: `password`, `username`, `url`
|
||||
- seed/managed_account: `seed|phrase`
|
||||
- totp: `code`, `secret`
|
||||
- ssh: `public`, `private`
|
||||
- pgp: `private`, `fingerprint`
|
||||
- nostr: `npub`, `nsec`
|
||||
- key_value: `key`, `value`
|
||||
- document/note: `content|text`
|
||||
- Sensitive field copies now require explicit `confirm`.
|
||||
- Updated palette help/reference strings to include copy action.
|
||||
- Tests added/updated:
|
||||
- `src/tests/test_tui_v2_textual_interactions.py`
|
||||
- new test: `test_tui2_textual_copy_command_for_core_and_advanced_fields`
|
||||
- validation assertions for copy usage errors
|
||||
- `src/tests/test_tui_v2_action_matrix.py`
|
||||
- matrix now exercises copy usage and success paths
|
||||
- Validation:
|
||||
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
- Result: `29 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` marks this Phase 4 action slice and sets advanced-key export actions as next recommendation.
|
||||
|
||||
## 2026-03-02 UI refresh implementation slice: Phase 4 advanced-kind actions (export workflow)
|
||||
- Implemented export action command in `src/seedpass/tui_v2/app.py`:
|
||||
- `export-field <field> <path> (optional: confirm)`
|
||||
- Export workflow details:
|
||||
- uses the same field/kind resolver as `copy`
|
||||
- writes UTF-8 payloads to requested file path
|
||||
- supports relative paths (resolved from current working directory)
|
||||
- creates parent directories when needed
|
||||
- Sensitive field exports now require explicit `confirm`.
|
||||
- Discoverability updates:
|
||||
- command palette placeholder now includes `export-field`
|
||||
- help summary and palette reference include `export-field`
|
||||
- Tests added/updated:
|
||||
- `src/tests/test_tui_v2_textual_interactions.py`
|
||||
- extended copy test to cover `export-field` success and confirmation gating
|
||||
- validation test covers usage errors
|
||||
- `src/tests/test_tui_v2_action_matrix.py`
|
||||
- matrix now exercises export-field usage + success paths
|
||||
- Validation:
|
||||
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
- Result: `29 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` marks export workflow landed and points next to Phase 5 polish (tree nav + density toggle + final mockup alignment).
|
||||
|
||||
## 2026-03-02 UI refresh implementation slice: Phase 5 polish (density + profile tree scaffold)
|
||||
- Implemented density controls in `src/seedpass/tui_v2/app.py`:
|
||||
- keybind: `d` via `action_toggle_density`
|
||||
- palette command: `density <compact|comfortable>`
|
||||
- density state shown in top ribbon, left panel, and grid heading
|
||||
- Implemented left-panel profile tree scaffold:
|
||||
- new `Profiles` section in left panel
|
||||
- uses profile service list when available
|
||||
- marks active fingerprint entry
|
||||
- Discoverability updates:
|
||||
- command palette placeholder/help/reference include density command
|
||||
- Tests updated:
|
||||
- `src/tests/test_tui_v2_textual_interactions.py`
|
||||
- pagination flow now verifies keybind/palette density success
|
||||
- settings validation now covers density usage/invalid-mode errors
|
||||
- `src/tests/test_tui_v2_action_matrix.py`
|
||||
- matrix now exercises density usage error + valid mode switches
|
||||
- Validation:
|
||||
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
|
||||
- Result: `29 passed`.
|
||||
- Refresh plan updated:
|
||||
- `docs/tui_v2_ui_refresh_plan.md` now tracks Phase 5 density/tree scaffold progress and points next to explicit tree keyboard navigation + selection actions.
|
||||
+224
-7
@@ -288,6 +288,7 @@ def launch_tui2(
|
||||
("2", "focus_center", "Center"),
|
||||
("3", "focus_right", "Right"),
|
||||
("f", "cycle_filter", "Filter"),
|
||||
("d", "toggle_density", "Density"),
|
||||
("h", "cycle_archive_scope", "Archive View"),
|
||||
("p", "prev_page", "Prev Page"),
|
||||
("n", "next_page", "Next Page"),
|
||||
@@ -319,6 +320,7 @@ def launch_tui2(
|
||||
placeholder=(
|
||||
"Command palette: help | open <id> | search <q> | "
|
||||
"help-commands | onboarding | quickstart | stats | session-status | lock | unlock <password> | "
|
||||
"density <compact|comfortable> | "
|
||||
"filter <kind> | archive | restore | "
|
||||
"archive-filter <active|all|archived> | "
|
||||
"add-password <label> <length> [username] [url] | "
|
||||
@@ -344,6 +346,8 @@ def launch_tui2(
|
||||
"totp-export <path> | parent-seed-backup [path] [password] | "
|
||||
"managed-load [entry_id] | managed-exit | session-status | lock | unlock <password> | "
|
||||
"doc-export [output_path] | "
|
||||
"copy <field> [confirm] | "
|
||||
"export-field <field> <path> [confirm] | "
|
||||
"link-add <target> [relation] [note] | "
|
||||
"link-rm <target> [relation] | "
|
||||
"reveal [confirm] | qr [public|private] [confirm] | "
|
||||
@@ -425,6 +429,7 @@ def launch_tui2(
|
||||
self._session_locked = False
|
||||
self._managed_session_entry_id: int | None = None
|
||||
self._last_sync_text = "(none)"
|
||||
self._density_mode = "compact"
|
||||
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
|
||||
try:
|
||||
self._service = (
|
||||
@@ -550,6 +555,7 @@ def launch_tui2(
|
||||
f" | Kind: {self.filter_kind}"
|
||||
f" | Archive: {self.archive_scope}"
|
||||
f" | Session: {lock_state}"
|
||||
f" | Density: {self._density_mode}"
|
||||
f" | Last Sync: {self._last_sync_text}"
|
||||
)
|
||||
self.query_one("#top-ribbon", Static).update(text)
|
||||
@@ -593,7 +599,8 @@ def launch_tui2(
|
||||
sort_line = (
|
||||
"Sort: index ▲▼ | entry ▲▼ | title ▲▼ | kind ▲▼ "
|
||||
f"Page {self._result_page + 1}/{self._total_pages()} "
|
||||
f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)}"
|
||||
f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)} "
|
||||
f"Density: {self._density_mode}"
|
||||
)
|
||||
self.query_one("#grid-heading", Static).update(
|
||||
f"Entry Grid\n{table_cols}\n{sort_line}"
|
||||
@@ -635,12 +642,13 @@ def launch_tui2(
|
||||
return (
|
||||
"Palette commands: help, help-commands, open, search, filter, "
|
||||
"onboarding, quickstart, stats, session-status, lock/unlock, "
|
||||
"density, "
|
||||
"archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, "
|
||||
"link-add/link-rm/link-filter/link-next/link-prev/link-open, "
|
||||
"add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, "
|
||||
"npub, nostr-reset-sync-state, nostr-fresh-namespace, sync-now/sync-bg, "
|
||||
"checksum-*, db-*, totp-export, parent-seed-backup, managed-load/exit, "
|
||||
"reveal/qr, page-*, retry, jump."
|
||||
"reveal/qr/copy/export-field, page-*, retry, jump."
|
||||
)
|
||||
|
||||
def _palette_reference_text(self) -> str:
|
||||
@@ -650,12 +658,12 @@ def launch_tui2(
|
||||
"-----------------",
|
||||
"",
|
||||
"Core: help | help-commands | open <id> | jump <id> | search <q>",
|
||||
"Core Ops: onboarding | quickstart | stats | session-status | lock | unlock <password>",
|
||||
"Core Ops: onboarding | quickstart | stats | session-status | lock | unlock <password> | density <compact|comfortable>",
|
||||
"Filters: filter <kind|all> | archive-filter <active|all|archived>",
|
||||
"Pages: page-next | page-prev | page <n>",
|
||||
"",
|
||||
"Entry Actions: archive | restore | reveal [confirm] | qr [public|private] [confirm]",
|
||||
"Docs: edit-doc | save-doc | cancel-edit | doc-export [output_path]",
|
||||
"Docs: edit-doc | save-doc | cancel-edit | doc-export [output_path] | copy <field> [confirm] | export-field <field> <path> [confirm]",
|
||||
"Graph: link-add <target> [relation] [note] | link-rm <target> [relation]",
|
||||
"Graph Nav: link-filter <relation|all> | link-next | link-prev | link-open",
|
||||
"",
|
||||
@@ -774,6 +782,23 @@ def launch_tui2(
|
||||
return f"Selected: #{self._selected_entry_id} [{kind}] {label}"
|
||||
|
||||
def _update_filters_panel(self) -> None:
|
||||
tree_lines = ["Profiles", "--------"]
|
||||
profiles: list[str] = []
|
||||
if self._profile_service is not None:
|
||||
lister = getattr(self._profile_service, "list_profiles", None)
|
||||
if callable(lister):
|
||||
try:
|
||||
profiles = [str(item) for item in lister()]
|
||||
except Exception:
|
||||
profiles = []
|
||||
current_fp = fingerprint or ""
|
||||
if profiles:
|
||||
for item in profiles[:8]:
|
||||
marker = "*" if item == current_fp else "-"
|
||||
tree_lines.append(f"{marker} {item}")
|
||||
else:
|
||||
tree_lines.append(f"* {current_fp or '(default)'}")
|
||||
|
||||
fp_line = (
|
||||
f"Fingerprint: {fingerprint}"
|
||||
if fingerprint
|
||||
@@ -794,6 +819,7 @@ def launch_tui2(
|
||||
f"Filter : {self.filter_kind}",
|
||||
f"Archive: {self.archive_scope}",
|
||||
f"Links : {self.link_relation_filter}",
|
||||
f"Density: {self._density_mode}",
|
||||
f"Session: {session_state}",
|
||||
f"Managed: {managed_state}",
|
||||
(
|
||||
@@ -801,10 +827,13 @@ def launch_tui2(
|
||||
f"Page: {self._result_page + 1}/{self._total_pages()}"
|
||||
),
|
||||
"",
|
||||
*tree_lines,
|
||||
"",
|
||||
"Nav ⌘",
|
||||
"- / search",
|
||||
"- j jump to id",
|
||||
"- f cycle kind filter",
|
||||
"- d density toggle",
|
||||
"- h cycle archive scope",
|
||||
"- p/n prev/next page",
|
||||
"- 1/2/3 focus pane",
|
||||
@@ -850,7 +879,11 @@ def launch_tui2(
|
||||
url: str | None = None,
|
||||
) -> str:
|
||||
entry_num = idx
|
||||
title = (label or "").replace("\n", " ").strip()[:25]
|
||||
title_limit = 25 if self._density_mode == "compact" else 34
|
||||
meta_limit = 20 if self._density_mode == "compact" else 28
|
||||
title_width = title_limit
|
||||
meta_width = meta_limit
|
||||
title = (label or "").replace("\n", " ").strip()[:title_limit]
|
||||
kind = (etype or "unknown")[:14]
|
||||
if url:
|
||||
meta = str(url).replace("\n", " ").strip()
|
||||
@@ -858,9 +891,12 @@ def launch_tui2(
|
||||
meta = str(username).replace("\n", " ").strip()
|
||||
else:
|
||||
meta = "-"
|
||||
meta = meta[:20]
|
||||
meta = meta[:meta_limit]
|
||||
arch = "YES" if archived else "NO"
|
||||
return f"{idx:<8} {entry_num:<6} {title:<25} {kind:<14} {meta:<20} {arch:<4}"
|
||||
return (
|
||||
f"{idx:<8} {entry_num:<6} {title:<{title_width}} "
|
||||
f"{kind:<14} {meta:<{meta_width}} {arch:<4}"
|
||||
)
|
||||
|
||||
def _total_pages(self) -> int:
|
||||
_page, _start, _end, total_pages = pagination_window(
|
||||
@@ -1861,6 +1897,93 @@ def launch_tui2(
|
||||
raise ValueError("Selected entry not found")
|
||||
return dict(entry)
|
||||
|
||||
def _resolve_copy_field_value(
|
||||
self, field: str
|
||||
) -> tuple[str, bool, str]:
|
||||
if self._service is None:
|
||||
raise ValueError("Entry service unavailable")
|
||||
entry = self._selected_entry_payload()
|
||||
entry_id = int(self._selected_entry_id or 0)
|
||||
kind = self._entry_kind(entry)
|
||||
key = field.strip().lower()
|
||||
|
||||
if kind in {"password", "stored_password"}:
|
||||
if key in {"password", "pass"}:
|
||||
length = int(entry.get("length", 16))
|
||||
return (
|
||||
str(self._service.generate_password(length, entry_id)),
|
||||
True,
|
||||
"password",
|
||||
)
|
||||
if key == "username":
|
||||
return str(entry.get("username") or ""), False, "username"
|
||||
if key == "url":
|
||||
return str(entry.get("url") or ""), False, "url"
|
||||
raise ValueError("copy field unsupported for password: password|username|url")
|
||||
|
||||
if kind in {"seed", "managed_account"}:
|
||||
if key not in {"seed", "phrase"}:
|
||||
raise ValueError("copy field unsupported for seed: seed|phrase")
|
||||
getter = (
|
||||
self._service.get_managed_account_seed
|
||||
if kind == "managed_account"
|
||||
else self._service.get_seed_phrase
|
||||
)
|
||||
return str(getter(entry_id)), True, "seed"
|
||||
|
||||
if kind == "totp":
|
||||
if key == "code":
|
||||
return str(self._service.get_totp_code(entry_id)), True, "code"
|
||||
if key == "secret":
|
||||
return str(self._service.get_totp_secret(entry_id)), True, "secret"
|
||||
raise ValueError("copy field unsupported for totp: code|secret")
|
||||
|
||||
if kind == "ssh":
|
||||
private_key, public_key = self._service.get_ssh_key_pair(entry_id)
|
||||
if key in {"public", "public_key"}:
|
||||
return str(public_key), False, "public_key"
|
||||
if key in {"private", "private_key"}:
|
||||
return str(private_key), True, "private_key"
|
||||
raise ValueError("copy field unsupported for ssh: public|private")
|
||||
|
||||
if kind == "pgp":
|
||||
private_key, fingerprint_text = self._service.get_pgp_key(entry_id)
|
||||
if key in {"private", "private_key"}:
|
||||
return str(private_key), True, "private_key"
|
||||
if key in {"fingerprint", "fpr"}:
|
||||
return str(fingerprint_text), False, "fingerprint"
|
||||
raise ValueError("copy field unsupported for pgp: private|fingerprint")
|
||||
|
||||
if kind == "nostr":
|
||||
npub, nsec = self._service.get_nostr_key_pair(entry_id)
|
||||
if key == "npub":
|
||||
return str(npub), False, "npub"
|
||||
if key == "nsec":
|
||||
return str(nsec), True, "nsec"
|
||||
raise ValueError("copy field unsupported for nostr: npub|nsec")
|
||||
|
||||
if kind == "key_value":
|
||||
if key == "key":
|
||||
return str(entry.get("key") or ""), False, "key"
|
||||
if key == "value":
|
||||
return str(entry.get("value") or ""), True, "value"
|
||||
raise ValueError("copy field unsupported for key_value: key|value")
|
||||
|
||||
if kind in {"document", "note"}:
|
||||
if key not in {"content", "text"}:
|
||||
raise ValueError("copy field unsupported for document: content|text")
|
||||
return str(entry.get("content") or ""), False, "content"
|
||||
|
||||
raise ValueError(f"copy not supported for kind '{kind or 'unknown'}'")
|
||||
|
||||
def _export_value_to_path(self, value: str, output_path: str) -> Path:
|
||||
raw = Path(output_path).expanduser()
|
||||
if not raw.is_absolute():
|
||||
raw = Path.cwd() / raw
|
||||
raw.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw.write_text(value, encoding="utf-8")
|
||||
return raw
|
||||
|
||||
def _apply_selected_modify(
|
||||
self,
|
||||
*,
|
||||
@@ -1977,6 +2100,19 @@ def launch_tui2(
|
||||
self.query_one("#entry-detail", Static).update(self._stats_text())
|
||||
self._set_status("Displayed vault stats")
|
||||
return
|
||||
if cmd == "density":
|
||||
if len(args) != 1:
|
||||
self._set_status("Usage: density <compact|comfortable>")
|
||||
return
|
||||
mode = args[0].strip().lower()
|
||||
if mode not in {"compact", "comfortable"}:
|
||||
self._set_status("density must be one of: compact, comfortable")
|
||||
return
|
||||
self._density_mode = mode
|
||||
self._render_current_page(preserve_selected=True)
|
||||
self._update_filters_panel()
|
||||
self._set_status(f"Density set to {mode}")
|
||||
return
|
||||
if cmd == "session-status":
|
||||
if args:
|
||||
self._set_status("Usage: session-status")
|
||||
@@ -3541,6 +3677,76 @@ def launch_tui2(
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "copy":
|
||||
if self._service is None:
|
||||
self._set_status("Entry service unavailable")
|
||||
return
|
||||
if len(args) < 1 or len(args) > 2:
|
||||
self._set_status("Usage: copy <field> (optional: confirm)")
|
||||
return
|
||||
field = args[0].strip()
|
||||
confirm = False
|
||||
if len(args) == 2:
|
||||
if args[1].strip().lower() != "confirm":
|
||||
self._set_status("Usage: copy <field> (optional: confirm)")
|
||||
return
|
||||
confirm = True
|
||||
try:
|
||||
value, sensitive, canonical = self._resolve_copy_field_value(field)
|
||||
except Exception as exc:
|
||||
self._set_status(str(exc))
|
||||
return
|
||||
if sensitive and not confirm:
|
||||
self._set_status(
|
||||
f"copy {canonical} is sensitive. Re-run with: copy {field} confirm"
|
||||
)
|
||||
return
|
||||
if not self._copy_to_clipboard(value):
|
||||
self._set_status("Clipboard unavailable")
|
||||
return
|
||||
self._set_status(f"Copied {canonical} to clipboard")
|
||||
return
|
||||
|
||||
if cmd == "export-field":
|
||||
if self._service is None:
|
||||
self._set_status("Entry service unavailable")
|
||||
return
|
||||
if len(args) not in {2, 3}:
|
||||
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
|
||||
return
|
||||
field = args[0].strip()
|
||||
output_path = args[1].strip()
|
||||
if not output_path:
|
||||
self._set_status("export-field path is required")
|
||||
return
|
||||
confirm = False
|
||||
if len(args) == 3:
|
||||
if args[2].strip().lower() != "confirm":
|
||||
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
|
||||
return
|
||||
confirm = True
|
||||
try:
|
||||
value, sensitive, canonical = self._resolve_copy_field_value(field)
|
||||
except Exception as exc:
|
||||
self._set_status(str(exc))
|
||||
return
|
||||
if sensitive and not confirm:
|
||||
self._set_status(
|
||||
f"export-field {canonical} is sensitive. Re-run with: export-field {field} {output_path} confirm"
|
||||
)
|
||||
return
|
||||
try:
|
||||
destination = self._export_value_to_path(value, output_path)
|
||||
self._set_status(f"Exported {canonical} to {destination}")
|
||||
except Exception as exc:
|
||||
self._record_failure(
|
||||
"export-field failed",
|
||||
exc,
|
||||
retry=lambda: self._run_palette_command(raw),
|
||||
hint="Press 'x' to retry.",
|
||||
)
|
||||
return
|
||||
|
||||
if cmd in ("archive", "restore"):
|
||||
if self._service is None or self._selected_entry_id is None:
|
||||
self._set_status("No entry selected")
|
||||
@@ -3903,6 +4109,17 @@ def launch_tui2(
|
||||
self._load_entries(query=self._last_query, reset_page=True)
|
||||
self._set_status(f"Applied archive filter: {self.archive_scope}")
|
||||
|
||||
def action_toggle_density(self) -> None:
|
||||
if self.editing_document:
|
||||
self._set_status("Finish document edit before changing density")
|
||||
return
|
||||
self._density_mode = (
|
||||
"comfortable" if self._density_mode == "compact" else "compact"
|
||||
)
|
||||
self._render_current_page(preserve_selected=True)
|
||||
self._update_filters_panel()
|
||||
self._set_status(f"Density: {self._density_mode}")
|
||||
|
||||
def action_next_page(self) -> None:
|
||||
if not self._all_results:
|
||||
self._set_status("No entries loaded")
|
||||
|
||||
@@ -504,6 +504,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
|
||||
app._run_palette_command("page")
|
||||
app._run_palette_command("page abc")
|
||||
app._run_palette_command("page 0")
|
||||
app._run_palette_command("density")
|
||||
app._run_palette_command("density noisy")
|
||||
app._run_palette_command("help-commands now")
|
||||
app._run_palette_command("onboarding now")
|
||||
app._run_palette_command("quickstart now")
|
||||
@@ -577,6 +579,10 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
|
||||
app._run_palette_command("lock now")
|
||||
app._run_palette_command("unlock")
|
||||
app._run_palette_command("doc-export one two")
|
||||
app._run_palette_command("copy")
|
||||
app._run_palette_command("copy password nope")
|
||||
app._run_palette_command("export-field")
|
||||
app._run_palette_command("export-field private /tmp/matrix-key.bad nope")
|
||||
app._run_palette_command("filter")
|
||||
app._run_palette_command("archive-filter")
|
||||
app._run_palette_command("archive-filter bogus")
|
||||
@@ -592,6 +598,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
|
||||
app._run_palette_command("lock")
|
||||
app._run_palette_command("unlock hunter2")
|
||||
app._run_palette_command("search Doc")
|
||||
app._run_palette_command("density comfortable")
|
||||
app._run_palette_command("density compact")
|
||||
app._run_palette_command("filter document")
|
||||
app._run_palette_command("archive-filter all")
|
||||
app._run_palette_command("archive-filter archived")
|
||||
@@ -624,9 +632,13 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
|
||||
app._run_palette_command("2fa-refresh")
|
||||
app._run_palette_command("2fa-hide")
|
||||
app._run_palette_command("doc-export /tmp/seedpass-matrix-export")
|
||||
app._run_palette_command("copy content")
|
||||
app._run_palette_command("export-field content /tmp/seedpass-matrix-content.txt")
|
||||
await pilot.pause()
|
||||
|
||||
app._run_palette_command("open 2")
|
||||
app._run_palette_command("copy password confirm")
|
||||
app._run_palette_command("export-field password /tmp/seedpass-matrix-password.txt confirm")
|
||||
app.action_reveal_selected()
|
||||
await pilot.pause()
|
||||
assert "pw-2-" in str(app.query_one("#secret-detail").render())
|
||||
|
||||
@@ -643,6 +643,16 @@ async def test_tui2_textual_pagination_and_search_flow() -> None:
|
||||
await pilot.pause()
|
||||
assert "Page: 2/3" in _filters_text(app)
|
||||
|
||||
app.action_toggle_density()
|
||||
await pilot.pause()
|
||||
assert "Density: comfortable" in _status_text(app)
|
||||
assert "Density: comfortable" in _filters_text(app)
|
||||
|
||||
app._run_palette_command("density compact")
|
||||
await pilot.pause()
|
||||
assert "Density set to compact" in _status_text(app)
|
||||
assert "Density: compact" in _filters_text(app)
|
||||
|
||||
app.query_one("#search", Input).value = "Entry 44"
|
||||
app._load_entries(query="Entry 44", reset_page=True)
|
||||
await pilot.pause()
|
||||
@@ -978,6 +988,99 @@ async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> Non
|
||||
assert "Nostr #2 QR" in _widget_text(app, "#secret-detail")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_copy_command_for_core_and_advanced_fields(tmp_path) -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"kind": "password",
|
||||
"label": "Login 1",
|
||||
"length": 16,
|
||||
"username": "alice",
|
||||
"url": "https://example.com",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"kind": "ssh",
|
||||
"label": "SSH 2",
|
||||
"private_key": "SSH_PRIVATE_2",
|
||||
"public_key": "ssh-ed25519 AAAA-2",
|
||||
},
|
||||
{"id": 3, "kind": "nostr", "label": "Nostr 3", "npub": "npub3", "nsec": "nsec3"},
|
||||
{"id": 4, "kind": "key_value", "label": "KV 4", "key": "TOKEN", "value": "abc123"},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
app._run_palette_command("copy password")
|
||||
await pilot.pause()
|
||||
assert "copy password is sensitive" in _status_text(app)
|
||||
|
||||
app._run_palette_command("copy password confirm")
|
||||
await pilot.pause()
|
||||
assert "Copied password to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "pw-1-16"
|
||||
|
||||
app._run_palette_command("copy username")
|
||||
await pilot.pause()
|
||||
assert "Copied username to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "alice"
|
||||
|
||||
app._run_palette_command("open 2")
|
||||
app._run_palette_command("copy private")
|
||||
await pilot.pause()
|
||||
assert "copy private_key is sensitive" in _status_text(app)
|
||||
app._run_palette_command("copy private confirm")
|
||||
await pilot.pause()
|
||||
assert "Copied private_key to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "SSH_PRIVATE_2"
|
||||
|
||||
app._run_palette_command("open 3")
|
||||
app._run_palette_command("copy npub")
|
||||
await pilot.pause()
|
||||
assert "Copied npub to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "npub3"
|
||||
|
||||
app._run_palette_command("copy nsec confirm")
|
||||
await pilot.pause()
|
||||
assert "Copied nsec to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "nsec3"
|
||||
|
||||
app._run_palette_command("open 4")
|
||||
app._run_palette_command("copy value confirm")
|
||||
await pilot.pause()
|
||||
assert "Copied value to clipboard" in _status_text(app)
|
||||
assert service.clipboard_values[-1] == "abc123"
|
||||
|
||||
app._run_palette_command("export-field value value.txt confirm")
|
||||
await pilot.pause()
|
||||
assert "Exported value to" in _status_text(app)
|
||||
assert (Path.cwd() / "value.txt").exists()
|
||||
assert (Path.cwd() / "value.txt").read_text(encoding="utf-8") == "abc123"
|
||||
(Path.cwd() / "value.txt").unlink(missing_ok=True)
|
||||
|
||||
out_file = tmp_path / "nostr.nsec"
|
||||
app._run_palette_command("open 3")
|
||||
app._run_palette_command(f"export-field nsec {out_file}")
|
||||
await pilot.pause()
|
||||
assert "export-field nsec is sensitive" in _status_text(app)
|
||||
|
||||
app._run_palette_command(f"export-field nsec {out_file} confirm")
|
||||
await pilot.pause()
|
||||
assert "Exported nsec to" in _status_text(app)
|
||||
assert out_file.read_text(encoding="utf-8") == "nsec3"
|
||||
|
||||
app._run_palette_command("open 4")
|
||||
app._run_palette_command("copy bogus")
|
||||
await pilot.pause()
|
||||
assert "copy field unsupported for key_value" in _status_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_retry_after_search_failure() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -1792,6 +1895,30 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
|
||||
await pilot.pause()
|
||||
assert "Usage: unlock <password>" in _status_text(app)
|
||||
|
||||
app._run_palette_command("copy")
|
||||
await pilot.pause()
|
||||
assert "Usage: copy <field> (optional: confirm)" in _status_text(app)
|
||||
|
||||
app._run_palette_command("copy password nope")
|
||||
await pilot.pause()
|
||||
assert "Usage: copy <field> (optional: confirm)" in _status_text(app)
|
||||
|
||||
app._run_palette_command("export-field")
|
||||
await pilot.pause()
|
||||
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
|
||||
|
||||
app._run_palette_command("export-field password /tmp/x nope")
|
||||
await pilot.pause()
|
||||
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
|
||||
|
||||
app._run_palette_command("density")
|
||||
await pilot.pause()
|
||||
assert "Usage: density <compact|comfortable>" in _status_text(app)
|
||||
|
||||
app._run_palette_command("density noisy")
|
||||
await pilot.pause()
|
||||
assert "density must be one of: compact, comfortable" in _status_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_palette_session_status_lock_and_unlock() -> None:
|
||||
|
||||
Reference in new issue
Block a user