mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 08:52:04 +00:00
Improve v3 grid navigation and graph browsing
This commit is contained in:
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:
|
||||
|
||||
@@ -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
@@ -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`
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user