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..12bd02c 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 { @@ -467,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, @@ -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/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 30a9019..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); @@ -484,7 +493,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();
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();
   });
 });