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();
});
});