Skip to content

Commit 31e1ce2

Browse files
feat(results): drop format dropdown; add TSV view + FORMAT-clause raw passthrough
The editor toolbar's Output-format <select> (Table/TSV/JSON) overlapped the results view switcher and was an awkward way to pick output. Remove it and make the results view switcher the single place to choose how results render: Table · Chart · JSON · TSV (new, client-side TabSeparatedWithNames over the sorted rows, ClickHouse \N for nulls). The dropdown's loved capability — "use any format in your SELECT" — is preserved and made automatic: detectSqlFormat() spots a trailing `FORMAT …` clause and runs the query raw, showing ClickHouse's response verbatim (CSV, Pretty, Vertical, …) in the existing raw-text view. Removes outputFormat state + KEYS.format + .tb-select CSS; adds Icon.tsv, renderTsv, detectSqlFormat, and a nullText option on toTSV. 740 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
1 parent 8ebfe31 commit 31e1ce2

11 files changed

Lines changed: 86 additions & 52 deletions

File tree

src/core/export.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ function cell(v) {
99
/**
1010
* TabSeparated text: a header row of column names + one line per data row.
1111
* Backslashes, tabs and newlines are escaped ClickHouse-TSV style so embedded
12-
* whitespace can't break the column/row grid when pasted.
12+
* whitespace can't break the column/row grid when pasted. `nullText` is emitted
13+
* verbatim for null/undefined cells — '' (default) for Copy, or '\N' for the TSV
14+
* result view so it mirrors `FORMAT TabSeparatedWithNames`.
1315
*/
14-
export function toTSV(columns, rows) {
16+
export function toTSV(columns, rows, nullText = '') {
1517
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
18+
const tsvCell = (v) => (v == null ? nullText : esc(String(v)));
1619
const head = columns.map((c) => esc(c.name)).join('\t');
17-
const body = rows.map((row) => row.map((v) => esc(cell(v))).join('\t')).join('\n');
20+
const body = rows.map((row) => row.map(tsvCell).join('\t')).join('\n');
1821
return rows.length ? head + '\n' + body : head;
1922
}
2023

src/core/format.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ export function withStatementBreak(sql) {
6363
return s === '' || /[\s;]$/.test(s) ? s : s + '\n';
6464
}
6565

66+
/**
67+
* The trailing `FORMAT <Name>` clause of a query, or null. ClickHouse requires
68+
* FORMAT to be the last clause, so we only match it at the end (optionally before
69+
* a `;`). Lets the results panel switch to raw passthrough when the user picks an
70+
* output format from their own SQL (e.g. `… FORMAT Pretty` / `FORMAT CSV`). Pure.
71+
*/
72+
export function detectSqlFormat(sql) {
73+
const m = /\bFORMAT\s+([A-Za-z][A-Za-z0-9]*)\s*;?\s*$/i.exec(String(sql || ''));
74+
return m ? m[1] : null;
75+
}
76+
6677
/**
6778
* Derive a short display name for a saved query: "Query · <table>" when a
6879
* FROM clause is present, else the first 48 chars of the collapsed SQL.

src/state.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ export function tabChart(tab) {
1212
return tab && tab.chartCfg ? { cfg: cloneChartCfg(tab.chartCfg), key: tab.chartKey ?? null } : null;
1313
}
1414

15-
/** Result views a saved query can remember (the raw TSV/JSON view is transient). */
16-
export const SAVED_VIEWS = new Set(['table', 'json', 'chart']);
15+
/** Result views a saved query can remember (a raw FORMAT-clause view is transient). */
16+
export const SAVED_VIEWS = new Set(['table', 'json', 'chart', 'tsv']);
1717

1818
export const KEYS = {
1919
theme: 'asb:theme',
2020
sidebarPx: 'asb:sidebarPx',
2121
editorPct: 'asb:editorPct',
2222
sideSplitPct: 'asb:sideSplitPct',
23-
format: 'asb:format',
2423
sidePanel: 'asb:sidePanel',
2524
saved: 'asb:saved',
2625
history: 'asb:history',
@@ -60,7 +59,6 @@ export function createState(read = { loadJSON, loadStr }) {
6059
abortController: null,
6160
resultView: 'table',
6261
resultSort: { col: null, dir: 'asc' },
63-
outputFormat: read.loadStr(KEYS.format, 'Table'),
6462
sidePanel: read.loadStr(KEYS.sidePanel, 'saved'),
6563
savedQueries: read.loadJSON(KEYS.saved, []),
6664
history: read.loadJSON(KEYS.history, []),

src/styles.css

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,14 @@ body {
619619
color: var(--fg); white-space: pre;
620620
background: var(--bg);
621621
}
622+
.tsv-view {
623+
height: 100%; overflow: auto; padding: 12px 14px;
624+
font-family: var(--mono); font-size: 11.5px;
625+
color: var(--fg); white-space: pre; tab-size: 8;
626+
background: var(--bg);
627+
}
622628
/* tabindex makes the pane keyboard-scrollable; suppress the focus ring. */
623-
.raw-text-view:focus, .json-view:focus { outline: none; }
629+
.raw-text-view:focus, .json-view:focus, .tsv-view:focus { outline: none; }
624630

625631
/* ------------ chart view ------------ */
626632
/* `.res-body` is display:block, so height:100% (not flex:1) is what lets the
@@ -847,24 +853,6 @@ body {
847853
.tb-btn:hover { background: var(--bg-hover); color: var(--fg); }
848854
.tb-btn[disabled] { opacity: .4; cursor: not-allowed; }
849855
.tb-btn[disabled]:hover { background: transparent; color: var(--fg-mute); }
850-
.tb-select {
851-
height: 26px;
852-
padding: 0 26px 0 9px;
853-
background-color: transparent;
854-
background-repeat: no-repeat; background-position: right 8px center;
855-
color: var(--fg-mute);
856-
border: 1px solid var(--border);
857-
border-radius: 5px;
858-
font-size: 11.5px; font-family: inherit;
859-
cursor: pointer; flex-shrink: 0;
860-
appearance: none; -webkit-appearance: none;
861-
}
862-
[data-theme='dark'] .tb-select {
863-
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path fill='%23A0A0A8' d='M0 0h8L4 5z'/></svg>");
864-
}
865-
[data-theme='light'] .tb-select {
866-
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path fill='%2357575E' d='M0 0h8L4 5z'/></svg>");
867-
}
868856

869857
/* ------------ SQL editor ------------ */
870858
.sql-editor {

src/ui/app.js

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '../state.js';
1212
import { saveJSON, saveStr } from '../core/storage.js';
1313
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
14-
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak } from '../core/format.js';
14+
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js';
1515
import { resolveTarget } from '../core/target.js';
1616
import { toTSV, toCSV } from '../core/export.js';
1717
import { newResult, applyStreamLine } from '../core/stream.js';
@@ -378,14 +378,16 @@ export function createApp(env = {}) {
378378
await ensureConfig();
379379
if (!(await getToken())) { chCtx.onSignedOut(); return; }
380380

381-
const fmt = app.state.outputFormat || 'Table';
381+
// Default to structured streaming (Table); if the user ends their SQL with a
382+
// FORMAT clause, run raw and show ClickHouse's response verbatim (#format).
383+
const fmt = detectSqlFormat(tab.sql) || 'Table';
382384
const t0 = now();
383385
tab.result = newResult(fmt);
384386
app.state.resultSort = { col: null, dir: 'asc' };
385387
// Keep the current Table/JSON/Chart tab across re-runs (#34); a saved-query
386388
// open passes its remembered view in opts.view to restore that instead.
387389
const view = opts && opts.view;
388-
app.state.resultView = ['table', 'json', 'chart'].includes(view) ? view : app.state.resultView;
390+
app.state.resultView = ['table', 'json', 'chart', 'tsv'].includes(view) ? view : app.state.resultView;
389391
app.state.running = true;
390392
app.state.runT0 = t0;
391393
app.state.runQueryId = cryptoObj.randomUUID ? cryptoObj.randomUUID() : 'q' + t0;
@@ -743,18 +745,11 @@ export function renderApp(app, helpers) {
743745
h('button', { class: 'new-tab', title: 'New query', onclick: () => app.actions.newTab() }, Icon.plus()));
744746

745747
app.dom.runBtn = h('button', { class: 'run-btn', onclick: () => app.actions.run() }, Icon.play(), h('span', null, 'Run'), h('kbd', null, '⌘↵'));
746-
app.dom.fmtSelect = h('select', {
747-
class: 'tb-select', title: 'Output format',
748-
onchange: (e) => { state.outputFormat = e.target.value; app.savePref('format', state.outputFormat); },
749-
},
750-
h('option', { value: 'Table', selected: state.outputFormat === 'Table' }, 'Table'),
751-
h('option', { value: 'TSV', selected: state.outputFormat === 'TSV' }, 'TSV'),
752-
h('option', { value: 'JSON', selected: state.outputFormat === 'JSON' }, 'JSON'));
753748
app.dom.fmtBtn = h('button', { class: 'tb-btn', title: 'Format SQL (⌘⇧↵)', onclick: () => app.actions.formatQuery() }, Icon.braces(), 'Format');
754749
app.dom.saveBtn = h('button', { class: 'tb-btn save-btn', onclick: () => app.actions.save() });
755750
app.dom.shareBtn = h('button', { class: 'tb-btn', title: 'Share query (copies link)', onclick: () => app.actions.share() }, Icon.share(), 'Share');
756751

757-
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);
752+
const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, app.dom.saveBtn, h('div', { style: { flex: '1' } }), app.dom.shareBtn);
758753
app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } });
759754
app.dom.resultsRegion = h('div', { class: 'results-region', style: { flex: '1', minHeight: '0', overflow: 'hidden' } });
760755
app.dom.editorResultsSplit = h('div', { class: 'row-resize', onmousedown: (e) => helpers.startDrag(e, 'row', dragCtx) });

