Skip to content

Commit a55e795

Browse files
authored
Merge pull request #221 from Maxteabag/worktree-filter-cursor-fix
Land filter-accept cursor on the matched node after snapshot restore
2 parents 3b75bc0 + d986431 commit a55e795

3 files changed

Lines changed: 199 additions & 6 deletions

File tree

sqlit/domains/explorer/ui/mixins/tree_filter.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,61 @@ def action_tree_filter_close(self: TreeFilterMixinHost) -> None:
109109

110110
def action_tree_filter_accept(self: TreeFilterMixinHost) -> None:
111111
"""Accept current filter selection, close filter, and activate the node."""
112-
# Store current match before closing
113-
current_node = None
114-
if self._tree_filter_matches and self._tree_filter_match_index < len(self._tree_filter_matches):
112+
# Remember the match's *data* (not the node reference) before closing.
113+
# Closing the filter rebuilds the tree from the snapshot taken at
114+
# filter-open time, which replaces every node object — so the
115+
# reference we captured here would be stale after close. The data
116+
# payload, however, is the same object on both old and new nodes
117+
# (we pass it through unchanged in _restore_node_under), so we can
118+
# re-locate the match by identity.
119+
matched_data: Any = None
120+
if (
121+
self._tree_filter_matches
122+
and self._tree_filter_match_index < len(self._tree_filter_matches)
123+
):
115124
current_node = self._tree_filter_matches[self._tree_filter_match_index]
125+
if current_node and current_node.data:
126+
matched_data = current_node.data
116127

117128
# Close the filter
118129
self.action_tree_filter_close()
119130

120-
# Activate the selected node (connect to server, expand folder, etc.)
121-
if current_node and current_node.data:
122-
self._activate_tree_node(current_node)
131+
if matched_data is None:
132+
return
133+
134+
fresh_node = self._find_node_by_data(matched_data)
135+
if fresh_node is None:
136+
return
137+
138+
# Textual's Tree.move_cursor reads `node._line`, which is set during
139+
# the next layout pass — not when the node is `add()`-ed. Since the
140+
# snapshot restore that just ran in action_tree_filter_close added
141+
# all fresh nodes synchronously, calling move_cursor right now sees
142+
# stale `_line` values and parks the cursor on the wrong row.
143+
# Defer the move (and the activation) until after the next refresh.
144+
call_after = getattr(self, "call_after_refresh", None)
145+
if callable(call_after):
146+
call_after(lambda: self._select_and_activate_after_refresh(fresh_node))
147+
else:
148+
# Synchronous fallback (used in unit tests with a mock host).
149+
self._select_and_activate_after_refresh(fresh_node)
150+
151+
def _select_and_activate_after_refresh(self: TreeFilterMixinHost, node: Any) -> None:
152+
try:
153+
self.object_tree.move_cursor(node)
154+
except Exception:
155+
pass
156+
self._activate_tree_node(node)
157+
158+
def _find_node_by_data(self: TreeFilterMixinHost, data: Any) -> Any | None:
159+
"""Locate the node in the current tree whose `.data` is `data`."""
160+
stack = [self.object_tree.root]
161+
while stack:
162+
node = stack.pop()
163+
if node.data is data:
164+
return node
165+
stack.extend(node.children)
166+
return None
123167

124168
def action_tree_filter_next(self: TreeFilterMixinHost) -> None:
125169
"""Move to next filter match."""
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Pilot-driven test for cursor position after accepting a filter match.
2+
3+
Runs the real Textual app + Tree widget via `app.run_test()` and presses
4+
real keys. Asserts that pressing Enter on a filtered match leaves the
5+
cursor on that match in the rebuilt tree (the failure mode the user
6+
sees is that it lands somewhere else — e.g. the top node).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from sqlit.domains.shell.app.main import SSMSTUI
14+
15+
from ..mocks import (
16+
MockConnectionStore,
17+
MockSettingsStore,
18+
build_test_services,
19+
create_test_connection,
20+
)
21+
22+
23+
def _connection_name_of(node) -> str | None:
24+
data = getattr(node, "data", None)
25+
if data is None:
26+
return None
27+
config = getattr(data, "config", None)
28+
if config is None:
29+
return None
30+
return getattr(config, "name", None)
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_filter_accept_lands_cursor_on_matched_connection():
35+
"""Repro of the issue the user demonstrated: after `/` then typing a
36+
substring and pressing Enter, the explorer cursor should be on the
37+
matched connection — not on the top node."""
38+
connections = [
39+
create_test_connection("alpha", "sqlite"),
40+
create_test_connection("test-server", "sqlite"),
41+
create_test_connection("production", "sqlite"),
42+
]
43+
services = build_test_services(
44+
connection_store=MockConnectionStore(connections),
45+
settings_store=MockSettingsStore({"theme": "tokyo-night"}),
46+
)
47+
app = SSMSTUI(services=services)
48+
49+
async with app.run_test(size=(120, 40)) as pilot:
50+
await pilot.pause()
51+
52+
# Focus the explorer so key events reach the tree filter mixin.
53+
app.action_focus_explorer()
54+
await pilot.pause()
55+
assert app.object_tree.has_focus
56+
57+
# Open the filter, type a substring of one connection, accept.
58+
await pilot.press("slash")
59+
await pilot.pause()
60+
for ch in "test":
61+
await pilot.press(ch)
62+
await pilot.pause()
63+
64+
# Sanity: a single match was found.
65+
assert len(app._tree_filter_matches) == 1, (
66+
f"expected exactly one match for 'test'; "
67+
f"got {len(app._tree_filter_matches)}"
68+
)
69+
70+
await pilot.press("enter")
71+
await pilot.pause()
72+
73+
cursor = app.object_tree.cursor_node
74+
assert cursor is not None, "cursor unexpectedly None after accept"
75+
name = _connection_name_of(cursor)
76+
assert name == "test-server", (
77+
"Cursor landed on the wrong element after accepting the filter "
78+
f"match. Expected 'test-server', got node with connection "
79+
f"name={name!r} (cursor.label={getattr(cursor, 'label', None)!r})."
80+
)

