Skip to content

Commit 4ff2b53

Browse files
committed
fix(history): render brackets safely, collapse same-text rows
Two bugs in the query history screen: 1. Bracket identifiers like [dbo].[ServiceFeatures] were truncated to "[dbo]." in the option list. rich.markup.escape only escapes brackets whose tag-name starts with [a-z#/@], so [ServiceFeatures] slipped through and Textual's markup parser ate it as an unknown tag. Build options (and the preview) as Rich Text instead of markup strings to sidestep parsing entirely. 2. Starring one row marked every same-text row as starred, because history dedup is per (connection, database, query_hash) but the starred store is keyed by (connection, query_text). Collapse rows with identical query text in _merge_entries (keeping the most-recent timestamp), so the display row count matches the starring model.
1 parent 1b20cbe commit 4ff2b53

2 files changed

Lines changed: 222 additions & 19 deletions

File tree

‎sqlit/domains/query/ui/screens/query_history.py‎

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any
88

99
from textual.app import ComposeResult
10-
from rich.markup import escape
10+
from rich.text import Text
1111
from textual.binding import Binding
1212
from textual.containers import VerticalScroll
1313
from textual.screen import ModalScreen
@@ -112,17 +112,32 @@ def __init__(
112112
self._filtered_entries: list[QueryHistoryEntry] = []
113113

114114
def _merge_entries(self) -> list[QueryHistoryEntry]:
115-
"""Merge history entries with starred-only queries."""
115+
"""Merge history entries with starred-only queries.
116+
117+
Same-text rows collapse to a single most-recent entry so the row
118+
count matches the starring model (which is keyed by query text):
119+
starring one row never marks two visually-identical rows. In
120+
multi-connection (telescope) mode the dedupe key is
121+
(connection_name, query); in single-connection mode it's just
122+
the query.
123+
"""
116124
result: list[QueryHistoryEntry] = []
117125

118126
if self._multi_connection:
119-
history_queries_by_connection: dict[str, set[str]] = {}
127+
seen: dict[tuple[str, str], QueryHistoryEntry] = {}
120128
for entry in self.history:
121129
starred_queries = self._starred_by_connection.get(entry.connection_name, set())
122130
entry.is_starred = entry.query.strip() in starred_queries
123131
entry.is_starred_only = False
124-
history_queries_by_connection.setdefault(entry.connection_name, set()).add(entry.query.strip())
125-
result.append(entry)
132+
key = (entry.connection_name, entry.query.strip())
133+
existing = seen.get(key)
134+
if existing is None or entry.timestamp > existing.timestamp:
135+
seen[key] = entry
136+
result.extend(seen.values())
137+
138+
history_queries_by_connection: dict[str, set[str]] = {}
139+
for (conn_name, query_text) in seen:
140+
history_queries_by_connection.setdefault(conn_name, set()).add(query_text)
126141

127142
for connection_name, starred_queries in self._starred_by_connection.items():
128143
history_queries = history_queries_by_connection.get(connection_name, set())
@@ -137,15 +152,17 @@ def _merge_entries(self) -> list[QueryHistoryEntry]:
137152
)
138153
result.append(entry)
139154
else:
140-
# Mark history entries that are starred
141-
history_queries: set[str] = set()
155+
seen_single: dict[str, QueryHistoryEntry] = {}
142156
for entry in self.history:
143157
entry.is_starred = entry.query.strip() in self.starred
144158
entry.is_starred_only = False
145-
history_queries.add(entry.query.strip())
146-
result.append(entry)
159+
key = entry.query.strip()
160+
existing = seen_single.get(key)
161+
if existing is None or entry.timestamp > existing.timestamp:
162+
seen_single[key] = entry
163+
result.extend(seen_single.values())
147164

148-
# Add starred-only entries (queries that were starred but not in history)
165+
history_queries = set(seen_single.keys())
149166
for starred_query in self.starred:
150167
if starred_query not in history_queries:
151168
entry = QueryHistoryEntry(
@@ -221,7 +238,7 @@ def _update_preview(self, idx: int) -> None:
221238
entries = self._get_display_entries()
222239
if idx < len(entries):
223240
preview = self.query_one("#history-preview", Static)
224-
preview.update(escape(entries[idx].query))
241+
preview.update(Text(entries[idx].query))
225242

226243
def action_select(self) -> None:
227244
entries = self._get_display_entries()
@@ -479,19 +496,25 @@ def _build_action_result(self, action: str, entry: QueryHistoryEntry) -> tuple[s
479496
return action, entry.query
480497

481498
def _build_option(self, entry: QueryHistoryEntry) -> Option:
482-
star = "[yellow]*[/] " if entry.is_starred else " "
483-
# Collapse all whitespace to single spaces and strip
499+
# Build a Rich Text (not a markup string) so identifiers like
500+
# [dbo].[ServiceFeatures] aren't eaten by the markup parser:
501+
# rich.markup.escape only escapes "[a-z#/@…]" tags, so uppercase
502+
# bracket identifiers slip through and get consumed as fake tags.
484503
query_single_line = re.sub(r"\s+", " ", entry.query).strip()
485504
max_len = 40 if self._multi_connection else 55
486505
if len(query_single_line) > max_len:
487506
query_preview = query_single_line[:max_len] + "..."
488507
else:
489508
query_preview = query_single_line
490-
query_preview = escape(query_preview)
509+
510+
text = Text()
511+
if entry.is_starred:
512+
text.append("* ", style="yellow")
513+
else:
514+
text.append(" ")
491515
if self._multi_connection:
492516
conn_label = self._truncate_label(self._entry_connection_label(entry), 24)
493-
conn_label = escape(conn_label)
494-
option_id = self._entry_option_id(entry)
495-
if self._multi_connection:
496-
return Option(f"{star}[dim]{conn_label}[/]/{query_preview}", id=option_id)
497-
return Option(f"{star}{query_preview}", id=option_id)
517+
text.append(conn_label, style="dim")
518+
text.append("/")
519+
text.append(query_preview)
520+
return Option(text, id=self._entry_option_id(entry))

‎tests/ui/test_query_history.py‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,186 @@ def save_query(self, connection_name, query):
296296
assert all(entry.connection_name == "saved-db" for entry in screen._merged_entries)
297297

298298

299+
class TestQueryHistoryStarringDuplicates:
300+
"""Regression tests for starring behavior when multiple history entries
301+
share the same query text (e.g., same SQL run against multiple databases
302+
on one connection — file-backed dedup is per-(connection, database))."""
303+
304+
def _make_screen(
305+
self,
306+
history: list[QueryHistoryEntry],
307+
starred: set[str],
308+
connection_name: str = "saved-db",
309+
) -> QueryHistoryScreen:
310+
return QueryHistoryScreen(
311+
history=history,
312+
connection_name=connection_name,
313+
starred=starred,
314+
)
315+
316+
def test_same_query_across_databases_collapses_to_single_row(self) -> None:
317+
history = [
318+
QueryHistoryEntry(
319+
query="SELECT 1",
320+
timestamp="2026-01-02T00:00:00",
321+
connection_name="saved-db",
322+
database="db_a",
323+
),
324+
QueryHistoryEntry(
325+
query="SELECT 1",
326+
timestamp="2026-01-01T00:00:00",
327+
connection_name="saved-db",
328+
database="db_b",
329+
),
330+
]
331+
screen = self._make_screen(history, starred={"SELECT 1"})
332+
333+
merged = screen._merge_entries()
334+
335+
assert len(merged) == 1, (
336+
"Same query text from two databases should collapse to one row "
337+
"in the history display (starring is keyed by text, so the row "
338+
"count must match)."
339+
)
340+
assert merged[0].is_starred
341+
# Most-recent occurrence wins the row.
342+
assert merged[0].database == "db_a"
343+
344+
@pytest.mark.asyncio
345+
async def test_starring_one_marks_duplicate_in_other_database(self) -> None:
346+
saved_conn = create_test_connection("saved-db", "sqlite")
347+
entries = [
348+
QueryHistoryEntry(
349+
query="SELECT 1",
350+
timestamp="2026-01-02T00:00:00",
351+
connection_name="saved-db",
352+
database="db_a",
353+
),
354+
QueryHistoryEntry(
355+
query="SELECT 1",
356+
timestamp="2026-01-01T00:00:00",
357+
connection_name="saved-db",
358+
database="db_b",
359+
),
360+
]
361+
362+
class StubHistoryStore:
363+
def __init__(self, entries):
364+
self._entries = entries
365+
366+
def load_all(self):
367+
return list(self._entries)
368+
369+
def load_for_connection(self, connection_name):
370+
return [e for e in self._entries if e.connection_name == connection_name]
371+
372+
def delete_entry(self, connection_name, timestamp):
373+
_ = connection_name
374+
_ = timestamp
375+
return False
376+
377+
def save_query(self, connection_name, query, database=""):
378+
_ = connection_name
379+
_ = query
380+
_ = database
381+
382+
history_store = StubHistoryStore(entries)
383+
services = build_test_services(
384+
connection_store=MockConnectionStore([saved_conn]),
385+
settings_store=MockSettingsStore({"theme": "tokyo-night"}),
386+
history_store=history_store,
387+
)
388+
app = SSMSTUI(services=services)
389+
390+
async with app.run_test(size=(100, 35)) as pilot:
391+
app.connections = [saved_conn]
392+
app.current_config = saved_conn
393+
394+
# Open history, star the first row (which corresponds to db_a),
395+
# close — emulates: user opens history, presses '*' on one row.
396+
app.action_show_history()
397+
await pilot.pause(0.2)
398+
screen = next(
399+
(s for s in app.screen_stack if isinstance(s, QueryHistoryScreen)),
400+
None,
401+
)
402+
assert screen is not None
403+
option_list = screen.query_one("#history-list", OptionList)
404+
# The two same-text rows should already be collapsed to one.
405+
assert option_list.option_count == 1
406+
assert option_list.highlighted == 0
407+
screen.action_toggle_star()
408+
await pilot.pause(0.2)
409+
410+
# Re-open: exactly one starred row, not two.
411+
app.action_show_history()
412+
await pilot.pause(0.2)
413+
screen = next(
414+
(s for s in app.screen_stack if isinstance(s, QueryHistoryScreen)),
415+
None,
416+
)
417+
assert screen is not None
418+
starred_entries = [e for e in screen._merged_entries if e.is_starred]
419+
try:
420+
assert len(starred_entries) == 1, (
421+
f"Expected exactly one starred row after toggling one; "
422+
f"got {len(starred_entries)}"
423+
)
424+
finally:
425+
# The shared test config dir is module-scoped (see
426+
# tests/fixtures/utils.py), so any star we save here would
427+
# leak to later tests. Clean up.
428+
services.starred_store.clear_for_connection(saved_conn.name)
429+
430+
431+
class TestQueryHistoryBracketIdentifierRendering:
432+
"""Regression tests for MSSQL bracket identifiers like [dbo].[ServiceFeatures]
433+
being silently eaten by Textual markup when the bracket contents start with
434+
an uppercase letter (rich.markup.escape only escapes [a-z#/@] tags)."""
435+
436+
@pytest.mark.asyncio
437+
async def test_bracket_identifier_with_uppercase_not_truncated(self) -> None:
438+
from textual.app import App
439+
from textual.widgets.option_list import Option
440+
441+
# Drive the option directly through a minimal Textual app so we
442+
# observe what _build_option's text actually renders to.
443+
screen = QueryHistoryScreen(
444+
history=[
445+
QueryHistoryEntry(
446+
query="SELECT TOP 100 * FROM [dbo].[ServiceFeatures]",
447+
timestamp="2026-01-01T00:00:00",
448+
connection_name="saved-db",
449+
),
450+
],
451+
connection_name="saved-db",
452+
starred=set(),
453+
)
454+
screen._merged_entries = screen._merge_entries()
455+
built = screen._build_option(screen._merged_entries[0])
456+
457+
class _ProbeApp(App):
458+
def compose(self):
459+
yield OptionList(Option(built.prompt, id=built.id or "probe"), id="probe-list")
460+
461+
app = _ProbeApp()
462+
async with app.run_test(size=(120, 10)) as pilot:
463+
await pilot.pause()
464+
option_list = app.query_one("#probe-list", OptionList)
465+
lines = option_list.render_lines(option_list.region)
466+
rendered_text = "\n".join(
467+
"".join(seg.text for seg in line) for line in lines
468+
)
469+
470+
assert "[ServiceFeatures]" in rendered_text, (
471+
"Bracket identifier with uppercase first letter was silently "
472+
"swallowed by Textual markup (rich.markup.escape only escapes "
473+
"tags starting with [a-z#/@], so '[ServiceFeatures]' is left "
474+
"unescaped and the markup parser eats it). Visible text:\n"
475+
+ rendered_text
476+
)
477+
478+
299479
class TestQueryHistoryVimNavigation:
300480
"""Tests for j/k vim-style navigation in the history screen."""
301481

0 commit comments

Comments
 (0)