From 3c59470f254c256954f3acd5be7ab223a7e8e142 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sun, 21 Jun 2026 18:33:04 +0200 Subject: [PATCH 1/2] feat(results+header): cell-detail drawer, sticky # column, header polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 of UX improvements: 1. Result cells truncate (CSS max-width + ellipsis) so one fat column (e.g. HTML blobs) can't dominate; click a cell → right-side detail drawer with the full value pretty-printed (JSON reindented), and for HTML a Rendered (in a sandbox="" srcdoc iframe) ↔ Source toggle. Esc / backdrop / ✕ close it. 2. Freeze the row-number (#) column (position: sticky; left: 0) so it stays visible when scrolling right. 3. Truncate the version to its first three segments ("26.3.10"); full string on hover (title). 4. Column types are no longer shown inline in the header — exposed as a hover tooltip (title) on the header cell instead. 5. Move the GitHub source link to immediately after the version in the header. 6. Left-align the Save button (now grouped with Run/Format). - core/format.js: shortVersion. core/cell.js: looksLikeHtml, prettyValue (pure). - deploy/http_handlers.xml + README: add frame-src 'self' for the sandboxed HTML-preview iframe (the sandbox keeps it script-less/inert). npm test → 392 passing; new core files + results.js 100%, app.js meets its gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- README.md | 3 ++ deploy/http_handlers.xml | 6 ++-- src/core/cell.js | 25 ++++++++++++++ src/core/format.js | 10 ++++++ src/styles.css | 34 +++++++++++++++++++ src/ui/app.js | 20 +++++++----- src/ui/results.js | 64 +++++++++++++++++++++++++++++++++++- tests/unit/app.test.js | 2 +- tests/unit/cell.test.js | 31 ++++++++++++++++++ tests/unit/format.test.js | 14 +++++++- tests/unit/results.test.js | 67 +++++++++++++++++++++++++++++++++++--- 11 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 src/core/cell.js create mode 100644 tests/unit/cell.test.js diff --git a/README.md b/README.md index 5d17eec..8201762 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ response. The CSP is `default-src 'none'` with everything re-allowed explicitly: `sessionStorage` tokens to an attacker. `'self'` covers ClickHouse queries + `config.json`; the IdP origins cover OIDC discovery and the token endpoint. - `img-src data:`, `frame-ancestors 'none'` (anti-clickjacking), `base-uri 'none'`. +- `frame-src 'self'` — lets the result cell-detail drawer preview an HTML value + in a `sandbox=""` (script-less, inert) `srcdoc` iframe. The sandbox blocks any + script/form/navigation, so the relaxation can't run injected code. `install.sh` fills `connect-src` automatically: it fetches your issuer's OIDC discovery document and rewrites the host list to your real issuer + token-endpoint diff --git a/deploy/http_handlers.xml b/deploy/http_handlers.xml index d94b802..e896aa3 100644 --- a/deploy/http_handlers.xml +++ b/deploy/http_handlers.xml @@ -35,8 +35,10 @@ IdP, replace the two https:// hosts below with your issuer + token endpoint origins. script-src/style-src need 'unsafe-inline' because the JS + CSS are inlined into this single HTML file; the real protection is - connect-src, which bounds where the sessionStorage tokens can be sent. --> - default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com; base-uri 'none'; frame-ancestors 'none' + connect-src, which bounds where the sessionStorage tokens can be sent. + frame-src 'self' permits the cell-detail drawer's HTML preview, which + renders a result value in a sandbox="" (script-less, inert) srcdoc iframe. --> + default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src 'self'; frame-src 'self'; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com; base-uri 'none'; frame-ancestors 'none' nosniff no-referrer diff --git a/src/core/cell.js b/src/core/cell.js new file mode 100644 index 0000000..fdf07fd --- /dev/null +++ b/src/core/cell.js @@ -0,0 +1,25 @@ +// Pure helpers for the cell-detail drawer. No DOM, no globals. + +/** Heuristic: does the string look like an HTML/XML fragment worth rendering? */ +export function looksLikeHtml(s) { + const str = String(s || ''); + return /<([a-z!][\s\S]*?)>/i.test(str) && /<\/[a-z]+\s*>|\/>/i.test(str); +} + +/** + * Pretty-print a cell value for the detail view: valid JSON is reindented; + * anything else is returned as-is (coerced to string, null/undefined → ''). + */ +export function prettyValue(s) { + if (s == null) return ''; + const str = String(s); + const t = str.trim(); + if (t && (t[0] === '{' || t[0] === '[')) { + try { + return JSON.stringify(JSON.parse(t), null, 2); + } catch { + /* not JSON — fall through */ + } + } + return str; +} diff --git a/src/core/format.js b/src/core/format.js index 32bf5f5..08abbd8 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -65,3 +65,13 @@ export function inferQueryName(sql) { export function isNumericType(type) { return /^(U?Int|Float|Decimal)/.test(type || ''); } + +/** + * Short form of a ClickHouse version for the header: the first three + * dot-segments (e.g. '26.3.10.20001.altinityantalya' → '26.3.10'). The full + * string is shown on hover. Empty/short inputs pass through unchanged. + */ +export function shortVersion(v) { + const parts = String(v || '').split('.'); + return parts.length > 3 ? parts.slice(0, 3).join('.') : String(v || ''); +} diff --git a/src/styles.css b/src/styles.css index ce9689e..0f4570f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -764,11 +764,45 @@ table.res-table td.idx { user-select: none; width: 36px; } +/* Freeze the row-number column so it stays visible when scrolling right. */ +table.res-table th:first-child { left: 0; z-index: 3; } +table.res-table td.idx { position: sticky; left: 0; z-index: 1; background: var(--bg-table); } table.res-table td.num { text-align: right; color: var(--num); } +/* Hard cap on cell width + ellipsis so one fat column can't dominate; click a + cell to see the full value in the detail drawer. */ +table.res-table td.cell { cursor: pointer; } +table.res-table td .cell-val { + max-width: 420px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +table.res-table td.num .cell-val { max-width: none; } +table.res-table.fixed td .cell-val { max-width: 100%; } table.res-table tbody tr:hover td { background: var(--bg-hover); } +table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } + +/* Cell-detail drawer (click a result cell) */ +.cd-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.4); display: flex; justify-content: flex-end; } +.cd-panel { + width: min(560px, 92vw); height: 100%; + background: var(--bg-panel, var(--bg-editor)); border-left: 1px solid var(--border); + box-shadow: -8px 0 28px rgba(0,0,0,.35); + display: flex; flex-direction: column; +} +.cd-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.cd-title { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 8px; } +.cd-name { font-weight: 600; color: var(--fg); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cd-type { font-size: 11px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; } +.cd-close { border: none; background: transparent; color: var(--fg-mute); cursor: pointer; border-radius: 4px; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; } +.cd-close:hover { background: var(--bg-hover); color: var(--fg); } +.cd-toggle { display: flex; gap: 4px; padding: 10px 14px 0; flex-shrink: 0; } +.cd-seg { font: inherit; font-size: 11px; padding: 4px 11px; border: 1px solid var(--border); background: transparent; color: var(--fg-mute); cursor: pointer; border-radius: 5px; } +.cd-seg.on { background: var(--bg-hover); color: var(--fg); border-color: var(--accent); } +.cd-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; } +.cd-pre { margin: 0; font-family: var(--mono); font-size: 12px; color: var(--fg); white-space: pre-wrap; word-break: break-word; } +.cd-frame { width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 6px; background: #fff; } [data-density='compact'] table.res-table th, [data-density='compact'] table.res-table td { padding: 4px 10px; } diff --git a/src/ui/app.js b/src/ui/app.js index 15915d9..f02f5a6 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -11,7 +11,7 @@ import { } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; -import { sqlString, inferQueryName } from '../core/format.js'; +import { sqlString, inferQueryName, shortVersion } from '../core/format.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; import { encodeSqlForHash } from '../core/share.js'; @@ -190,8 +190,12 @@ export function createApp(env = {}) { function setConn(online) { if (!app.dom.connStatus) return; app.dom.connStatus.classList.toggle('dim', !online); + const full = app.state.serverVersion; + // Show a short version (e.g. 26.3.10); full string on hover so the header + // doesn't crowd/overflow on a narrow window. + app.dom.connStatus.title = online ? 'ClickHouse ' + full : ''; app.dom.connStatus.replaceChildren(h('span', { class: 'ver' }, - online ? 'ClickHouse ' + app.state.serverVersion : 'offline')); + online ? 'ClickHouse ' + shortVersion(full) : 'offline')); } app.loadSchema = async () => { try { @@ -505,14 +509,14 @@ export function renderApp(app, helpers) { h('div', { class: 'env-chip' }, app.host()), h('div', { style: { flex: '1' } }), app.dom.connStatus, - h('button', { class: 'hd-btn', title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts() }, Icon.shortcuts()), - app.dom.themeBtn, - h('div', { class: 'user-email', title: app.email() }, app.email()), - h('button', { class: 'hd-btn text', title: 'Log out', onclick: () => app.signOut() }, 'Log Out'), h('a', { class: 'hd-btn', href: 'https://github.com/Altinity/altinity-sql-browser', target: '_blank', rel: 'noopener noreferrer', title: 'View source on GitHub', - }, Icon.github())); + }, Icon.github()), + h('button', { class: 'hd-btn', title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts() }, Icon.shortcuts()), + app.dom.themeBtn, + h('div', { class: 'user-email', title: app.email() }, app.email()), + h('button', { class: 'hd-btn text', title: 'Log out', onclick: () => app.signOut() }, 'Log Out')); app.dom.schemaSearchInput = h('input', { type: 'text', placeholder: 'Search tables, columns…', @@ -562,7 +566,7 @@ export function renderApp(app, helpers) { app.dom.saveBtn = h('button', { class: 'tb-btn save-btn', onclick: () => app.actions.save() }); app.dom.shareBtn = h('button', { class: 'tb-btn', title: 'Share query (copies link)', onclick: () => app.actions.share() }, Icon.share(), 'Share'); - const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, h('div', { style: { flex: '1' } }), app.dom.saveBtn, app.dom.shareBtn, app.dom.fmtSelect); + const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, app.dom.saveBtn, h('div', { style: { flex: '1' } }), app.dom.shareBtn, app.dom.fmtSelect); app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } }); app.dom.resultsRegion = h('div', { class: 'results-region', style: { flex: '1', minHeight: '0', overflow: 'hidden' } }); app.dom.editorResultsSplit = h('div', { class: 'row-resize', onmousedown: (e) => helpers.startDrag(e, 'row', dragCtx) }); diff --git a/src/ui/results.js b/src/ui/results.js index 7a28e5f..34fc531 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -5,6 +5,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { formatRows, formatBytes, isNumericType } from '../core/format.js'; +import { looksLikeHtml, prettyValue } from '../core/cell.js'; import { sortRows } from '../core/sort.js'; import { pickChartAxes, chartSeries } from '../core/chart-data.js'; @@ -171,6 +172,7 @@ export function renderTable(app, r) { r.columns.forEach((c, i) => { const isSort = col === i; const th = h('th', { + title: c.type || '', // type exposed on hover, not shown inline onclick: () => { if (isSort) app.state.resultSort.dir = dir === 'asc' ? 'desc' : 'asc'; else { app.state.resultSort.col = i; app.state.resultSort.dir = 'asc'; } @@ -200,7 +202,14 @@ export function renderTable(app, r) { tr.appendChild(h('td', { class: 'idx' }, String(ri + 1))); row.forEach((v, ci) => { const isNum = isNumericType(r.columns[ci].type); - tr.appendChild(h('td', { class: isNum ? 'num' : '' }, v == null ? '' : String(v))); + const text = v == null ? '' : String(v); + // Truncate in-cell (CSS max-width + ellipsis); click opens the full value + // in a side drawer so one fat column (e.g. HTML blobs) can't dominate. + tr.appendChild(h('td', { + class: 'cell' + (isNum ? ' num' : ''), + title: text.length > 100 ? text.slice(0, 100) + '…' : text, + onclick: () => openCellDetail(app, r.columns[ci].name, r.columns[ci].type, v), + }, h('div', { class: 'cell-val' }, text))); }); tbody.appendChild(tr); }); @@ -214,6 +223,59 @@ export function renderTable(app, r) { return wrap; } +/** + * Open a right-side drawer with one cell's full value: pretty-printed (JSON is + * reindented), and for HTML a Rendered (sandboxed iframe) ↔ Source toggle. + * Escape or a backdrop/✕ click closes it. Exported for tests. + */ +export function openCellDetail(app, name, type, value) { + const doc = app.document || document; + const text = value == null ? '' : String(value); + let backdrop; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + function close() { + if (backdrop) backdrop.remove(); + doc.removeEventListener('keydown', onKey, true); + } + + const body = h('div', { class: 'cd-body' }); + const showSource = () => body.replaceChildren(h('pre', { class: 'cd-pre' }, prettyValue(text))); + + const head = h('div', { class: 'cd-head' }, + h('div', { class: 'cd-title' }, + h('span', { class: 'cd-name' }, name), + type ? h('span', { class: 'cd-type' }, type) : null), + h('button', { class: 'cd-close', title: 'Close (Esc)', onclick: close }, Icon.close())); + + const panel = h('div', { class: 'cd-panel', onclick: (e) => e.stopPropagation() }, head); + + if (looksLikeHtml(text)) { + const seg = h('div', { class: 'cd-toggle' }); + const setMode = (mode) => { + seg.replaceChildren( + h('button', { class: 'cd-seg' + (mode === 'rendered' ? ' on' : ''), onclick: () => setMode('rendered') }, 'Rendered'), + h('button', { class: 'cd-seg' + (mode === 'source' ? ' on' : ''), onclick: () => setMode('source') }, 'Source')); + if (mode === 'rendered') { + const frame = h('iframe', { class: 'cd-frame', sandbox: '' }); + frame.setAttribute('srcdoc', text); + body.replaceChildren(frame); + } else { + showSource(); + } + }; + panel.append(seg, body); + setMode('rendered'); + } else { + panel.appendChild(body); + showSource(); + } + + backdrop = h('div', { class: 'cd-backdrop', onclick: close }, panel); + doc.body.appendChild(backdrop); + doc.addEventListener('keydown', onKey, true); + return backdrop; +} + export function renderChart(r) { const { xIdx, yIdx, ok } = pickChartAxes(r.columns); if (!ok) return h('div', { class: 'placeholder' }, h('div', null, 'No numeric columns to chart.')); diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 30a9019..e6419c2 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -484,7 +484,7 @@ describe('exhaustive controller coverage', () => { const app = createApp(e); app.renderApp(); app.root.querySelector('.new-tab').dispatchEvent(new Event('click')); - app.root.querySelectorAll('.hd-btn')[0].dispatchEvent(new Event('click')); // shortcuts + app.root.querySelector('.hd-btn[title^="Keyboard"]').dispatchEvent(new Event('click')); // shortcuts app.activeTab().sql = 'SELECT 1'; // set sql on the now-active tab app.dom.saveBtn.dispatchEvent(new Event('click')); // open save popover document.querySelector('.save-popover .sp-input').value = 'Q'; diff --git a/tests/unit/cell.test.js b/tests/unit/cell.test.js new file mode 100644 index 0000000..aec7fb0 --- /dev/null +++ b/tests/unit/cell.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { looksLikeHtml, prettyValue } from '../../src/core/cell.js'; + +describe('looksLikeHtml', () => { + it('true for tag pairs and self-closing tags', () => { + expect(looksLikeHtml('
hi
')).toBe(true); + expect(looksLikeHtml('

