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

+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()