src/ui/icons.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const Icon = {
5959
chart: () => svg('M2 10V7M5 10V4M8 10V6M11 10V2', 12, 12),
6060
json: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12),
6161
table2: () => iconEl('<rect x="1.5" y="2" width="9" height="8" rx=".5"/><path d="M1.5 4.5h9M1.5 7h9M4.5 4.5v5"/>', 12, 12),
62+
tsv: () => svg('M2 3h8M2 6h8M2 9h8M5 2.5v7', 12, 12, { stroke: 1.3 }),
6263
shortcuts: () => iconEl('<rect x="1.5" y="3" width="9" height="6" rx="1"/><path d="M3.5 5h.01M6 5h.01M8.5 5h.01M3.5 7h5"/>', 12, 12, 1.3),
6364
copy: () => iconEl('<rect x="3.5" y="3.5" width="7" height="7" rx="1"/><path d="M2 8.5V2.5a1 1 0 0 1 1-1h6"/>', 12, 12),
6465
download: () => iconEl('<path d="M6 1.5v6.5M3.5 5.5L6 8l2.5-2.5"/><path d="M2 10h8"/>', 12, 12),

src/ui/results.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Icon } from './icons.js';
77
import { formatRows, formatBytes, isNumericType } from '../core/format.js';
88
import { looksLikeHtml, prettyValue } from '../core/cell.js';
99
import { sortRows } from '../core/sort.js';
10+
import { toTSV } from '../core/export.js';
1011
import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, CHART_ROW_CAP } from '../core/chart-data.js';
1112

