Skip to content

Commit f0dbad0

Browse files
authored
Merge pull request #211 from Maxteabag/explorer-filter-backspace-restore
Restore matches when backspacing in the explorer tree filter
2 parents 2c1e58c + fb8b4ce commit f0dbad0

2 files changed

Lines changed: 248 additions & 2 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,20 @@ def on_key(self: TreeFilterMixinHost, event: Any) -> None:
178178
def _update_tree_filter(self: TreeFilterMixinHost) -> None:
179179
"""Update the tree based on current filter text."""
180180
self._restore_tree_labels()
181-
total = self._count_all_nodes()
182181
raw_text = self._tree_filter_text
183182
self._tree_filter_fuzzy = raw_text.startswith("~")
184183
self._tree_filter_query = raw_text[1:] if self._tree_filter_fuzzy else raw_text
185184

185+
# Rebuild the full tree first so each filter pass searches every node,
186+
# not just the survivors of the previous (narrower) filter. Without this,
187+
# backspacing from a no-match query like "tt" back to "t" would leave the
188+
# tree empty because the "t"-matching nodes were physically removed.
189+
self._show_all_tree_nodes()
190+
self._tree_original_labels = {}
191+
192+
total = self._count_all_nodes()
193+
186194
if not self._tree_filter_query:
187-
self._show_all_tree_nodes()
188195
self._tree_filter_matches = []
189196
self.tree_filter_input.set_filter("", 0, total)
190197
return
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Tests for the explorer tree '/' search filter.
2+
3+
Simulates typing into the tree filter and pressing backspace to verify that
4+
narrowing then widening the query restores previously-matching nodes.
5+
6+
Scenario:
7+
1. Tree has many connection nodes; some contain 't' in their name.
8+
2. Open filter and type 't' -> only 't'-matching nodes are visible.
9+
3. Type 't' again (filter is 'tt'), which matches none -> tree becomes empty.
10+
4. Press backspace (filter back to 't') -> 't'-matching nodes reappear.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from unittest.mock import MagicMock
16+
17+
from sqlit.domains.explorer.domain.tree_nodes import ConnectionNode
18+
from sqlit.domains.explorer.ui.mixins.tree_filter import TreeFilterMixin
19+
20+
21+
class MockTreeNode:
22+
"""Mock Textual tree node supporting add/remove/expand/set_label."""
23+
24+
def __init__(self, label: str = "", data=None, parent: "MockTreeNode | None" = None):
25+
self.label = label
26+
self.data = data
27+
self.parent = parent
28+
self.children: list[MockTreeNode] = []
29+
self.allow_expand = False
30+
self.is_expanded = False
31+
32+
def add(self, label: str, data=None) -> "MockTreeNode":
33+
child = MockTreeNode(label, data=data, parent=self)
34+
self.children.append(child)
35+
return child
36+
37+
def remove(self) -> None:
38+
if self.parent and self in self.parent.children:
39+
self.parent.children.remove(self)
40+
41+
def expand(self) -> None:
42+
self.is_expanded = True
43+
44+
def collapse(self) -> None:
45+
self.is_expanded = False
46+
47+
def set_label(self, label: str) -> None:
48+
self.label = label
49+
50+
51+
class MockTree:
52+
"""Mock Tree widget exposing the root node and basic ops."""
53+
54+
def __init__(self):
55+
self.root = MockTreeNode("root")
56+
self.has_focus = True
57+
self.selected_node: MockTreeNode | None = None
58+
59+
def select_node(self, node: MockTreeNode) -> None:
60+
self.selected_node = node
61+
62+
def focus(self) -> None:
63+
self.has_focus = True
64+
65+
66+
class MockFilterInput:
67+
"""Mock TreeFilterInput capturing the last set_filter call."""
68+
69+
def __init__(self):
70+
self.visible = False
71+
self.last_text = ""
72+
self.last_match_count = 0
73+
self.last_total_count = 0
74+
75+
def show(self) -> None:
76+
self.visible = True
77+
78+
def hide(self) -> None:
79+
self.visible = False
80+
81+
def set_filter(self, text: str, match_count: int = 0, total_count: int = 0) -> None:
82+
self.last_text = text
83+
self.last_match_count = match_count
84+
self.last_total_count = total_count
85+
86+
87+
def _make_connection_node(name: str) -> ConnectionNode:
88+
"""Create a ConnectionNode by constructing a minimal ConnectionConfig."""
89+
config = MagicMock()
90+
config.name = name
91+
node = object.__new__(ConnectionNode)
92+
object.__setattr__(node, "config", config)
93+
return node
94+
95+
96+
class _FilterHost(TreeFilterMixin):
97+
"""Concrete host that uses TreeFilterMixin and rebuilds the tree on refresh.
98+
99+
`refresh_tree` here mirrors the real behavior: it discards the current
100+
tree contents and rebuilds them from the stored original connection list.
101+
"""
102+
103+
def __init__(self, connection_names: list[str]):
104+
self._connection_names = connection_names
105+
self.object_tree = MockTree()
106+
self.tree_filter_input = MockFilterInput()
107+
self._populate()
108+
109+
def _populate(self) -> None:
110+
self.object_tree.root.children = []
111+
for name in self._connection_names:
112+
data = _make_connection_node(name)
113+
child = self.object_tree.root.add(name, data=data)
114+
child.allow_expand = True
115+
116+
# Required by TreeFilterMixin when query becomes empty.
117+
def refresh_tree(self) -> None:
118+
self._populate()
119+
120+
# No-op stubs required by the mixin.
121+
def _update_footer_bindings(self) -> None:
122+
pass
123+
124+
def _activate_tree_node(self, _node) -> None:
125+
pass
126+
127+
128+
def _visible_node_names(host: _FilterHost) -> list[str]:
129+
names: list[str] = []
130+
for child in host.object_tree.root.children:
131+
data = child.data
132+
if data is not None and hasattr(data, "get_label_text"):
133+
names.append(data.get_label_text())
134+
return names
135+
136+
137+
class TestTreeFilterSearch:
138+
"""End-to-end-ish tests of the search behavior with typing and backspace."""
139+
140+
CONNECTION_NAMES = [
141+
"alpha",
142+
"bravo",
143+
"gamma",
144+
"test-server",
145+
"atlas",
146+
"tipi",
147+
"production",
148+
"staging",
149+
"delta",
150+
"omega",
151+
]
152+
# Names that contain a 't' (case-insensitive)
153+
T_MATCHES = {
154+
"test-server",
155+
"atlas",
156+
"tipi",
157+
"production",
158+
"staging",
159+
"delta",
160+
}
161+
162+
def _open_filter(self, host: _FilterHost) -> None:
163+
"""Open the filter (no text)."""
164+
TreeFilterMixin.action_tree_filter(host) # type: ignore[arg-type]
165+
166+
def _type(self, host: _FilterHost, text: str) -> None:
167+
"""Simulate typing `text` into the already-open filter."""
168+
for ch in text:
169+
host._tree_filter_text += ch
170+
TreeFilterMixin._update_tree_filter(host) # type: ignore[arg-type]
171+
172+
def _backspace(self, host: _FilterHost) -> None:
173+
"""Simulate pressing backspace while filter is active."""
174+
if host._tree_filter_text:
175+
host._tree_filter_text = host._tree_filter_text[:-1]
176+
TreeFilterMixin._update_tree_filter(host) # type: ignore[arg-type]
177+
178+
def test_typing_t_filters_to_t_matches(self):
179+
host = _FilterHost(self.CONNECTION_NAMES)
180+
181+
self._open_filter(host)
182+
self._type(host, "t")
183+
184+
visible = set(_visible_node_names(host))
185+
assert visible == self.T_MATCHES, (
186+
f"Expected only 't'-matching connections visible, got {visible}"
187+
)
188+
189+
def test_typing_tt_filters_out_everything(self):
190+
host = _FilterHost(self.CONNECTION_NAMES)
191+
192+
self._open_filter(host)
193+
self._type(host, "tt")
194+
195+
visible = _visible_node_names(host)
196+
assert visible == [], (
197+
f"Expected no connections to match 'tt', got {visible}"
198+
)
199+
200+
def test_backspace_after_tt_restores_t_matches(self):
201+
"""The key regression: narrowing then widening should restore matches.
202+
203+
After typing 't' (matches), then 't' again ('tt', no matches),
204+
pressing backspace returns the query to 't' and the previously
205+
matching connections must reappear.
206+
"""
207+
host = _FilterHost(self.CONNECTION_NAMES)
208+
209+
self._open_filter(host)
210+
211+
# Type 't' -> 't' matches visible
212+
self._type(host, "t")
213+
assert set(_visible_node_names(host)) == self.T_MATCHES
214+
215+
# Type 't' again -> filter is 'tt', no matches
216+
self._type(host, "t")
217+
assert _visible_node_names(host) == []
218+
219+
# Backspace -> filter is 't' again; 't'-matching nodes must reappear
220+
self._backspace(host)
221+
222+
visible = set(_visible_node_names(host))
223+
assert visible == self.T_MATCHES, (
224+
"After backspacing from 'tt' back to 't', expected the "
225+
f"'t'-matching connections to reappear, but got {visible}"
226+
)
227+
228+
def test_backspace_to_empty_restores_all(self):
229+
"""Backspacing all the way clears the filter and restores every node."""
230+
host = _FilterHost(self.CONNECTION_NAMES)
231+
232+
self._open_filter(host)
233+
self._type(host, "t")
234+
assert set(_visible_node_names(host)) == self.T_MATCHES
235+
236+
self._backspace(host)
237+
238+
visible = _visible_node_names(host)
239+
assert set(visible) == set(self.CONNECTION_NAMES)

0 commit comments

Comments
 (0)