Improve v3 grid navigation and graph browsing

This commit is contained in:
thePR0M3TH3AN committed 2026-03-06 07:10:36 -05:00
1 parent 6e7cd5b734
commit 46ffff577b
11 files changed
+656 -32

No files matched your search

@@ -258,6 +258,12 @@ Required linked item card data:
4. `relation`
5. `scope_path`
6. `archived`
Baseline now implemented in code:
1. `SearchService.linked_neighbors(...)` returns deterministic incoming/outgoing neighbor cards
2. `SearchService.relation_summary(...)` returns grouped incoming/outgoing/combined relation counts
3. TUI v3 inspector now surfaces linked-item summaries and direct open-entry actions
4. TUI v3 grid now exposes explicit sort/filter/mode controls and preserves the active search query across refreshes
7. `modified_ts`
Required actions:
+12 -7
View File
@@ -1,6 +1,6 @@
# SeedPass Dev Control Center
Last updated: 2026-03-05
Last updated: 2026-03-06
Branch baseline: `beta`
This is the single source-of-truth document to decide what to work on next.
@@ -97,9 +97,14 @@ Use these docs as inputs, but treat this file as the decision layer:
- deterministic filter/sort pipeline added for keyword / hybrid / semantic search modes
- v3 entry grid now routes through the unified search path instead of branching search logic in the widget layer
- focused regression coverage added in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py`
- Phase B baseline landed:
- `SearchService.linked_neighbors(...)` and `relation_summary(...)` now provide explicit outgoing/incoming graph navigation data
- v3 grid now preserves active search query across refresh/filter/sort changes
- v3 grid now exposes explicit in-app filter/mode/sort controls instead of palette-only command flow
- v3 inspector now includes a linked-items panel with direct open-entry actions
- focused regression coverage expanded in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py`
- Next planned implementation:
- richer sort/filter controls in v3
- linked-item navigation service methods and inspector consumers
- deeper linked-navigation workflows and richer graph-oriented pivots
- deeper atlas/search handoff and graph-aware result workflows
### Future Capability: Nostr Communications
@@ -174,7 +179,7 @@ Use these docs as inputs, but treat this file as the decision layer:
3. Continue v3 startup/restore hardening:
: clearer restore warnings, success summaries, and import/recovery guidance.
4. Continue Phase B/C from `docs/atlas_search_graph_integration_plan_2026-03-05.md`:
: linked-item navigation, richer filter/sort controls, and deeper v3 search/navigation handoff.
: deeper v3 search/navigation handoff, graph pivots, and next-level linked-item workflows.
5. Resume Index0 Atlas and graph follow-on work after current v3 UX slices stabilize:
: agent-facing atlas workflows, linked-item navigation, deeper search/navigation handoff, and any later event-pruning compaction policy.
6. Continue security-readiness blockers in parallel where they do not conflict with the v3 path.
@@ -184,9 +189,9 @@ Use these docs as inputs, but treat this file as the decision layer:
### Slice A: “Search Graph Navigation”
Deliverables:
- Add linked-neighbor and relation-summary methods above the current atlas/search foundations.
- Expose explicit sort and filter controls in v3 on top of the existing unified `SearchService`.
- Add inspector linked-item navigation so users can browse outgoing/incoming relationships without raw JSON inspection.
- Extend the new linked-neighbor and relation-summary methods with deeper graph pivots above the current atlas/search foundations.
- Continue iterating on the new explicit v3 sort/filter controls on top of the unified `SearchService`.
- Expand the new inspector linked-item navigation so users can pivot through relationships more fluidly without raw JSON inspection.
- Keep the atlas/search/graph plan and control center synchronized with shipped behavior as this slice lands.
Exit criteria:
+20 -1
View File
@@ -1,6 +1,6 @@
# TUI v3 Modular Architecture & Parity Plan
Last updated: 2026-03-05
Last updated: 2026-03-06
Branch target: `beta`
Status: Active (Default UI / parity hardening in progress)
Companion search/atlas plan: `docs/atlas_search_graph_integration_plan_2026-03-05.md`
@@ -168,3 +168,22 @@ To finalize v3, we must verify 100% functional parity with the **Legacy TUI**.
- All reveal/QR/copy semantics verified for high-risk kinds.
- PGP Public key support confirmed in both TUI v2 and v3.
- Nostr relay actions mapped back through internal configuration via `app.services['nostr']`.
## 6. Progress Log (2026-03-06)
### Completed in this session
- **Main grid reliability:**
- fixed v3 main-grid keyboard navigation by restoring focus ownership to `#entry-data-table` on workspace mount
- closing the command palette now restores focus to the main grid instead of leaving focus on a hidden input
- added focused regression coverage for default grid focus and post-palette keyboard navigation
- **Search graph navigation baseline:**
- added `SearchService.linked_neighbors(...)` for deterministic incoming/outgoing link traversal
- added `SearchService.relation_summary(...)` for grouped relation counts
- v3 inspector now includes a linked-items panel with direct open-entry actions
- **Grid control hardening:**
- v3 grid now persists the active search query across refresh/filter/sort changes
- added explicit in-app filter, search-mode, archived, and sort controls on the main grid surface
- palette command surface now includes `sort <...>` and routes search-mode changes through app actions for one consistent control path
### Validation evidence
- `pytest -q src/tests/test_tui_v3_smoke.py src/tests/test_tui_v3_parity.py src/tests/test_core_api_services.py` -> `107 passed`
+16
View File
@@ -46,3 +46,19 @@
- Added regression tests in `src/tests/test_tui_v3_parity.py` to lock this compatibility behavior.
- Current CI-equivalent state:
- Full main suite passes (`1064 passed, 16 skipped`), but existing `tui2_coverage_gate.sh` still fails `src/seedpass/core/api.py >= 85` on its focused subset (`68.79%`) and needs separate gate policy/workload adjustment.
## Session addendum — v3 grid focus + graph navigation baseline (2026-03-06)
- Root cause for the reported v3 "main index list / scrolling" bug was focus management, not `DataTable` scrolling itself:
- first render focus landed on the hidden command palette input
- arrow-key navigation did nothing until the entry table was manually focused
- Stable fix:
- `MainScreen.on_mount()` now focuses `#entry-data-table`
- closing the command palette restores focus to `#entry-data-table` instead of clearing focus
- Reusable v3 search/navigation pattern:
- persist the active search query in app state so refresh/filter/sort changes do not silently clear the current result set
- keep sort and mode changes as explicit app actions (`action_set_search_mode`, `action_set_search_sort`) so toolbar buttons and palette commands stay aligned
- Search/graph baseline shipped:
- `SearchService.linked_neighbors(...)` now returns deterministic incoming/outgoing link cards
- `SearchService.relation_summary(...)` now returns grouped relation counts
- TUI v3 inspector now exposes a linked-items panel with direct open-entry actions
- TUI v3 grid now has explicit filter/mode/sort buttons instead of palette-only control
+129
View File
@@ -1501,6 +1501,135 @@ class SearchService:
results.sort(key=_result_key)
return results[: max(1, int(limit))]
def linked_neighbors(
self,
entry_id: int,
*,
relation: str | None = None,
direction: str = "both",
include_archived: bool = True,
limit: int = 50,
) -> list[dict[str, Any]]:
with self._lock:
target_entry_id = int(entry_id or 0)
if target_entry_id <= 0:
return []
direction_key = str(direction or "both").strip().lower()
if direction_key not in {"incoming", "outgoing", "both"}:
direction_key = "both"
relation_filter = str(relation or "").strip().lower()
scope_path = self._scope_path()
entries = self._all_entries()
entry_map = {
int(item.get("id", 0) or 0): item
for item in entries
if int(item.get("id", 0) or 0) > 0
}
current_entry = entry_map.get(target_entry_id)
if current_entry is None:
return []
neighbors: list[dict[str, Any]] = []
if direction_key in {"outgoing", "both"}:
for link in self._normalized_links(current_entry):
link_relation = str(link.get("relation", "")).strip().lower()
if relation_filter and link_relation != relation_filter:
continue
neighbor_id = int(link.get("target_id", 0) or 0)
neighbor = entry_map.get(neighbor_id)
if neighbor is None:
continue
archived = bool(neighbor.get("archived", False))
if not include_archived and archived:
continue
kind = self._entry_kind(neighbor)
neighbors.append(
{
"entry_id": neighbor_id,
"label": str(neighbor.get("label", "")).strip(),
"kind": kind,
"scope_path": scope_path,
"archived": archived,
"direction": "outgoing",
"relation": link_relation,
"note": str(link.get("note", "")).strip(),
"tags": self._normalized_tags(neighbor),
"meta": self._meta(neighbor, kind),
}
)
if direction_key in {"incoming", "both"}:
for source in entries:
source_id = int(source.get("id", 0) or 0)
if source_id <= 0 or source_id == target_entry_id:
continue
archived = bool(source.get("archived", False))
if not include_archived and archived:
continue
for link in self._normalized_links(source):
if int(link.get("target_id", 0) or 0) != target_entry_id:
continue
link_relation = str(link.get("relation", "")).strip().lower()
if relation_filter and link_relation != relation_filter:
continue
kind = self._entry_kind(source)
neighbors.append(
{
"entry_id": source_id,
"label": str(source.get("label", "")).strip(),
"kind": kind,
"scope_path": scope_path,
"archived": archived,
"direction": "incoming",
"relation": link_relation,
"note": str(link.get("note", "")).strip(),
"tags": self._normalized_tags(source),
"meta": self._meta(source, kind),
}
)
neighbors.sort(
key=lambda item: (
str(item.get("direction", "")),
str(item.get("relation", "")),
str(item.get("label", "")).lower(),
int(item.get("entry_id", 0) or 0),
)
)
return neighbors[: max(1, int(limit))]
def relation_summary(
self,
entry_id: int,
*,
include_archived: bool = True,
) -> dict[str, dict[str, int]]:
neighbors = self.linked_neighbors(
entry_id,
include_archived=include_archived,
direction="both",
limit=1000,
)
summary = {
"incoming": {},
"outgoing": {},
"combined": {},
}
for item in neighbors:
relation = str(item.get("relation", "")).strip().lower()
direction = str(item.get("direction", "")).strip().lower()
if not relation or direction not in {"incoming", "outgoing"}:
continue
summary[direction][relation] = summary[direction].get(relation, 0) + 1
summary["combined"][relation] = summary["combined"].get(relation, 0) + 1
return {
key: dict(sorted(value.items()))
for key, value in summary.items()
}
class AtlasService:
"""Thread-safe wrapper around canonical atlas/index0 read operations."""
+53 -9
View File
@@ -14,7 +14,7 @@ from utils.fingerprint_manager import FingerprintManager
from .widgets.header import AtlasStrip, RibbonHeader
from .widgets.sidebar import SidebarContainer
from .widgets.grid import GridContainer
from .widgets.inspector import BoardContainer
from .widgets.inspector import BoardContainer, LinkedItemsPanel
from .widgets.palette import CommandPalette
from .widgets.action_bar import ActionBar
from .screens.atlas import AtlasWayfinderScreen
@@ -63,7 +63,7 @@ class CommandProcessor:
if cmd == "help":
self.app.notify(
"v3 commands: help, stats, atlas, wayfinder, session-status, lock, unlock <password>, refresh, search <query>, search-mode <keyword|hybrid|semantic>, filter <all|secrets|docs|keys|2fa>, archived, open <id>, settings, profiles, relay-list, npub, nostr-pubkey, nostr-reset-sync-state, nostr-fresh-namespace, change-password, backup-parent-seed <path> (optional: password), maximize, copy, edit, export, add, seed-plus, archive, restore, delete, ml, mx, db-export <path>, db-import <path>"
"v3 commands: help, stats, atlas, wayfinder, session-status, lock, unlock <password>, refresh, search <query>, search-mode <keyword|hybrid|semantic>, sort <relevance|modified_desc|modified_asc|label_asc|kind|created_desc|most_linked>, filter <all|secrets|docs|keys|2fa>, archived, open <id>, settings, profiles, relay-list, npub, nostr-pubkey, nostr-reset-sync-state, nostr-fresh-namespace, change-password, backup-parent-seed <path> (optional: password), maximize, copy, edit, export, add, seed-plus, archive, restore, delete, ml, mx, db-export <path>, db-import <path>"
)
elif cmd == "stats":
self.app.notify("Calculating stats...")
@@ -93,12 +93,15 @@ class CommandProcessor:
"Usage: search-mode <keyword|hybrid|semantic>", severity="warning"
)
return
mode = args[0].lower()
if mode in {"keyword", "hybrid", "semantic"}:
self.app.search_mode = mode
self.app.notify(f"Search mode set to: {mode}")
else:
self.app.notify(f"Invalid search mode: {mode}", severity="error")
self.app.action_set_search_mode(args[0])
elif cmd == "sort":
if not args:
self.app.notify(
"Usage: sort <relevance|modified_desc|modified_asc|label_asc|kind|created_desc|most_linked>",
severity="warning",
)
return
self.app.action_set_search_sort(args[0])
elif cmd == "filter":
if not args:
self.app.notify(
@@ -211,8 +214,16 @@ class MainScreen(Screen):
yield Static("Inspector Board", id="inspector-heading")
yield Button("Close", id="inspector-close", variant="default")
yield BoardContainer(id="board-container")
yield LinkedItemsPanel(id="linked-items-panel")
yield ActionBar(id="action-bar")
def on_mount(self) -> None:
"""Keep keyboard navigation on the main entry grid by default."""
try:
self.query_one("#entry-data-table").focus()
except Exception:
pass
class StartupScreen(Screen):
"""Profile-selection and unlock screen for TUI v3 startup."""
@@ -821,7 +832,9 @@ class SeedPassTuiV3(App[None]):
active_breadcrumb = reactive("")
selected_entry_id = reactive[int | None](None)
session_locked = reactive(False)
search_query = reactive("")
search_mode = reactive("keyword")
search_sort = reactive("relevance")
filter_kind = reactive("all")
show_archived = reactive(False)
@@ -1104,6 +1117,7 @@ class SeedPassTuiV3(App[None]):
else:
inspector.remove_class("hidden")
self.screen.query_one("#board-container").update_entry(new_id)
self.screen.query_one("#linked-items-panel").update_entry(new_id)
except Exception:
pass
@@ -1131,11 +1145,41 @@ class SeedPassTuiV3(App[None]):
def action_search(self, query: str) -> None:
"""Search entries and update grid."""
try:
self.screen.query_one("#entry-data-table")._refresh_data(query)
self.search_query = str(query or "")
self.screen.query_one("#entry-data-table")._refresh_data()
self.notify(f"Search results for: {query}")
except Exception:
pass
def action_set_search_mode(self, mode: str) -> None:
"""Set the active search mode and refresh the grid."""
normalized = str(mode or "").strip().lower()
if normalized not in {"keyword", "hybrid", "semantic"}:
self.notify(f"Invalid search mode: {mode}", severity="error")
return
self.search_mode = normalized
self.notify(f"Search mode set to: {normalized}")
self.action_refresh()
def action_set_search_sort(self, sort_key: str) -> None:
"""Set the active grid sort mode and refresh the grid."""
normalized = str(sort_key or "").strip().lower()
allowed = {
"relevance",
"modified_desc",
"modified_asc",
"label_asc",
"kind",
"created_desc",
"most_linked",
}
if normalized not in allowed:
self.notify(f"Invalid sort mode: {sort_key}", severity="error")
return
self.search_sort = normalized
self.notify(f"Grid sort set to: {normalized}")
self.action_refresh()
def action_toggle_archived_view(self) -> None:
"""Toggle between active entries and archived entries."""
self.show_archived = not self.show_archived
+69 -10
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from typing import Any
from types import SimpleNamespace
from textual.app import ComposeResult
from textual.widgets import Static, DataTable
from textual.widgets import Static, DataTable, Button
from textual.containers import Horizontal
from textual.reactive import reactive
@@ -23,7 +24,6 @@ class GridMetrics(Static):
def render(self) -> str:
app = self.app
# These would eventually come from App reactives
pg = "1/1"
rows = "0/0"
try:
@@ -36,7 +36,6 @@ class GridMetrics(Static):
pass
density = "compact"
# Search mode highlighting
m = app.search_mode
keyword = f"[b](KEYWORD)[/b]" if m == "keyword" else "keyword"
hybrid = f"[b](HYBRID)[/b]" if m == "hybrid" else "hybrid"
@@ -44,15 +43,72 @@ class GridMetrics(Static):
search = f"{keyword} {hybrid} {semantic}"
filter_text = f"Filter: [b]{app.filter_kind}[/b]"
sort_text = f"Sort: [b]{app.search_sort}[/b]"
query_text = (
f' Query: [b]"{app.search_query}"[/b]' if str(app.search_query).strip() else ""
)
arch_text = " | [reverse] ARCHIVED [/reverse]" if app.show_archived else ""
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} {filter_text}{arch_text} Search {search}"
return (
f"Entry Grid | Pg {pg} Rows {rows} Density {density} "
f"{filter_text} {sort_text}{arch_text}{query_text} Search {search}"
)
def on_mount(self) -> None:
self.watch(self.app, "search_mode", self.refresh)
self.watch(self.app, "selected_entry_id", self.refresh)
self.watch(self.app, "filter_kind", self.refresh)
self.watch(self.app, "show_archived", self.refresh)
self.watch(self.app, "search_sort", self.refresh)
self.watch(self.app, "search_query", self.refresh)
class GridToolbar(Horizontal):
"""Explicit filter/sort/search-mode controls for the main grid."""
DEFAULT_CSS = """
GridToolbar {
height: 3;
background: #d9d9d9;
color: #000000;
padding: 0 1;
}
GridToolbar Button {
min-width: 8;
width: auto;
margin-right: 1;
height: 1;
}
"""
def compose(self) -> ComposeResult:
yield Button("All", id="grid-filter-all")
yield Button("Secrets", id="grid-filter-secrets")
yield Button("Docs", id="grid-filter-docs")
yield Button("Keys", id="grid-filter-keys")
yield Button("2FA", id="grid-filter-2fa")
yield Button("Archived", id="grid-toggle-archived")
yield Button("Keyword", id="grid-mode-keyword")
yield Button("Hybrid", id="grid-mode-hybrid")
yield Button("Semantic", id="grid-mode-semantic")
yield Button("Relevance", id="grid-sort-relevance")
yield Button("Recent", id="grid-sort-modified_desc")
yield Button("Label", id="grid-sort-label_asc")
yield Button("Linked", id="grid-sort-most_linked")
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id.startswith("grid-filter-"):
self.app.action_set_kind_filter(button_id.removeprefix("grid-filter-"))
return
if button_id == "grid-toggle-archived":
self.app.action_toggle_archived_view()
return
if button_id.startswith("grid-mode-"):
self.app.action_set_search_mode(button_id.removeprefix("grid-mode-"))
return
if button_id.startswith("grid-sort-"):
self.app.action_set_search_sort(button_id.removeprefix("grid-sort-"))
class EntryDataTable(DataTable):
@@ -76,11 +132,12 @@ class EntryDataTable(DataTable):
except Exception:
pass
def _refresh_data(self, query: str = "") -> None:
def _refresh_data(self, query: str | None = None) -> None:
self.clear()
app = self.app
if "entry" not in app.services:
return
active_query = app.search_query if query is None else query
if app.selected_entry_id is None:
self._suppress_next_highlight = True
@@ -105,11 +162,12 @@ class EntryDataTable(DataTable):
if "search" in app.services:
try:
results = app.services["search"].search(
query,
active_query,
kinds=kinds,
include_archived=app.show_archived,
archived_only=app.show_archived,
mode=app.search_mode,
sort=app.search_sort,
)
for result in results:
try:
@@ -131,9 +189,9 @@ class EntryDataTable(DataTable):
app.notify(f"Search failed: {e}", severity="error")
entries = []
# Fallback to direct semantic search if requested and available
elif app.search_mode != "keyword" and "semantic" in app.services and query:
elif app.search_mode != "keyword" and "semantic" in app.services and active_query:
try:
results = app.services["semantic"].search(query, mode=app.search_mode)
results = app.services["semantic"].search(active_query, mode=app.search_mode)
# results is list of {entry_id, kind, label, score, excerpt}
for r in results:
eid = r["entry_id"]
@@ -173,7 +231,7 @@ class EntryDataTable(DataTable):
app.notify(f"Semantic search failed: {e}", severity="error")
# Fallback to lexical
entries = app.services["entry"].search_entries(
query,
active_query,
kinds=kinds,
include_archived=app.show_archived,
archived_only=app.show_archived,
@@ -181,7 +239,7 @@ class EntryDataTable(DataTable):
else:
# Fetch filtered data from standard service
entries = app.services["entry"].search_entries(
query,
active_query,
kinds=kinds,
include_archived=app.show_archived,
archived_only=app.show_archived,
@@ -236,6 +294,7 @@ class GridContainer(Static):
def compose(self) -> ComposeResult:
yield GridMetrics()
yield GridToolbar()
# Header divider
yield Static("-" * 120, id="grid-divider")
yield EntryDataTable(id="entry-data-table")
+113 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.widgets import Static, Label
from textual.widgets import Static, Label, Button
from textual.containers import Vertical, Horizontal
from textual.reactive import reactive
@@ -631,3 +631,115 @@ class BoardContainer(Vertical):
new_board = board_cls()
self.mount(new_board)
return new_board
class LinkedItemsPanel(Vertical):
"""Inspector panel for browsing explicit incoming and outgoing relationships."""
DEFAULT_CSS = """
LinkedItemsPanel {
height: auto;
max-height: 12;
min-height: 6;
background: #111111;
color: #ffffff;
border-top: solid #ffffff;
padding: 1;
}
#linked-items-title {
text-style: bold;
margin-bottom: 1;
}
.linked-summary {
color: #cccccc;
margin-bottom: 1;
}
.linked-empty {
color: #aaaaaa;
}
.linked-open {
width: 100%;
height: auto;
margin-bottom: 1;
content-align: left middle;
}
"""
def compose(self) -> ComposeResult:
yield Label("Linked Items", id="linked-items-title")
yield Label("No linked items.", id="linked-items-summary", classes="linked-summary")
with Vertical(id="linked-items-list"):
yield Label("Select an entry to inspect relationships.", classes="linked-empty")
def update_entry(self, entry_id: int | None) -> None:
summary_label = self.query_one("#linked-items-summary", Label)
list_container = self.query_one("#linked-items-list", Vertical)
list_container.remove_children()
if entry_id is None:
summary_label.update("No linked items.")
list_container.mount(
Label(
"Select an entry to inspect relationships.",
classes="linked-empty",
)
)
return
search = self.app.services.get("search")
if search is None or not hasattr(search, "linked_neighbors"):
summary_label.update("Search graph service unavailable.")
list_container.mount(
Label(
"Linked navigation requires SearchService graph helpers.",
classes="linked-empty",
)
)
return
try:
neighbors = search.linked_neighbors(entry_id, direction="both", limit=8)
summary = search.relation_summary(entry_id)
except Exception as exc:
summary_label.update(f"Linked navigation failed: {exc}")
list_container.mount(Label("Unable to load linked items.", classes="linked-empty"))
return
outgoing = ", ".join(
f"{relation}:{count}" for relation, count in summary.get("outgoing", {}).items()
) or "none"
incoming = ", ".join(
f"{relation}:{count}" for relation, count in summary.get("incoming", {}).items()
) or "none"
summary_label.update(f"Outgoing {outgoing} | Incoming {incoming}")
if not neighbors:
list_container.mount(Label("No linked items for this entry.", classes="linked-empty"))
return
for item in neighbors:
direction = "->" if item.get("direction") == "outgoing" else "<-"
relation = str(item.get("relation", "")).strip() or "related_to"
label = str(item.get("label", "")).strip() or f"Entry #{item.get('entry_id', '?')}"
kind = str(item.get("kind", "")).strip() or "entry"
archived = " archived" if item.get("archived") else ""
line = f"{direction} {relation} #{item.get('entry_id')} {label} [{kind}{archived}]"
list_container.mount(
Button(
f"{line} | Open",
id=f"linked-open-{int(item.get('entry_id', 0) or 0)}",
classes="linked-open",
)
)
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if not button_id.startswith("linked-open-"):
return
try:
entry_id = int(button_id.removeprefix("linked-open-"))
except ValueError:
self.app.notify("Invalid linked item target", severity="error")
return
self.app.selected_entry_id = entry_id
self.app.notify(f"Opened linked entry #{entry_id}")
+4 -1
View File
@@ -51,7 +51,10 @@ class CommandPalette(Vertical):
def toggle(self) -> None:
if self.has_class("visible"):
self.remove_class("visible")
self.app.set_focus(None)
try:
self.app.screen.query_one("#entry-data-table").focus()
except Exception:
self.app.set_focus(None)
else:
self.add_class("visible")
self.input.focus()
+67
View File
@@ -391,6 +391,73 @@ class TestSearchService:
assert results[0]["linked_hits"][0]["relation"] == "references"
assert results[0]["excerpt"].startswith("SeedPass atlas")
def test_linked_neighbors_and_relation_summary(
self, service, mock_manager, tmp_path
):
mock_manager.fingerprint_dir = tmp_path
mock_manager.entry_manager.search_entries.return_value = [
(1, "Project Plan", None, None, False, EntryType.DOCUMENT),
(2, "Ops Vault", "ops", "https://ops", False, EntryType.PASSWORD),
(3, "Runbook", None, None, False, EntryType.DOCUMENT),
]
entries = {
1: {
"id": 1,
"kind": "document",
"label": "Project Plan",
"links": [{"target_id": 2, "relation": "references"}],
},
2: {
"id": 2,
"kind": "password",
"label": "Ops Vault",
"username": "ops",
"url": "https://ops",
},
3: {
"id": 3,
"kind": "document",
"label": "Runbook",
"links": [{"target_id": 1, "relation": "depends_on"}],
},
}
mock_manager.entry_manager.retrieve_entry.side_effect = lambda eid: entries[eid]
neighbors = service.linked_neighbors(1)
summary = service.relation_summary(1)
assert neighbors == [
{
"entry_id": 3,
"label": "Runbook",
"kind": "document",
"scope_path": f"seed/{tmp_path.name}",
"archived": False,
"direction": "incoming",
"relation": "depends_on",
"note": "",
"tags": [],
"meta": "",
},
{
"entry_id": 2,
"label": "Ops Vault",
"kind": "password",
"scope_path": f"seed/{tmp_path.name}",
"archived": False,
"direction": "outgoing",
"relation": "references",
"note": "",
"tags": [],
"meta": "ops",
},
]
assert summary == {
"incoming": {"depends_on": 1},
"outgoing": {"references": 1},
"combined": {"depends_on": 1, "references": 1},
}
@patch("seedpass.core.api.SemanticIndexService.search")
def test_hybrid_search_combines_semantic_and_filters(
self, semantic_search, service, mock_manager, tmp_path
+167 -3
View File
@@ -27,9 +27,24 @@ from seedpass.tui_v3.screens.security import ChangePasswordScreen
class V3EntryService:
def __init__(self) -> None:
self._entries: dict[int, dict] = {
1: {"id": 1, "kind": "password", "label": "Email", "length": 16},
2: {"id": 2, "kind": "managed_account", "label": "Managed Ops"},
3: {"id": 3, "kind": "nostr", "label": "Agent Nostr"},
1: {
"id": 1,
"kind": "password",
"label": "Email",
"length": 16,
"links": [{"target_id": 2, "relation": "references"}],
},
2: {
"id": 2,
"kind": "managed_account",
"label": "Managed Ops",
},
3: {
"id": 3,
"kind": "nostr",
"label": "Agent Nostr",
"links": [{"target_id": 1, "relation": "uses"}],
},
}
self.copied: list[str] = []
@@ -199,6 +214,7 @@ class V3SearchService:
def __init__(self, entry: V3EntryService) -> None:
self.entry = entry
self.calls: list[dict] = []
self.link_calls: list[dict] = []
def search(
self,
@@ -259,6 +275,62 @@ class V3SearchService:
)
return out
def linked_neighbors(
self,
entry_id: int,
*,
relation: str | None = None,
direction: str = "both",
include_archived: bool = True,
limit: int = 50,
) -> list[dict]:
_ = (include_archived, limit)
self.link_calls.append(
{
"entry_id": entry_id,
"relation": relation,
"direction": direction,
}
)
if int(entry_id) == 1:
rows = [
{
"entry_id": 2,
"label": "Managed Ops",
"kind": "managed_account",
"archived": False,
"direction": "outgoing",
"relation": "references",
},
{
"entry_id": 3,
"label": "Agent Nostr",
"kind": "nostr",
"archived": False,
"direction": "incoming",
"relation": "uses",
},
]
if relation:
return [row for row in rows if row["relation"] == relation]
return rows
return []
def relation_summary(
self,
entry_id: int,
*,
include_archived: bool = True,
) -> dict[str, dict[str, int]]:
_ = include_archived
if int(entry_id) != 1:
return {"incoming": {}, "outgoing": {}, "combined": {}}
return {
"incoming": {"uses": 1},
"outgoing": {"references": 1},
"combined": {"references": 1, "uses": 1},
}
def _build_app() -> tuple[object, V3EntryService, V3VaultService, V3SearchService]:
holder: dict[str, object] = {}
@@ -360,6 +432,98 @@ async def test_tui3_grid_uses_search_service_for_query_and_mode() -> None:
assert table.row_count >= 1
@pytest.mark.anyio
async def test_tui3_main_grid_has_default_focus_and_keyboard_navigation() -> None:
app, _entry, _vault, _search = _build_app()
async with app.run_test() as pilot:
await pilot.pause()
table = app.screen.query_one("#entry-data-table", DataTable)
assert app.focused is table
assert app.selected_entry_id is None
await pilot.press("down")
await pilot.pause()
assert app.focused is table
assert app.selected_entry_id == 2
assert table.cursor_coordinate.row == 1
@pytest.mark.anyio
async def test_tui3_palette_close_restores_focus_to_main_grid() -> None:
app, _entry, _vault, _search = _build_app()
async with app.run_test() as pilot:
await pilot.pause()
table = app.screen.query_one("#entry-data-table", DataTable)
palette_input = app.screen.query_one("#palette-input", Input)
app.action_open_palette()
await pilot.pause()
assert app.focused is palette_input
app.action_open_palette()
await pilot.pause()
assert app.focused is table
await pilot.press("down")
await pilot.pause()
assert app.selected_entry_id == 2
@pytest.mark.anyio
async def test_tui3_grid_toolbar_updates_filter_mode_and_sort() -> None:
app, _entry, _vault, search = _build_app()
async with app.run_test() as pilot:
await pilot.pause()
app.screen.query_one("#grid-filter-keys", Button).press()
await pilot.pause()
assert app.filter_kind == "keys"
app.screen.query_one("#grid-mode-hybrid", Button).press()
await pilot.pause()
assert app.search_mode == "hybrid"
app.screen.query_one("#grid-sort-most_linked", Button).press()
await pilot.pause()
assert app.search_sort == "most_linked"
assert search.calls
assert search.calls[-1]["sort"] == "most_linked"
@pytest.mark.anyio
async def test_tui3_search_query_persists_across_refresh() -> None:
app, _entry, _vault, search = _build_app()
async with app.run_test() as pilot:
await pilot.pause()
app.action_search("Email")
await pilot.pause()
assert app.search_query == "Email"
assert search.calls[-1]["query"] == "Email"
app.action_set_kind_filter("all")
await pilot.pause()
assert search.calls[-1]["query"] == "Email"
@pytest.mark.anyio
async def test_tui3_linked_items_panel_shows_neighbors_and_opens_entry() -> None:
app, _entry, _vault, search = _build_app()
async with app.run_test() as pilot:
await pilot.pause()
app.selected_entry_id = 1
await pilot.pause()
summary = app.screen.query_one("#linked-items-summary", Static)
assert "Outgoing references:1" in str(summary.render())
assert "Incoming uses:1" in str(summary.render())
assert search.link_calls[-1]["entry_id"] == 1
open_button = app.screen.query_one("#linked-open-2", Button)
open_button.press()
await pilot.pause()
assert app.selected_entry_id == 2
@pytest.mark.anyio
async def test_tui3_inspector_stays_collapsed_until_selection_and_can_close() -> None:
app, _entry, _vault, _search = _build_app()