tests/ui/explorer/test_tree_filter_search.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,29 @@ def __init__(self):
6464
def select_node(self, node: MockTreeNode) -> None:
6565
self.selected_node = node
6666

67+
def move_cursor(self, node: MockTreeNode) -> None:
68+
self.selected_node = node
69+
6770
def focus(self) -> None:
6871
self.has_focus = True
6972

73+
def is_node_in_tree(self, node: MockTreeNode | None) -> bool:
74+
"""Walk the tree to check if a node reference is still attached.
75+
76+
Mirrors Textual's actual behavior: a cursor reference pointing at a
77+
node that was `child.remove()`d is stale — the widget no longer
78+
renders that node.
79+
"""
80+
if node is None:
81+
return False
82+
stack = [self.root]
83+
while stack:
84+
current = stack.pop()
85+
if current is node:
86+
return True
87+
stack.extend(current.children)
88+
return False
89+
7090

7191
class MockFilterInput:
7292
"""Mock TreeFilterInput capturing the last set_filter call."""
@@ -384,3 +404,52 @@ def test_filter_finds_lazy_loaded_tables_in_multi_db_mode(self):
384404
assert "cs_ticket" in matched, (
385405
f"Issue #141: filter must find lazy-loaded tables; got {matched}"
386406
)
407+
408+
409+
class TestCursorPositionAfterFilterAccept:
410+
"""Pressing Enter on a filter match should leave the cursor on that
411+
same match in the rebuilt tree — not on a stale node reference (the
412+
pre-snapshot match object has been removed and replaced by a fresh
413+
node when the tree was restored)."""
414+
415+
def _open_filter(self, host: _FilterHost) -> None:
416+
TreeFilterMixin.action_tree_filter(host) # type: ignore[arg-type]
417+
418+
def _type(self, host: _FilterHost, text: str) -> None:
419+
for ch in text:
420+
host._tree_filter_text += ch
421+
TreeFilterMixin._update_tree_filter(host) # type: ignore[arg-type]
422+
423+
def test_cursor_stays_on_matched_node_after_accept(self):
424+
host = _FilterHost(["alpha", "test-server", "production"])
425+
426+
self._open_filter(host)
427+
self._type(host, "test")
428+
429+
# We have exactly one match: 'test-server'
430+
assert len(host._tree_filter_matches) == 1
431+
matched_label = host._tree_filter_matches[0].data.get_label_text()
432+
assert matched_label == "test-server"
433+
434+
# Accept (this closes the filter and restores the tree from snapshot)
435+
TreeFilterMixin.action_tree_filter_accept(host) # type: ignore[arg-type]
436+
437+
cursor = host.object_tree.selected_node
438+
439+
# 1. The cursor must point at a node that is still in the tree —
440+
# not at a removed/stale Python object from before the restore.
441+
assert host.object_tree.is_node_in_tree(cursor), (
442+
"After accept, cursor references a node that's no longer in "
443+
"the tree (stale reference left over from the filter session). "
444+
f"cursor: {cursor!r}"
445+
)
446+
447+
# 2. That node should correspond to the match the user accepted.
448+
assert cursor is not None
449+
assert (
450+
cursor.data is not None
451+
and cursor.data.get_label_text() == "test-server"
452+
), (
453+
"Cursor ended up on the wrong node after accept. "
454+
f"Expected 'test-server', got: {cursor.data.get_label_text() if cursor.data else None}"
455+
)

0 commit comments

Comments
 (0)