Skip to content

Commit 5ad4559

Browse files
sykesabMaxteabag
andauthored
Fix/blob text renders as hex (#329)
* Render safe utf-8 values as text, fallback to rendering hex on error * Fix regression to crash on "v" preview for byte value fields. * test(results): reproduce filtered cell tooltip markup regression * fix(results): strip filter highlighting from cell previews --------- Co-authored-by: Peter Adams <18162810+Maxteabag@users.noreply.github.com>
1 parent 19a2e50 commit 5ad4559

4 files changed

Lines changed: 130 additions & 2 deletions

File tree

‎sqlit/domains/results/ui/mixins/results.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,12 +582,14 @@ def _show_cell_tooltip(
582582
self._tooltip_timer.stop()
583583
self._tooltip_timer = None
584584

585+
value = _strip_table_markup(table, value)
585586
tooltip_value = "NULL" if value is None else str(value)
586587
if len(tooltip_value) > 2000:
587588
tooltip_value = f"{tooltip_value[:2000]}..."
588589

589590
try:
590-
table.tooltip = tooltip_value
591+
# Wrap in Text so Rich does not parse cell content as markup.
592+
table.tooltip = Text(tooltip_value)
591593
table._manual_tooltip_active = True
592594
except Exception:
593595
pass

‎sqlit/shared/ui/widgets_tables.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ def normalize_arrow_value(value: Any) -> Any:
2525
if isinstance(value, UUID):
2626
return str(value)
2727
if isinstance(value, (bytes, bytearray, memoryview)):
28-
return f"0x{bytes(value).hex()}"
28+
raw = bytes(value)
29+
try:
30+
return raw.decode("utf-8")
31+
except UnicodeDecodeError:
32+
return f"0x{raw.hex()}"
2933
return value
3034

3135

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Cell previews must preserve values without leaking filter highlighting."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from rich.text import Text
7+
from textual.coordinate import Coordinate
8+
9+
from sqlit.domains.shell.app.main import SSMSTUI
10+
11+
from .mocks import (
12+
MockConnectionStore,
13+
MockSettingsStore,
14+
build_test_services,
15+
create_test_connection,
16+
)
17+
18+
19+
@pytest.mark.asyncio
20+
@pytest.mark.parametrize("filtered", [False, True], ids=["unfiltered", "filtered"])
21+
@pytest.mark.parametrize(
22+
"payload,search",
23+
[("Jane", "Ja"), ('{"path": "[/api/v1]", "name": "Jane"}', "Jane")],
24+
ids=["plain-text", "bracketed-json"],
25+
)
26+
async def test_cell_preview_preserves_original_value(payload: str, search: str, filtered: bool) -> None:
27+
connection = create_test_connection("test-db", "sqlite")
28+
services = build_test_services(
29+
connection_store=MockConnectionStore([connection]),
30+
settings_store=MockSettingsStore({"theme": "tokyo-night"}),
31+
)
32+
app = SSMSTUI(services=services)
33+
34+
async with app.run_test(size=(120, 40)) as pilot:
35+
await pilot.pause()
36+
await app._display_query_results(
37+
columns=["payload"], rows=[(payload,)], row_count=1,
38+
truncated=False, elapsed_ms=0,
39+
)
40+
await pilot.pause()
41+
app.results_table.focus()
42+
if filtered:
43+
await pilot.press("slash")
44+
assert app._results_filter_visible
45+
await pilot.press(*search)
46+
await pilot.pause()
47+
await pilot.press("enter")
48+
await pilot.pause()
49+
assert not app._results_filter_visible
50+
assert app.results_table.render_markup
51+
52+
app.results_table.cursor_coordinate = Coordinate(0, 0)
53+
await pilot.press("v")
54+
await pilot.pause()
55+
assert app._tooltip_showing
56+
tooltip = app.results_table.tooltip
57+
assert tooltip is not None
58+
# Textual renders string tooltips as markup, but Text values literally.
59+
preview = tooltip.plain if isinstance(tooltip, Text) else Text.from_markup(tooltip).plain
60+
assert preview == payload

‎tests/ui/test_uuid_fastdatatable.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
_INVALID_UTF8_BINARY = b"\x00\x00\x00\x00\x00\x00\x81\xff"
2424
_BINARY_TEXT = "0x00000000000081ff"
25+
_UTF8_JSON_BINARY = b'{"a": 1, "b": [1, 2, 3]}'
26+
_UTF8_JSON_TEXT = '{"a": 1, "b": [1, 2, 3]}'
2527

2628

2729
@pytest.mark.parametrize(
@@ -44,6 +46,35 @@ def test_binary_column_is_stringified_before_arrow_measurement(value: object) ->
4446
assert table.get_cell_at(Coordinate(0, 0)) == _BINARY_TEXT
4547

4648

49+
@pytest.mark.parametrize(
50+
"value",
51+
[
52+
_UTF8_JSON_BINARY,
53+
bytearray(_UTF8_JSON_BINARY),
54+
memoryview(_UTF8_JSON_BINARY),
55+
],
56+
ids=["bytes", "bytearray", "memoryview"],
57+
)
58+
def test_utf8_binary_column_is_decoded_as_text(value: object) -> None:
59+
table = SqlitDataTable(
60+
data={"binary": [value]},
61+
column_labels=["binary"],
62+
)
63+
64+
assert table.backend is not None
65+
assert table.backend.column_content_widths == [len(_UTF8_JSON_TEXT)]
66+
assert table.get_cell_at(Coordinate(0, 0)) == _UTF8_JSON_TEXT
67+
68+
69+
def test_incrementally_added_utf8_binary_is_decoded_as_text() -> None:
70+
table = SqlitDataTable(data={"binary": ["initial"]}, column_labels=["binary"])
71+
72+
table.add_rows([(_UTF8_JSON_BINARY,)])
73+
74+
assert table.backend is not None
75+
assert table.get_cell_at(Coordinate(1, 0)) == _UTF8_JSON_TEXT
76+
77+
4778
def test_incrementally_added_binary_is_stringified() -> None:
4879
table = SqlitDataTable(data={"binary": ["initial"]}, column_labels=["binary"])
4980

@@ -139,3 +170,34 @@ async def test_decimal_incremental_backend_stringifies_binary_column() -> None:
139170
_BINARY_TEXT
140171
)
141172
assert app.results_table.get_cell_at(Coordinate(0, 2)) == _BINARY_TEXT
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_view_cell_tooltip_does_not_parse_markup() -> None:
177+
from rich.text import Text
178+
179+
payload = b'{"path": "[/api/v1]", "tags": ["a", "b"]}'
180+
connection = create_test_connection("test-db", "sqlite")
181+
services = build_test_services(
182+
connection_store=MockConnectionStore([connection]),
183+
settings_store=MockSettingsStore({"theme": "tokyo-night"}),
184+
)
185+
app = SSMSTUI(services=services)
186+
187+
async with app.run_test(size=(120, 40)) as pilot:
188+
await pilot.pause()
189+
await app._display_query_results(
190+
columns=["payload"],
191+
rows=[(payload,)],
192+
row_count=1,
193+
truncated=False,
194+
elapsed_ms=0,
195+
)
196+
await pilot.pause(0.05)
197+
app.results_table.cursor_coordinate = Coordinate(0, 0)
198+
app.action_view_cell()
199+
await pilot.pause(0.05)
200+
201+
tooltip = app.results_table.tooltip
202+
assert isinstance(tooltip, Text)
203+
assert tooltip.plain == payload.decode("utf-8")

0 commit comments

Comments
 (0)