mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
tui2: add profile-aware filter menu and sensitive-state UX polish
This commit is contained in:
1 parent
a7ab61fae9
commit
9fc8abccc8
2 files changed
+475
-75
No files matched your search
+347
-73
@@ -110,7 +110,7 @@ def launch_tui2(
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||
from textual.widgets import Button, Input, Label, ListItem, ListView, Static
|
||||
|
||||
try:
|
||||
from textual.widgets import TextArea
|
||||
@@ -125,6 +125,22 @@ def launch_tui2(
|
||||
class SeedPassTuiV2(App[None]):
|
||||
RESULT_PAGE_SIZE = 200
|
||||
DETAIL_CONTENT_PREVIEW_LIMIT = 4000
|
||||
FILTER_PRESETS: dict[str, list[str] | None] = {
|
||||
"all": None,
|
||||
"secrets": [
|
||||
"password",
|
||||
"stored_password",
|
||||
"seed",
|
||||
"managed_account",
|
||||
"ssh",
|
||||
"pgp",
|
||||
"nostr",
|
||||
"key_value",
|
||||
],
|
||||
"docs": ["document", "note"],
|
||||
"keys": ["seed", "managed_account", "ssh", "pgp", "nostr"],
|
||||
"2fa": ["totp"],
|
||||
}
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
@@ -183,7 +199,7 @@ def launch_tui2(
|
||||
#body { height: 1fr; margin: 0 1; }
|
||||
#top-work { height: 6fr; }
|
||||
#status {
|
||||
height: 4;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
border: heavy #58f29d;
|
||||
background: #11191f;
|
||||
@@ -191,7 +207,7 @@ def launch_tui2(
|
||||
margin: 1 1 0 1;
|
||||
}
|
||||
#action-strip {
|
||||
height: 4;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
margin: 0 1;
|
||||
border: heavy #2abf75;
|
||||
@@ -199,6 +215,17 @@ def launch_tui2(
|
||||
color: #daf2e5;
|
||||
}
|
||||
#left { width: 31; border: solid #1a3024; padding: 0 1; background: #0d1114; }
|
||||
#left.sidebar-collapsed { width: 3; padding: 0; }
|
||||
#sidebar-toggle {
|
||||
height: 1;
|
||||
min-height: 1;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
content-align: left middle;
|
||||
}
|
||||
#center { width: 1fr; border: solid #1a3024; padding: 0 1; background: #0d1114; margin-left: 1; }
|
||||
#right { height: 5fr; border: heavy #3ce79c; padding: 1; background: #0d1114; margin-top: 0; }
|
||||
#grid-heading {
|
||||
@@ -228,27 +255,31 @@ def launch_tui2(
|
||||
}
|
||||
#search { margin-bottom: 0; }
|
||||
#quick-jump { margin-bottom: 0; }
|
||||
#kind-filter-input {
|
||||
margin-bottom: 0;
|
||||
border: solid #2abf75;
|
||||
}
|
||||
#entry-list { height: 1fr; }
|
||||
#entry-detail {
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
overflow: auto;
|
||||
border: solid #58f29d;
|
||||
padding: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
#link-detail {
|
||||
min-height: 3;
|
||||
height: 2fr;
|
||||
overflow: auto;
|
||||
border: solid #2abf75;
|
||||
padding: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
#secret-detail {
|
||||
min-height: 3;
|
||||
height: 1fr;
|
||||
overflow: auto;
|
||||
border: solid #58f29d;
|
||||
padding: 1;
|
||||
padding: 0 1;
|
||||
margin-top: 0;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
@@ -296,7 +327,8 @@ def launch_tui2(
|
||||
("1", "focus_left", "Left"),
|
||||
("2", "focus_center", "Center"),
|
||||
("3", "focus_right", "Right"),
|
||||
("f", "cycle_filter", "Filter"),
|
||||
("f", "toggle_filter_menu", "Filter Menu"),
|
||||
("shift+f", "cycle_filter", "Filter"),
|
||||
("d", "toggle_density", "Density"),
|
||||
("h", "cycle_archive_scope", "Archive View"),
|
||||
("up", "profile_tree_prev", "Profile Prev"),
|
||||
@@ -315,6 +347,7 @@ def launch_tui2(
|
||||
("e", "edit_document", "Edit Document"),
|
||||
("ctrl+s", "save_document", "Save"),
|
||||
("ctrl+p", "open_palette", "Palette"),
|
||||
("ctrl+b", "toggle_sidebar", "Sidebar"),
|
||||
("escape", "cancel_document_edit", "Cancel"),
|
||||
]
|
||||
|
||||
@@ -332,7 +365,7 @@ def launch_tui2(
|
||||
"Command palette: help | open <id> | search <q> | "
|
||||
"help-commands | onboarding | quickstart | stats | session-status | lock | unlock <password> | "
|
||||
"density <compact|comfortable> | "
|
||||
"filter <kind> | archive | restore | "
|
||||
"filter <kind|comma-list|all|secrets|docs|keys|2fa> | archive | restore | "
|
||||
"archive-filter <active|all|archived> | "
|
||||
"add-password <label> <length> [username] [url] | "
|
||||
"add-totp <label> [period] [digits] [secret] | "
|
||||
@@ -376,6 +409,7 @@ def launch_tui2(
|
||||
with Vertical(id="body"):
|
||||
with Horizontal(id="top-work"):
|
||||
with Vertical(id="left"):
|
||||
yield Button("◀ Collapse", id="sidebar-toggle")
|
||||
yield Static("", id="filters")
|
||||
yield Static("", id="activity")
|
||||
with Vertical(id="center"):
|
||||
@@ -386,6 +420,11 @@ def launch_tui2(
|
||||
yield Input(
|
||||
placeholder="Jump to entry id (Enter)", id="quick-jump"
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Filter presets: all | secrets | docs | keys | 2fa (Enter)",
|
||||
id="kind-filter-input",
|
||||
classes="hidden",
|
||||
)
|
||||
yield ListView(id="entry-list")
|
||||
with Vertical(id="right"):
|
||||
with Vertical(id="right-view"):
|
||||
@@ -456,6 +495,9 @@ def launch_tui2(
|
||||
self._compact_layout = False
|
||||
self._viewport_width = 0
|
||||
self._viewport_height = 0
|
||||
self._sidebar_collapsed = False
|
||||
self._active_profile_fp = (fingerprint or "").strip()
|
||||
self._profile_filter_by_fp: dict[str, str] = {}
|
||||
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
|
||||
try:
|
||||
self._service = (
|
||||
@@ -535,6 +577,7 @@ def launch_tui2(
|
||||
self._update_activity_panel()
|
||||
self._apply_focus_style()
|
||||
self._refresh_profile_tree()
|
||||
self._restore_filter_for_active_profile(default_filter="all")
|
||||
self._update_top_ribbon()
|
||||
self._update_action_strip()
|
||||
self._set_secret_panel(
|
||||
@@ -582,39 +625,58 @@ def launch_tui2(
|
||||
text = "\n".join(lines)
|
||||
self.query_one("#activity", Static).update(text)
|
||||
|
||||
def _set_secret_panel(self, text: str) -> None:
|
||||
self.query_one("#secret-detail", Static).update(text)
|
||||
def _set_secret_panel(self, text: str, *, state: str | None = "HIDDEN") -> None:
|
||||
if state:
|
||||
rendered = f"Sensitive State: {state}\n\n{text}"
|
||||
else:
|
||||
rendered = text
|
||||
self.query_one("#secret-detail", Static).update(rendered)
|
||||
|
||||
def _select_highlighted_entry_for_sensitive_action(self) -> bool:
|
||||
if self._selected_entry_id is not None:
|
||||
return True
|
||||
list_view = self.query_one("#entry-list", ListView)
|
||||
item = getattr(list_view, "highlighted_child", None)
|
||||
if isinstance(item, EntryListItem):
|
||||
self._show_entry(item.entry_index)
|
||||
return self._selected_entry_id is not None
|
||||
|
||||
def _update_top_ribbon(self) -> None:
|
||||
lock_state = "locked" if self._session_locked else "unlocked"
|
||||
managed = (
|
||||
str(self._managed_session_entry_id)
|
||||
if self._managed_session_entry_id is not None
|
||||
else "0"
|
||||
)
|
||||
managed_rows, _agent_rows = self._profile_tree_child_nodes()
|
||||
managed = str(len(managed_rows))
|
||||
total = len(self._all_results)
|
||||
fp_text = fingerprint or "(default)"
|
||||
if len(fp_text) > 14:
|
||||
fp_text = f"{fp_text[:11]}..."
|
||||
pw_count = 0
|
||||
twofa_count = 0
|
||||
doc_count = 0
|
||||
key_count = 0
|
||||
for row in self._all_results:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
kind_obj = row[5]
|
||||
kind = str(getattr(kind_obj, "value", kind_obj)).strip().lower()
|
||||
if kind in {"password", "stored_password"}:
|
||||
pw_count += 1
|
||||
elif kind == "totp":
|
||||
twofa_count += 1
|
||||
elif kind in {"document", "note"}:
|
||||
doc_count += 1
|
||||
elif kind in {"seed", "managed_account", "ssh", "pgp", "nostr", "key_value"}:
|
||||
key_count += 1
|
||||
fp_text = self._active_profile_key()
|
||||
if len(fp_text) > 16:
|
||||
fp_text = f"{fp_text[:13]}..."
|
||||
sync_text = self._last_sync_text or "(none)"
|
||||
if len(sync_text) > 18:
|
||||
sync_text = f"{sync_text[:15]}..."
|
||||
sem_mode = self._semantic_mode
|
||||
if sem_mode not in {"keyword", "hybrid", "semantic"}:
|
||||
sem_mode = "keyword"
|
||||
sem_mode_short = {
|
||||
"keyword": "kw",
|
||||
"hybrid": "hyb",
|
||||
"semantic": "sem",
|
||||
}[sem_mode]
|
||||
if len(sync_text) > 26:
|
||||
sync_text = f"{sync_text[:23]}..."
|
||||
text = (
|
||||
f"FP:{fp_text}"
|
||||
f" | M:{managed}"
|
||||
f" | E:{total}"
|
||||
f" | K:{self.filter_kind}"
|
||||
f" | Session:{lock_state}"
|
||||
f" | SEM:{sem_mode_short}/{self._semantic_state}"
|
||||
f" | Sync:{sync_text}"
|
||||
f"FP {fp_text}"
|
||||
f" | Managed Users: {managed}"
|
||||
f" | Entries: {total}"
|
||||
f" | PW:{pw_count} 2FA:{twofa_count} DOC:{doc_count} KEY:{key_count}"
|
||||
f" | Kind: {self.filter_kind}"
|
||||
f" | Session: {lock_state}"
|
||||
f" | Last Sync: {sync_text}"
|
||||
)
|
||||
self.query_one("#top-ribbon", Static).update(text)
|
||||
|
||||
@@ -654,9 +716,11 @@ def launch_tui2(
|
||||
except Exception:
|
||||
profiles = []
|
||||
if not profiles:
|
||||
profiles = [fingerprint or "(default)"]
|
||||
profiles = [self._active_profile_key()]
|
||||
self._profile_tree_items = profiles
|
||||
active = fingerprint or ""
|
||||
active = self._active_profile_key()
|
||||
if active == "(default)" and "(default)" not in profiles:
|
||||
active = ""
|
||||
if active and active in profiles:
|
||||
self._profile_tree_cursor = profiles.index(active)
|
||||
else:
|
||||
@@ -664,6 +728,57 @@ def launch_tui2(
|
||||
max(0, self._profile_tree_cursor), max(0, len(profiles) - 1)
|
||||
)
|
||||
|
||||
def _profile_tree_child_nodes(self) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
||||
managed: list[tuple[int, str]] = []
|
||||
agent_like: list[tuple[int, str]] = []
|
||||
for row in self._all_results:
|
||||
if len(row) < 6:
|
||||
continue
|
||||
entry_id = int(row[0])
|
||||
label = str(row[1] or f"entry-{entry_id}").strip()
|
||||
kind_obj = row[5]
|
||||
kind = str(getattr(kind_obj, "value", kind_obj)).strip().lower()
|
||||
if kind == "managed_account" and len(managed) < 6:
|
||||
managed.append((entry_id, label))
|
||||
elif kind == "nostr" and len(agent_like) < 6:
|
||||
agent_like.append((entry_id, label))
|
||||
return managed, agent_like
|
||||
|
||||
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
|
||||
self._sidebar_collapsed = bool(collapsed)
|
||||
left = self.query_one("#left", Vertical)
|
||||
toggle = self.query_one("#sidebar-toggle", Button)
|
||||
filters = self.query_one("#filters", Static)
|
||||
activity = self.query_one("#activity", Static)
|
||||
if self._sidebar_collapsed:
|
||||
left.add_class("sidebar-collapsed")
|
||||
filters.add_class("hidden")
|
||||
activity.add_class("hidden")
|
||||
toggle.label = "▶ Expand"
|
||||
if self._focus_pane == "left":
|
||||
self._focus_pane = "center"
|
||||
self._apply_focus_style()
|
||||
else:
|
||||
left.remove_class("sidebar-collapsed")
|
||||
filters.remove_class("hidden")
|
||||
activity.remove_class("hidden")
|
||||
toggle.label = "◀ Collapse"
|
||||
if self._viewport_height >= 36 and not self._compact_layout:
|
||||
activity.remove_class("hidden")
|
||||
self._update_action_strip()
|
||||
|
||||
def _set_filter_menu_visible(self, visible: bool) -> None:
|
||||
filter_input = self.query_one("#kind-filter-input", Input)
|
||||
if visible:
|
||||
filter_input.remove_class("hidden")
|
||||
filter_input.value = ""
|
||||
filter_input.focus()
|
||||
self._set_status("Filter menu: enter all|secrets|docs|keys|2fa")
|
||||
else:
|
||||
filter_input.add_class("hidden")
|
||||
if self._focus_pane == "center":
|
||||
self.query_one("#entry-list", ListView).focus()
|
||||
|
||||
def _update_action_strip(self) -> None:
|
||||
kind = ""
|
||||
if isinstance(self._selected_entry, dict):
|
||||
@@ -692,24 +807,18 @@ def launch_tui2(
|
||||
elif kind_l == "key_value":
|
||||
context = "Entry ▣ set-field/clear-field ▣ a Archive ▣ notes/tags"
|
||||
else:
|
||||
context = "Entry ▣ v Reveal ▣ g QR ▣ e Edit ▣ a Archive"
|
||||
context = "Select an entry to view context actions."
|
||||
if kind:
|
||||
context = f"{context} ({kind_l})"
|
||||
if self._compact_layout:
|
||||
global_row = (
|
||||
"Global ▣ ? Help ▣ Ctrl+P ▣ / Search ▣ j Jump ▣ "
|
||||
"1/2/3 ▣ p/n ▣ f/h ▣ r ▣ Compact"
|
||||
)
|
||||
global_row = "S Settings A Add C Create Seed R Remove Seed H Hide/Reveal E Export I Import B Backup Ctrl+P Cmd Compact"
|
||||
if self._viewport_width and self._viewport_width < 130:
|
||||
context = context.replace("Entry ▣ ", "E ▣ ")
|
||||
context = context.replace("Entry ▣ ", "")
|
||||
context = context.replace("Reveal", "Rev")
|
||||
context = context.replace("Archive", "Arch")
|
||||
context = context.replace("confirm", "cfm")
|
||||
else:
|
||||
global_row = (
|
||||
"Global ▣ ? Help ▣ Ctrl+P Command ▣ / Search ▣ j Jump ▣ "
|
||||
"1/2/3 Focus ▣ p/n Page ▣ f Kind ▣ h Archive ▣ r Refresh"
|
||||
)
|
||||
global_row = "S Settings A Add New Entry C Create New Seed R Remove Seed H Hide/Reveal Sensitive E Export Data I Import Data B Backup Data"
|
||||
text = f"{global_row}\n{context}"
|
||||
self.query_one("#action-strip", Static).update(text)
|
||||
|
||||
@@ -759,7 +868,7 @@ def launch_tui2(
|
||||
"- link-add / tag-add / notes-set to build knowledge graph context",
|
||||
"",
|
||||
"Useful navigation:",
|
||||
"- / search, j jump, f kind filter, h archive scope",
|
||||
"- / search, j jump, f filter presets, Shift+F kind cycle, h archive scope",
|
||||
"- 1/2/3 focus panes, ? keyboard help",
|
||||
]
|
||||
)
|
||||
@@ -819,7 +928,7 @@ def launch_tui2(
|
||||
[
|
||||
"TUI v2 Quick Help (Esc to close)",
|
||||
"",
|
||||
"Core : / search j jump-id p/n page f kind h archive scope r refresh",
|
||||
"Core : / search j jump-id p/n page f filter menu Shift+F kind cycle h archive scope r refresh",
|
||||
"Modes : Ctrl+P palette e edit-doc Ctrl+S save Esc cancel/close",
|
||||
"Graph ⚯ : l relation filter brackets link select o open link target",
|
||||
"Secrets 🔑: v reveal selected secret g QR for selected entry",
|
||||
@@ -931,9 +1040,59 @@ def launch_tui2(
|
||||
self._load_entries(self._last_query, reset_page=False)
|
||||
|
||||
def _current_filter_kinds(self) -> list[str] | None:
|
||||
if self.filter_kind == "all":
|
||||
return None
|
||||
return [self.filter_kind]
|
||||
token = self._normalize_filter_kind(self.filter_kind)
|
||||
if token in self.FILTER_PRESETS:
|
||||
return self.FILTER_PRESETS[token]
|
||||
if "," in token:
|
||||
return [part for part in token.split(",") if part]
|
||||
return [token]
|
||||
|
||||
def _active_profile_key(self) -> str:
|
||||
fp = str(getattr(self, "_active_profile_fp", "") or "").strip()
|
||||
return fp if fp else "(default)"
|
||||
|
||||
def _remember_filter_for_active_profile(self) -> None:
|
||||
key = self._active_profile_key()
|
||||
self._profile_filter_by_fp[key] = self._normalize_filter_kind(
|
||||
self.filter_kind
|
||||
)
|
||||
|
||||
def _restore_filter_for_active_profile(self, *, default_filter: str = "all") -> None:
|
||||
key = self._active_profile_key()
|
||||
raw = self._profile_filter_by_fp.get(key, default_filter)
|
||||
self.filter_kind = self._normalize_filter_kind(raw)
|
||||
|
||||
def _normalize_filter_kind(self, value: str) -> str:
|
||||
token = (value or "all").strip().lower()
|
||||
if token in self.FILTER_PRESETS:
|
||||
return token
|
||||
allowed = {
|
||||
"password",
|
||||
"stored_password",
|
||||
"totp",
|
||||
"document",
|
||||
"note",
|
||||
"key_value",
|
||||
"ssh",
|
||||
"pgp",
|
||||
"nostr",
|
||||
"seed",
|
||||
"managed_account",
|
||||
}
|
||||
if "," in token:
|
||||
parts: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in token.split(","):
|
||||
item = raw.strip()
|
||||
if item in allowed and item not in seen:
|
||||
parts.append(item)
|
||||
seen.add(item)
|
||||
if parts:
|
||||
return ",".join(parts)
|
||||
return "all"
|
||||
if token in allowed:
|
||||
return token
|
||||
return "all"
|
||||
|
||||
def _current_archive_scope_flags(self) -> tuple[bool, bool]:
|
||||
if self.archive_scope == "all":
|
||||
@@ -960,22 +1119,44 @@ def launch_tui2(
|
||||
self._refresh_profile_tree()
|
||||
tree_lines = ["Profile Tree", "------------"]
|
||||
profiles = list(self._profile_tree_items)
|
||||
current_fp = fingerprint or ""
|
||||
root_fp = profiles[0] if profiles else (current_fp or "(default)")
|
||||
root_name = f"{root_fp[:12]}Seed"
|
||||
tree_lines.append(f"■ {root_name}")
|
||||
current_fp = self._active_profile_key()
|
||||
if current_fp == "(default)" and "(default)" not in profiles:
|
||||
current_fp = ""
|
||||
managed_nodes, agent_nodes = self._profile_tree_child_nodes()
|
||||
if profiles:
|
||||
for idx, item in enumerate(profiles[:8]):
|
||||
for idx, item in enumerate(profiles[:6]):
|
||||
marker = "■" if item == current_fp else "□"
|
||||
cursor = "▶" if idx == self._profile_tree_cursor else " "
|
||||
item_name = f"{item[:12]}Seed"
|
||||
tree_lines.append(f"{cursor} └─ {marker} {item_name}:{item[:10]}")
|
||||
tree_lines.append(f"{cursor} {marker} {item_name}:{item[:10]}")
|
||||
is_active_branch = (
|
||||
item == current_fp
|
||||
or (not current_fp and idx == self._profile_tree_cursor)
|
||||
)
|
||||
if not is_active_branch:
|
||||
continue
|
||||
tree_lines.append(" ├─ 👤 Managed Users")
|
||||
if managed_nodes:
|
||||
for entry_id, label in managed_nodes[:3]:
|
||||
tree_lines.append(f" │ └─ #{entry_id} {label[:20]}")
|
||||
else:
|
||||
tree_lines.append(" │ └─ (none)")
|
||||
tree_lines.append(" └─ 🤖 Agents")
|
||||
if agent_nodes:
|
||||
for entry_id, label in agent_nodes[:3]:
|
||||
tree_lines.append(f" └─ #{entry_id} {label[:20]}")
|
||||
else:
|
||||
tree_lines.append(" └─ (none)")
|
||||
else:
|
||||
tree_lines.append(f"▶ └─ ■ default:{(current_fp or '(default)')[:10]}")
|
||||
tree_lines.append(f"▶ ■ default:{(current_fp or '(default)')[:10]}")
|
||||
tree_lines.append(" ├─ 👤 Managed Users")
|
||||
tree_lines.append(" │ └─ (none)")
|
||||
tree_lines.append(" └─ 🤖 Agents")
|
||||
tree_lines.append(" └─ (none)")
|
||||
|
||||
fp_line = (
|
||||
f"Fingerprint: {fingerprint}"
|
||||
if fingerprint
|
||||
f"Fingerprint: {self._active_profile_key()}"
|
||||
if self._active_profile_key() != "(default)"
|
||||
else "Fingerprint: (default)"
|
||||
)
|
||||
session_state = "locked" if self._session_locked else "unlocked"
|
||||
@@ -1003,7 +1184,7 @@ def launch_tui2(
|
||||
"",
|
||||
*tree_lines,
|
||||
"",
|
||||
"Nav: / j f h p/n 1/2/3 ↑/↓ Ctrl+O ? r x",
|
||||
"Nav: / j f(menu) Shift+F(cycle) h p/n 1/2/3 ↑/↓ Ctrl+O Ctrl+B ? r x",
|
||||
"Act: Ctrl+P a e Ctrl+S Esc l [ ] o v g 6 (v confirm for seed/ssh/pgp)",
|
||||
]
|
||||
)
|
||||
@@ -1137,6 +1318,10 @@ def launch_tui2(
|
||||
list_view.clear()
|
||||
total = len(self._all_results)
|
||||
if total == 0:
|
||||
try:
|
||||
list_view.index = None
|
||||
except Exception:
|
||||
pass
|
||||
self._selected_entry_id = None
|
||||
self._selected_entry = None
|
||||
is_empty_vault = (
|
||||
@@ -1191,10 +1376,26 @@ def launch_tui2(
|
||||
chosen_id = None
|
||||
if preserve_selected and self._selected_entry_id in self._entry_ids_in_view:
|
||||
chosen_id = self._selected_entry_id
|
||||
elif self._entry_ids_in_view:
|
||||
chosen_id = self._entry_ids_in_view[0]
|
||||
if chosen_id is not None:
|
||||
self._show_entry(chosen_id)
|
||||
return
|
||||
self._selected_entry_id = None
|
||||
self._selected_entry = None
|
||||
self.query_one("#entry-detail", Static).update(
|
||||
"Inspector idle.\n\nSelect an entry from the grid to inspect details."
|
||||
)
|
||||
self.query_one("#link-detail", Static).update("Links: select an entry first.")
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Select an entry, then use 'v' (reveal) or 'g' (QR)."
|
||||
)
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
try:
|
||||
list_view.index = None
|
||||
except Exception:
|
||||
pass
|
||||
self._set_status("Entries loaded. Select an entry to inspect.")
|
||||
self._update_filters_panel()
|
||||
|
||||
def _entry_detail_text(self, entry: dict[str, Any]) -> str:
|
||||
payload = truncate_entry_for_display(
|
||||
@@ -2052,7 +2253,8 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive QR hidden.\n"
|
||||
"Confirmation required for this action.\n"
|
||||
"Run: qr private confirm"
|
||||
"Run: qr private confirm",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status(
|
||||
"Sensitive QR requires confirmation. Run: qr private confirm"
|
||||
@@ -2061,7 +2263,8 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive reveal hidden.\n"
|
||||
"Confirmation required for this action.\n"
|
||||
"Run: reveal confirm"
|
||||
"Run: reveal confirm",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status(
|
||||
"Sensitive reveal requires confirmation. Run: reveal confirm"
|
||||
@@ -2071,7 +2274,8 @@ def launch_tui2(
|
||||
if include_qr:
|
||||
if not qr_data:
|
||||
self._set_secret_panel(
|
||||
f"{title}\n\nQR not supported for this entry."
|
||||
f"{title}\n\nQR not supported for this entry.",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._clear_failure()
|
||||
self._set_status("QR not available for selected entry")
|
||||
@@ -2087,20 +2291,22 @@ def launch_tui2(
|
||||
)
|
||||
return
|
||||
self._set_secret_panel(
|
||||
f"{title} QR\n\n{qr_ascii}\n\nPayload: {qr_data}"
|
||||
f"{title} QR\n\n{qr_ascii}\n\nPayload: {qr_data}",
|
||||
state="QR",
|
||||
)
|
||||
self._clear_failure()
|
||||
self._set_status("Rendered QR for selected entry")
|
||||
return
|
||||
|
||||
self._set_secret_panel(f"{title}\n\n{body}")
|
||||
self._set_secret_panel(f"{title}\n\n{body}", state="REVEALED")
|
||||
self._clear_failure()
|
||||
if self._is_secret_mode_enabled() and secret_value:
|
||||
if self._copy_to_clipboard(secret_value):
|
||||
delay = self._clipboard_clear_delay()
|
||||
self._set_secret_panel(
|
||||
f"{title}\n\nSecret mode is enabled.\n"
|
||||
f"Sensitive value copied to clipboard (auto-clear in {delay}s)."
|
||||
f"Sensitive value copied to clipboard (auto-clear in {delay}s).",
|
||||
state="COPIED",
|
||||
)
|
||||
self._set_status("Sensitive value copied to clipboard")
|
||||
return
|
||||
@@ -2609,9 +2815,12 @@ def launch_tui2(
|
||||
|
||||
if cmd == "filter":
|
||||
if len(args) != 1:
|
||||
self._set_status("Usage: filter <kind|all>")
|
||||
self._set_status(
|
||||
"Usage: filter <kind|comma-list|all|secrets|docs|keys|2fa>"
|
||||
)
|
||||
return
|
||||
self.filter_kind = args[0].strip().lower()
|
||||
self.filter_kind = self._normalize_filter_kind(args[0])
|
||||
self._remember_filter_for_active_profile()
|
||||
self._update_filters_panel()
|
||||
self._load_entries(query=self._last_query, reset_page=True)
|
||||
self._set_status(f"Applied filter: {self.filter_kind}")
|
||||
@@ -3428,6 +3637,8 @@ def launch_tui2(
|
||||
self._profile_service.switch_profile(
|
||||
ProfileSwitchRequest(fingerprint=fp, password=password)
|
||||
)
|
||||
self._active_profile_fp = fp
|
||||
self._restore_filter_for_active_profile(default_filter="all")
|
||||
self._clear_failure()
|
||||
self._load_entries(self._last_query, reset_page=True)
|
||||
self._refresh_profile_tree()
|
||||
@@ -4686,11 +4897,23 @@ def launch_tui2(
|
||||
self._set_status("Help opened" if self.help_open else "Help closed")
|
||||
|
||||
def action_focus_left(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
self._set_status("Sidebar is collapsed. Press Ctrl+B to expand.")
|
||||
return
|
||||
self._focus_pane = "left"
|
||||
self._apply_focus_style()
|
||||
self._set_status("Focused left pane")
|
||||
|
||||
def action_toggle_sidebar(self) -> None:
|
||||
self._set_sidebar_collapsed(not self._sidebar_collapsed)
|
||||
if self._sidebar_collapsed:
|
||||
self._set_status("Sidebar collapsed")
|
||||
else:
|
||||
self._set_status("Sidebar expanded")
|
||||
|
||||
def action_profile_tree_next(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
return
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
@@ -4705,6 +4928,8 @@ def launch_tui2(
|
||||
self._set_status(f"Profile selection: {selected}")
|
||||
|
||||
def action_profile_tree_prev(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
return
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
@@ -4719,6 +4944,8 @@ def launch_tui2(
|
||||
self._set_status(f"Profile selection: {selected}")
|
||||
|
||||
def action_profile_tree_open(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
return
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
if self._profile_service is None:
|
||||
@@ -4732,7 +4959,7 @@ def launch_tui2(
|
||||
if target == "(default)":
|
||||
self._set_status("No switch needed for default profile")
|
||||
return
|
||||
if target == (fingerprint or ""):
|
||||
if target == self._active_profile_key():
|
||||
self._set_status(f"Profile already active: {target}")
|
||||
return
|
||||
try:
|
||||
@@ -4741,6 +4968,8 @@ def launch_tui2(
|
||||
self._profile_service.switch_profile(
|
||||
ProfileSwitchRequest(fingerprint=target, password=None)
|
||||
)
|
||||
self._active_profile_fp = target
|
||||
self._restore_filter_for_active_profile(default_filter="all")
|
||||
self._clear_failure()
|
||||
self._load_entries(self._last_query, reset_page=True)
|
||||
self._refresh_profile_tree()
|
||||
@@ -4770,6 +4999,7 @@ def launch_tui2(
|
||||
return
|
||||
if self.palette_open:
|
||||
self._set_palette_visible(False)
|
||||
self._set_filter_menu_visible(False)
|
||||
self._focus_pane = "center"
|
||||
self._apply_focus_style()
|
||||
self.query_one("#quick-jump", Input).focus()
|
||||
@@ -4796,13 +5026,25 @@ def launch_tui2(
|
||||
return
|
||||
if self.palette_open:
|
||||
self._set_palette_visible(False)
|
||||
self._set_filter_menu_visible(False)
|
||||
self._focus_pane = "center"
|
||||
self._apply_focus_style()
|
||||
self.query_one("#search", Input).focus()
|
||||
|
||||
def action_toggle_filter_menu(self) -> None:
|
||||
if self.editing_document:
|
||||
self._set_status("Finish document edit before changing filters")
|
||||
return
|
||||
filter_input = self.query_one("#kind-filter-input", Input)
|
||||
self._set_filter_menu_visible(filter_input.has_class("hidden"))
|
||||
|
||||
def action_cycle_filter(self) -> None:
|
||||
order = [
|
||||
"all",
|
||||
"secrets",
|
||||
"docs",
|
||||
"keys",
|
||||
"2fa",
|
||||
"password",
|
||||
"totp",
|
||||
"document",
|
||||
@@ -4815,6 +5057,7 @@ def launch_tui2(
|
||||
]
|
||||
idx = order.index(self.filter_kind) if self.filter_kind in order else 0
|
||||
self.filter_kind = order[(idx + 1) % len(order)]
|
||||
self._remember_filter_for_active_profile()
|
||||
self._load_entries(query=self._last_query, reset_page=True)
|
||||
self._set_status(f"Applied filter: {self.filter_kind}")
|
||||
|
||||
@@ -4943,12 +5186,26 @@ def launch_tui2(
|
||||
if self._session_locked:
|
||||
self._set_status("Vault is locked. Run: unlock <password>")
|
||||
return
|
||||
if not self._select_highlighted_entry_for_sensitive_action():
|
||||
self._set_secret_panel(
|
||||
"No entry selected.\n\nSelect an entry, then press 'v' to reveal.",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status("No entry selected")
|
||||
return
|
||||
self._show_sensitive_panel(include_qr=False, confirm=confirm)
|
||||
|
||||
def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None:
|
||||
if self._session_locked:
|
||||
self._set_status("Vault is locked. Run: unlock <password>")
|
||||
return
|
||||
if not self._select_highlighted_entry_for_sensitive_action():
|
||||
self._set_secret_panel(
|
||||
"No entry selected.\n\nSelect an entry, then press 'g' to show QR.",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status("No entry selected")
|
||||
return
|
||||
self._show_sensitive_panel(include_qr=True, qr_mode=mode, confirm=confirm)
|
||||
|
||||
def action_toggle_totp_board(self) -> None:
|
||||
@@ -5097,6 +5354,11 @@ def launch_tui2(
|
||||
self._set_palette_visible(False)
|
||||
self._set_status("Palette closed")
|
||||
return
|
||||
filter_input = self.query_one("#kind-filter-input", Input)
|
||||
if not filter_input.has_class("hidden"):
|
||||
self._set_filter_menu_visible(False)
|
||||
self._set_status("Filter menu closed")
|
||||
return
|
||||
if self.help_open:
|
||||
self.help_open = False
|
||||
self._update_help_overlay()
|
||||
@@ -5121,6 +5383,10 @@ def launch_tui2(
|
||||
}:
|
||||
self._mark_doc_dirty(True)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "sidebar-toggle":
|
||||
self.action_toggle_sidebar()
|
||||
|
||||
def on_text_area_changed(self, _event: Any) -> None:
|
||||
self._mark_doc_dirty(True)
|
||||
|
||||
@@ -5148,6 +5414,14 @@ def launch_tui2(
|
||||
return
|
||||
self._show_entry(entry_id)
|
||||
return
|
||||
if event.input.id == "kind-filter-input":
|
||||
self.filter_kind = self._normalize_filter_kind(event.value.strip())
|
||||
self._remember_filter_for_active_profile()
|
||||
self._set_filter_menu_visible(False)
|
||||
self._update_filters_panel()
|
||||
self._load_entries(query=self._last_query, reset_page=True)
|
||||
self._set_status(f"Applied filter: {self.filter_kind}")
|
||||
return
|
||||
if event.input.id == "command-palette":
|
||||
command = event.value
|
||||
self._set_palette_visible(False)
|
||||
|
||||
@@ -777,6 +777,8 @@ async def test_tui2_textual_document_edit_save_flow() -> None:
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
app.action_edit_document()
|
||||
await pilot.pause()
|
||||
@@ -815,6 +817,7 @@ async def test_tui2_textual_link_commands_and_neighbor_open() -> None:
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _run_palette(app, pilot, "open 1")
|
||||
|
||||
await _run_palette(app, pilot, "link-add 2 references rel-note")
|
||||
links_text = _widget_text(app, "#link-detail")
|
||||
@@ -1000,11 +1003,17 @@ async def test_tui2_textual_default_focus_keeps_sensitive_hotkeys_active() -> No
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert getattr(app.focused, "id", None) == "entry-list"
|
||||
assert "Selected: (none)" in _filters_text(app)
|
||||
assert "Select an entry from the grid" in _widget_text(app, "#entry-detail")
|
||||
|
||||
await pilot.press("v")
|
||||
await pilot.pause()
|
||||
assert "requires confirmation" in _status_text(app)
|
||||
assert "Run: reveal confirm" in _widget_text(app, "#secret-detail")
|
||||
assert "No entry selected" in _status_text(app)
|
||||
assert "Select an entry" in _widget_text(app, "#secret-detail")
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
assert "Selected: #1" in _filters_text(app)
|
||||
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
@@ -1029,6 +1038,8 @@ async def test_tui2_textual_compact_layout_hides_link_panel_and_can_restore() ->
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
link_detail = app.query_one("#link-detail", Static)
|
||||
assert link_detail.has_class("hidden")
|
||||
@@ -1221,6 +1232,11 @@ async def test_tui2_textual_filters_panel_tracks_selected_entry() -> None:
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
filters_text = _widget_text(app, "#filters")
|
||||
assert "Selected: (none)" in filters_text
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
filters_text = _widget_text(app, "#filters")
|
||||
assert "Selected: #1" in filters_text
|
||||
@@ -1233,6 +1249,76 @@ async def test_tui2_textual_filters_panel_tracks_selected_entry() -> None:
|
||||
assert "Acct 2" in filters_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_filter_menu_applies_presets() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "password", "label": "Login 1", "length": 16},
|
||||
{"id": 2, "kind": "document", "label": "Doc 2", "content": "hello"},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("f")
|
||||
await pilot.pause()
|
||||
filter_input = app.query_one("#kind-filter-input", Input)
|
||||
assert not filter_input.has_class("hidden")
|
||||
filter_input.value = "docs"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert filter_input.has_class("hidden")
|
||||
assert "Filter : docs" in _widget_text(app, "#filters")
|
||||
list_view = app.query_one("#entry-list", ListView)
|
||||
assert len(list_view.children) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_filter_supports_multi_kind_list() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "password", "label": "Login 1", "length": 16},
|
||||
{"id": 2, "kind": "totp", "label": "Auth 2", "secret": "JBSWY3DPEHPK3PXP"},
|
||||
{"id": 3, "kind": "document", "label": "Doc 3", "content": "hello"},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("filter password,totp")
|
||||
await pilot.pause()
|
||||
assert "Filter : password,totp" in _widget_text(app, "#filters")
|
||||
list_view = app.query_one("#entry-list", ListView)
|
||||
assert len(list_view.children) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_filter_menu_closes_on_escape_and_focus_actions() -> None:
|
||||
service = FakeEntryService([{"id": 1, "kind": "document", "label": "Doc 1"}])
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("f")
|
||||
await pilot.pause()
|
||||
filter_input = app.query_one("#kind-filter-input", Input)
|
||||
assert not filter_input.has_class("hidden")
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert filter_input.has_class("hidden")
|
||||
|
||||
await pilot.press("f")
|
||||
await pilot.pause()
|
||||
assert not filter_input.has_class("hidden")
|
||||
app.action_focus_search()
|
||||
await pilot.pause()
|
||||
assert filter_input.has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -1583,6 +1669,8 @@ async def test_tui2_textual_palette_notes_tags_fields_and_doc_export_validation(
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
app._run_palette_command("notes-set")
|
||||
await pilot.pause()
|
||||
@@ -1919,6 +2007,7 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None:
|
||||
app._run_palette_command("setting-kdf-mode argon2")
|
||||
await pilot.pause()
|
||||
assert config.kdf_mode == "argon2"
|
||||
|
||||
assert "KDF mode set to argon2" in _status_text(app)
|
||||
|
||||
app._run_palette_command("semantic-status")
|
||||
@@ -2048,6 +2137,41 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_profile_switch_restores_filter_state() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "password", "label": "Login 1", "length": 16},
|
||||
{"id": 2, "kind": "document", "label": "Doc 2", "content": "hello"},
|
||||
{"id": 3, "kind": "ssh", "label": "SSH 3"},
|
||||
]
|
||||
)
|
||||
profiles = FakeProfileService(["fp-a", "fp-b"])
|
||||
app = _build_app(service, profile_service=profiles)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("filter docs")
|
||||
await pilot.pause()
|
||||
assert "Filter : docs" in _widget_text(app, "#filters")
|
||||
|
||||
app._run_palette_command("profile-switch fp-a")
|
||||
await pilot.pause()
|
||||
assert "Filter : all" in _widget_text(app, "#filters")
|
||||
|
||||
app._run_palette_command("filter keys")
|
||||
await pilot.pause()
|
||||
assert "Filter : keys" in _widget_text(app, "#filters")
|
||||
|
||||
app._run_palette_command("profile-switch fp-b")
|
||||
await pilot.pause()
|
||||
assert "Filter : all" in _widget_text(app, "#filters")
|
||||
|
||||
app._run_palette_command("profile-switch fp-a")
|
||||
await pilot.pause()
|
||||
assert "Filter : keys" in _widget_text(app, "#filters")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_managed_account_session_palette_commands() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -2060,6 +2184,8 @@ async def test_tui2_textual_managed_account_session_palette_commands() -> None:
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
|
||||
app._run_palette_command("managed-load")
|
||||
await pilot.pause()
|
||||
|
||||
Reference in new issue
Block a user