1213
const VIS_CAP = 5000;
@@ -103,6 +104,8 @@ export function renderResults(app) {
103104
inner.appendChild(renderJson(r));
104105
} else if (app.state.resultView === 'chart') {
105106
inner.appendChild(renderChart(app, r));
107+
} else if (app.state.resultView === 'tsv') {
108+
inner.appendChild(renderTsv(app, r));
106109
} else {
107110
inner.appendChild(renderTable(app, r));
108111
}
@@ -123,11 +126,12 @@ function buildToolbar(app, r) {
123126
const toolbar = h('div', { class: 'res-toolbar' });
124127
const tabs = h('div', { class: 'result-view-tabs' });
125128
const views = isRaw
126-
? [{ id: 'raw', label: r.rawFormat, icon: r.rawFormat === 'JSON' ? Icon.json() : Icon.table2() }]
129+
? [{ id: 'raw', label: r.rawFormat, icon: r.rawFormat === 'JSON' ? Icon.json() : Icon.tsv() }]
127130
: [
128131
{ id: 'table', label: 'Table', icon: Icon.table2() },
129132
{ id: 'json', label: 'JSON', icon: Icon.json() },
130133
{ id: 'chart', label: 'Chart', icon: Icon.chart() },
134+
{ id: 'tsv', label: 'TSV', icon: Icon.tsv() },
131135
];
132136
for (const v of views) {
133137
const isActive = app.state.resultView === v.id || (isRaw && v.id === 'raw');
@@ -184,6 +188,18 @@ export function renderJson(r) {
184188
return h('div', { class: 'json-view', tabindex: '0' }, JSON.stringify(arr, null, 2));
185189
}
186190

191+
/**
192+
* TSV view — a `<pre>` of the result as TabSeparatedWithNames, honoring the
193+
* current column sort (like the table) and ClickHouse's `\N` null sentinel. A
194+
* client-side approximation of `FORMAT TabSeparatedWithNames`; for an exact
195+
* server format, end the query with an explicit `FORMAT …` clause (raw view).
196+
*/
197+
export function renderTsv(app, r) {
198+
const { col, dir } = app.state.resultSort;
199+
const rows = sortRows(r.rows, col, dir).slice(0, VIS_CAP);
200+
return h('div', { class: 'tsv-view', tabindex: '0' }, toTSV(r.columns, rows, '\\N'));
201+
}
202+
187203
export function renderTable(app, r) {
188204
const { col, dir } = app.state.resultSort;
189205
const rows = sortRows(r.rows, col, dir);

tests/unit/app.test.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,6 @@ describe('renderApp shell', () => {
147147
expect(e.sessionStorage.getItem('oauth_verifier')).toBeNull();
148148
expect(e.sessionStorage.getItem('oauth_state')).toBeNull();
149149
});
150-
it('changing the format select persists the choice', () => {
151-
const { app } = rendered();
152-
app.dom.fmtSelect.value = 'JSON';
153-
app.dom.fmtSelect.dispatchEvent(new Event('change'));
154-
expect(app.state.outputFormat).toBe('JSON');
155-
});
156150
it('schema search updates the filter', () => {
157151
const { app } = rendered();
158152
app.dom.schemaSearchInput.value = 'foo';
@@ -346,14 +340,14 @@ describe('query run', () => {
346340
expect(app.activeTab().result.error).toContain('nope');
347341
expect(app.state.history.length).toBe(0);
348342
});
349-
it('captures raw output (TSV)', async () => {
343+
it('runs raw and captures the response when the SQL ends with a FORMAT clause', async () => {
350344
const { app } = appForRun([
351345
[(u, sql) => /SELECT 9/.test(sql), resp({ text: 'a\tb' })],
352346
]);
353-
app.state.outputFormat = 'TSV';
354-
app.activeTab().sql = 'SELECT 9';
347+
app.activeTab().sql = 'SELECT 9 FORMAT TabSeparatedWithNames';
355348
await app.actions.run();
356349
expect(app.activeTab().result.rawText).toBe('a\tb');
350+
expect(app.activeTab().result.rawFormat).toBe('TabSeparatedWithNames'); // label for the raw tab
357351
});
358352
});
359353

tests/unit/format.test.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import {
3-
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak,
3+
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat,
44
} from '../../src/core/format.js';
55

66
describe('clamp', () => {
@@ -86,6 +86,21 @@ describe('withStatementBreak', () => {
8686
});
8787
});
8888

89+
describe('detectSqlFormat', () => {
90+
it('returns the trailing FORMAT clause name (case-insensitive keyword, allows ; and trailing ws)', () => {
91+
expect(detectSqlFormat('SELECT 1 FORMAT Pretty')).toBe('Pretty');
92+
expect(detectSqlFormat('SELECT * FROM t\nFORMAT JSONEachRow')).toBe('JSONEachRow');
93+
expect(detectSqlFormat('select 1 format CSV')).toBe('CSV');
94+
expect(detectSqlFormat('SELECT 1 FORMAT TabSeparatedWithNames ; ')).toBe('TabSeparatedWithNames');
95+
});
96+
it('returns null without a trailing FORMAT clause', () => {
97+
expect(detectSqlFormat('SELECT 1')).toBeNull();
98+
expect(detectSqlFormat("SELECT 'FORMAT JSON' AS x")).toBeNull(); // FORMAT not the trailing clause
99+
expect(detectSqlFormat('')).toBeNull();
100+
expect(detectSqlFormat(null)).toBeNull();
101+
});
102+
});
103+
89104
describe('inferQueryName', () => {
90105
it('uses FROM table when present', () => {
91106
expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables');

tests/unit/results.test.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail, installChartZoomFix } from '../../src/ui/results.js';
2+
import { renderResults, renderJson, renderTable, renderChart, renderTsv, colResizeWidth, openCellDetail, installChartZoomFix } from '../../src/ui/results.js';
33
import { makeApp } from '../helpers/fake-app.js';
44
import { newResult } from '../../src/core/stream.js';
55
import { schemaKey } from '../../src/core/chart-data.js';
@@ -484,3 +484,19 @@ describe('installChartZoomFix', () => {
484484
expect(installChartZoomFix(null, null)).toBeNull();
485485
});
486486
});
487+
488+
describe('renderTsv (TSV view)', () => {
489+
it('renders TabSeparatedWithNames text with \\N for nulls, honoring the current sort', () => {
490+
const app = appWithResult(tableResult());
491+
const pre = renderTsv(app, app.activeTab().result);
492+
expect(pre.className).toContain('tsv-view');
493+
expect(pre.textContent).toBe('n\ts\n2\tb\n1\t\\N');
494+
});
495+
it('is the 4th view tab and renders through the switcher', () => {
496+
const app = appWithResult(tableResult(), { resultView: 'tsv' });
497+
renderResults(app);
498+
const labels = [...app.dom.resultsRegion.querySelectorAll('.result-view-tab span')].map((s) => s.textContent);
499+
expect(labels).toEqual(['Table', 'JSON', 'Chart', 'TSV']);
500+
expect(app.dom.resultsRegion.querySelector('.tsv-view')).not.toBeNull();
501+
});
502+
});

0 commit comments

Comments
 (0)