Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions deploy/http_handlers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<Content-Security-Policy>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'</Content-Security-Policy>
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. -->
<Content-Security-Policy>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'</Content-Security-Policy>
<X-Content-Type-Options>nosniff</X-Content-Type-Options>
<Referrer-Policy>no-referrer</Referrer-Policy>
</http_response_headers>
Expand Down
25 changes: 25 additions & 0 deletions src/core/cell.js
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '');
}
34 changes: 34 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
22 changes: 13 additions & 9 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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…',
Expand Down Expand Up @@ -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) });
Expand Down
64 changes: 63 additions & 1 deletion src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'; }
Expand Down Expand Up @@ -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);
});
Expand All @@ -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.'));
Expand Down
2 changes: 1 addition & 1 deletion src/ui/saved-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/ui/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/cell.test.js
Original file line number Diff line number Diff line change
@@ -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('<div>hi</div>')).toBe(true);
expect(looksLikeHtml('<p>a</p><br/>')).toBe(true);
expect(looksLikeHtml('<img src="x"/>')).toBe(true);
});
it('false for a lone open tag, plain text, comparisons, and empty', () => {
expect(looksLikeHtml('<img src=x>')).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('');
});
});
14 changes: 13 additions & 1 deletion tests/unit/format.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('');
});
});
Loading
Loading