a


')).toBe(true); + expect(looksLikeHtml('')).toBe(true); + }); + it('false for a lone open tag, plain text, comparisons, and empty', () => { + expect(looksLikeHtml('')).toBe(false); // no close / self-close + expect(looksLikeHtml('just text')).toBe(false); + expect(looksLikeHtml('a < b and c > d')).toBe(false); + expect(looksLikeHtml('')).toBe(false); + expect(looksLikeHtml(null)).toBe(false); + }); +}); + +describe('prettyValue', () => { + it('reindents valid JSON objects and arrays', () => { + expect(prettyValue('{"a":1}')).toBe('{\n "a": 1\n}'); + expect(prettyValue('[1,2]')).toBe('[\n 1,\n 2\n]'); + }); + it('returns non-JSON as-is, coerces non-strings, and maps null/undefined to ""', () => { + expect(prettyValue('plain text')).toBe('plain text'); + expect(prettyValue('{not json')).toBe('{not json'); // starts with { but invalid → catch + expect(prettyValue(123)).toBe('123'); + expect(prettyValue(null)).toBe(''); + expect(prettyValue(undefined)).toBe(''); + }); +}); diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 365af41..2d342ed 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, } from '../../src/core/format.js'; describe('clamp', () => { @@ -101,3 +101,15 @@ describe('isNumericType', () => { expect(isNumericType(null)).toBe(false); }); }); + +describe('shortVersion', () => { + it('keeps the first three dot-segments of a long version', () => { + expect(shortVersion('26.3.10.20001.altinityantalya')).toBe('26.3.10'); + }); + it('passes through short versions and empty/nullish input', () => { + expect(shortVersion('26.3.1')).toBe('26.3.1'); + expect(shortVersion('26.3')).toBe('26.3'); + expect(shortVersion('')).toBe(''); + expect(shortVersion(null)).toBe(''); + }); +}); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 83df642..aedaf46 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderResults, renderJson, renderTable, renderChart, colResizeWidth } from '../../src/ui/results.js'; +import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail } from '../../src/ui/results.js'; import { makeApp } from '../helpers/fake-app.js'; import { newResult } from '../../src/core/stream.js'; @@ -138,13 +138,23 @@ describe('renderTable', () => { renderResults(app); expect(app.dom.resultsRegion.querySelectorAll('.res-act')).toHaveLength(0); }); - it('header shows column names only, not types', () => { + it('header shows column names only, with the type as a hover tooltip', () => { const el = renderTable(appWithResult(tableResult()), tableResult()); const ths = el.querySelectorAll('thead th'); expect(ths[1].querySelector('.h-name').textContent).toBe('n'); expect(el.querySelector('.h-type')).toBeNull(); - expect(ths[1].textContent).not.toContain('UInt64'); // type not rendered - expect(ths[2].textContent).not.toContain('String'); + expect(ths[1].textContent).not.toContain('UInt64'); // type not rendered inline + expect(ths[1].getAttribute('title')).toBe('UInt64'); // exposed on hover + expect(ths[2].getAttribute('title')).toBe('String'); + }); + it('data cells truncate (.cell-val) and open the detail drawer on click', () => { + const app = appWithResult(tableResult()); + const el = renderTable(app, app.activeTab().result); + const cell = el.querySelector('tbody td.cell'); + expect(cell.querySelector('.cell-val')).not.toBeNull(); + click(cell); + expect(app.document.querySelector('.cd-backdrop')).not.toBeNull(); + app.document.querySelector('.cd-backdrop').remove(); // cleanup }); it('truncates very large result sets', () => { const r = newResult('Table'); @@ -214,6 +224,55 @@ describe('column resize', () => { }); }); +describe('openCellDetail', () => { + it('text value → pretty
, no toggle; closes via ✕', () => {
+    const app = makeApp();
+    openCellDetail(app, 'col', 'String', '{"a":1}');
+    const bd = document.querySelector('.cd-backdrop');
+    expect(bd).not.toBeNull();
+    expect(bd.querySelector('.cd-name').textContent).toBe('col');
+    expect(bd.querySelector('.cd-type').textContent).toBe('String');
+    expect(bd.querySelector('.cd-pre').textContent).toBe('{\n  "a": 1\n}');
+    expect(bd.querySelector('.cd-toggle')).toBeNull();
+    click(bd.querySelector('.cd-close'));
+    expect(document.querySelector('.cd-backdrop')).toBeNull();
+  });
+  it('null value + no type → empty pre, no type chip', () => {
+    openCellDetail(makeApp(), 'c', '', null);
+    const bd = document.querySelector('.cd-backdrop');
+    expect(bd.querySelector('.cd-type')).toBeNull();
+    expect(bd.querySelector('.cd-pre').textContent).toBe('');
+    bd.remove();
+  });
+  it('HTML value → Rendered (sandboxed iframe srcdoc) ↔ Source toggle', () => {
+    openCellDetail(makeApp(), 'html', 'String', 'hi');
+    const bd = document.querySelector('.cd-backdrop');
+    expect([...bd.querySelectorAll('.cd-seg')].map((s) => s.textContent)).toEqual(['Rendered', 'Source']);
+    const frame = bd.querySelector('iframe.cd-frame');
+    expect(frame.getAttribute('sandbox')).toBe('');
+    expect(frame.getAttribute('srcdoc')).toBe('hi');
+    click(bd.querySelectorAll('.cd-seg')[1]); // → Source
+    expect(bd.querySelector('iframe')).toBeNull();
+    expect(bd.querySelector('.cd-pre').textContent).toBe('hi');
+    click(bd.querySelectorAll('.cd-seg')[0]); // → Rendered again
+    expect(bd.querySelector('iframe.cd-frame')).not.toBeNull();
+    bd.remove();
+  });
+  it('Escape closes; backdrop click closes; panel click does not', () => {
+    const app = makeApp();
+    openCellDetail(app, 'c', 'String', 'x');
+    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    expect(document.querySelector('.cd-backdrop')).toBeNull();
+    openCellDetail(app, 'c', 'String', 'x');
+    click(document.querySelector('.cd-backdrop'));
+    expect(document.querySelector('.cd-backdrop')).toBeNull();
+    openCellDetail(app, 'c', 'String', 'x');
+    click(document.querySelector('.cd-panel')); // stopPropagation → stays open
+    expect(document.querySelector('.cd-backdrop')).not.toBeNull();
+    document.querySelector('.cd-backdrop').remove();
+  });
+});
+
 describe('renderJson', () => {
   it('builds an array of row objects capped at the cap', () => {
     const r = tableResult();

From 8e202f7c01e1cb4bea0b6e137d3bed822fbb0e02 Mon Sep 17 00:00:00 2001
From: Isolator acm 
Date: Sun, 21 Jun 2026 18:45:23 +0200
Subject: [PATCH 2/2] fix(save): show "Saved" when a saved query is restored
 into a tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Restoring a saved query now links the new tab to it (loadIntoNewTab takes an
optional savedId; the Saved list passes the entry id, History passes none), so
the toolbar Save button reads "Saved" — matching the post-save state. Editing
the restored query flips it back to "Save" (dirty) and re-saving updates the
entry in place, as before.

npm test → 393 passing.

Co-Authored-By: Claude Opus 4.8 
Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef
---
 src/ui/app.js                    | 2 +-
 src/ui/saved-history.js          | 2 +-
 src/ui/tabs.js                   | 9 +++++++--
 tests/unit/app.test.js           | 9 +++++++++
 tests/unit/saved-history.test.js | 2 +-
 tests/unit/tabs.test.js          | 9 +++++----
 6 files changed, 24 insertions(+), 9 deletions(-)

diff --git a/src/ui/app.js b/src/ui/app.js
index f02f5a6..12bd02c 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -471,7 +471,7 @@ export function createApp(env = {}) {
     newTab: () => newTab(app),
     selectTab: (id) => selectTab(app, id),
     closeTab: (id) => closeTab(app, id),
-    loadIntoNewTab: (name, sql) => loadIntoNewTab(app, name, sql),
+    loadIntoNewTab: (name, sql, savedId) => loadIntoNewTab(app, name, sql, savedId),
     login: (idpId) => login(idpId),
     share,
     copyResult,
diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js
index 7060b0b..ff2822e 100644
--- a/src/ui/saved-history.js
+++ b/src/ui/saved-history.js
@@ -69,7 +69,7 @@ function renderSaved(app, list) {
       nameEl = h('span', { class: 'name' }, q.name);
     }
 
-    const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) app.actions.loadIntoNewTab(q.name, q.sql); } },
+    const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) app.actions.loadIntoNewTab(q.name, q.sql, q.id); } },
       h('div', { class: 'top' },
         star,
         nameEl,
diff --git a/src/ui/tabs.js b/src/ui/tabs.js
index 6644949..40e7e46 100644
--- a/src/ui/tabs.js
+++ b/src/ui/tabs.js
@@ -47,12 +47,17 @@ export function newTab(app) {
   if (app.dom.editorTextarea) app.dom.editorTextarea.focus();
 }
 
-/** Open a tab pre-seeded with `name`/`sql` (used by saved/history). */
-export function loadIntoNewTab(app, name, sql) {
+/**
+ * Open a tab pre-seeded with `name`/`sql` (used by saved/history). `savedId`
+ * links it to a saved query so the Save button reads "Saved" (restoring a saved
+ * query); omit it for history entries, which aren't saved.
+ */
+export function loadIntoNewTab(app, name, sql, savedId = null) {
   const id = allocTabId(app.state);
   const tab = newTabObj(id);
   tab.name = name || 'Untitled';
   tab.sql = sql;
+  tab.savedId = savedId;
   app.state.tabs.push(tab);
   app.state.activeTabId = id;
   refresh(app);
diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js
index e6419c2..d62c20d 100644
--- a/tests/unit/app.test.js
+++ b/tests/unit/app.test.js
@@ -443,6 +443,15 @@ describe('share + star + columns', () => {
     document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
     expect(document.querySelector('.save-popover')).toBeNull();
   });
+  it('restoring a saved query links the tab → Save button reads "Saved"', () => {
+    const app = createApp(env());
+    app.renderApp();
+    app.state.savedQueries = [{ id: 's9', name: 'Fav', sql: 'SELECT 9', favorite: false }];
+    app.actions.loadIntoNewTab('Fav', 'SELECT 9', 's9');
+    expect(app.activeTab().savedId).toBe('s9');
+    expect(app.dom.saveBtn.classList.contains('saved')).toBe(true);
+    expect(app.dom.saveBtn.textContent).toContain('Saved');
+  });
   it('loadColumns fills the table object', async () => {
     const e = env({ fetch: makeFetch([[(u, sql) => /system\.columns/.test(sql), resp({ json: { data: [{ name: 'id', type: 'UInt64', comment: '' }] } })]]) });
     const app = createApp(e);
diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js
index 0783429..60c4d0b 100644
--- a/tests/unit/saved-history.test.js
+++ b/tests/unit/saved-history.test.js
@@ -28,7 +28,7 @@ describe('renderSavedHistory', () => {
     const row = app.dom.savedList.querySelector('.saved-row');
     expect(row.querySelector('.preview').textContent).toBe('SELECT 1');
     click(row);
-    expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('Q1', 'SELECT 1\n-- more');
+    expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('Q1', 'SELECT 1\n-- more', 's1'); // links the tab
     byTitle(row, 'Delete').dispatchEvent(new Event('click', { bubbles: true }));
     expect(app.state.savedQueries).toHaveLength(0);
     expect(app.updateSaveBtn).toHaveBeenCalled();
diff --git a/tests/unit/tabs.test.js b/tests/unit/tabs.test.js
index f32a73c..02279a1 100644
--- a/tests/unit/tabs.test.js
+++ b/tests/unit/tabs.test.js
@@ -73,17 +73,18 @@ describe('newTab / loadIntoNewTab', () => {
     expect(app.activeTab().name).toBe('Untitled');
     expect(app.dom.editorTextarea.focus).toHaveBeenCalled();
   });
-  it('loadIntoNewTab seeds name + sql and focuses the editor', () => {
+  it('loadIntoNewTab seeds name + sql, links savedId, and focuses the editor', () => {
     const app = makeApp();
     app.dom.editorTextarea = { focus: vi.fn() };
-    loadIntoNewTab(app, 'Saved', 'SELECT 1');
-    expect(app.activeTab()).toMatchObject({ name: 'Saved', sql: 'SELECT 1' });
+    loadIntoNewTab(app, 'Saved', 'SELECT 1', 's1');
+    expect(app.activeTab()).toMatchObject({ name: 'Saved', sql: 'SELECT 1', savedId: 's1' });
     expect(app.dom.editorTextarea.focus).toHaveBeenCalled();
   });
-  it('loadIntoNewTab defaults the name', () => {
+  it('loadIntoNewTab defaults the name and leaves savedId null (history restore)', () => {
     const app = makeApp();
     loadIntoNewTab(app, '', 'SELECT 2');
     expect(app.activeTab().name).toBe('Untitled');
+    expect(app.activeTab().savedId).toBeNull();
   });
 });