diff --git a/src/core/stream.js b/src/core/stream.js
index c50a598..b8a77e7 100644
--- a/src/core/stream.js
+++ b/src/core/stream.js
@@ -16,6 +16,7 @@ export function newResult(fmt) {
rawFormat: fmt,
progress: { rows: 0, bytes: 0, elapsed_ns: 0 },
error: null,
+ cancelled: false,
pct: 0,
};
}
diff --git a/src/net/ch-client.js b/src/net/ch-client.js
index c4a3e7c..4cd0da2 100644
--- a/src/net/ch-client.js
+++ b/src/net/ch-client.js
@@ -75,6 +75,18 @@ export async function queryJson(ctx, sql) {
return resp.json();
}
+/**
+ * Best-effort `KILL QUERY` for the given query_id (the client also aborts the
+ * stream; this stops the server-side work). Swallows errors — cancellation must
+ * never throw at the call site, and the user lacking the privilege is non-fatal.
+ */
+export async function killQuery(ctx, queryId, sqlString) {
+ if (!queryId) return;
+ try {
+ await queryJson(ctx, 'KILL QUERY WHERE query_id = ' + sqlString(queryId) + ' ASYNC');
+ } catch { /* best-effort */ }
+}
+
/** Fetch `version()` + `uptime()`. Returns the version string ('' on shape miss). */
export async function loadServerVersion(ctx) {
const json = await queryJson(ctx, 'SELECT version() AS v, uptime() AS u FORMAT JSON');
@@ -139,7 +151,14 @@ export async function runQuery(ctx, sql, o = {}) {
: 'JSONCompact';
const url = chUrl(ctx.origin, {
format: fmtParam,
- extra: { wait_end_of_query: 1, add_http_cors_header: 1 },
+ // wait_end_of_query buffers the whole response server-side so the HTTP
+ // status reflects errors — but it defeats progressive streaming (first rows
+ // wait for the query to finish: ~16s vs ~0.5s on a 1.3M-row scan). Keep it
+ // only for raw modes (read whole anyway); the streaming Table path drops it
+ // and surfaces mid-stream errors via the in-band `exception` line instead.
+ extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), add_http_cors_header: 1 },
+ // Tagging the request with a query_id lets Cancel issue KILL QUERY for it.
+ params: o.queryId ? { query_id: o.queryId } : {},
});
const resp = await authedFetch(ctx, url, sql, o.signal);
diff --git a/src/styles.css b/src/styles.css
index 1ec65e6..aadad08 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -866,17 +866,37 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
border-radius: 4px; color: var(--fg-mute);
}
-/* progress */
-.progress-bar {
- position: absolute; left: 0; right: 0; bottom: 0;
- height: 2px; background: transparent;
- overflow: hidden; pointer-events: none;
-}
-.progress-bar > i {
- display: block; height: 100%;
- background: var(--accent);
- width: var(--progress, 0%);
- transition: width .15s linear;
+/* streaming progress strip + live run state */
+.stream-strip {
+ position: absolute; left: 0; right: 0; top: 0;
+ height: 2px; background: var(--bg-chip);
+ overflow: hidden; pointer-events: none; z-index: 2;
+}
+.stream-strip > i { display: block; height: 100%; background: var(--accent); }
+.stream-strip > i.fill { transition: width .3s linear; }
+.stream-strip > i.sweep { width: 40%; animation: runsweep 1.1s ease-in-out infinite; }
+@keyframes runsweep {
+ 0% { transform: translateX(-110%); }
+ 100% { transform: translateX(310%); }
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+.spin { display: inline-flex; animation: spin .8s linear infinite; }
+.placeholder.starting { color: var(--fg-mute); }
+.placeholder.starting .spin { color: var(--accent); }
+/* live run counters (accent) shown in the results toolbar while streaming */
+.stat.live, .stat.live .ic, .stat.live .v { color: var(--accent); }
+.res-act.cancel-act:hover {
+ background: color-mix(in oklab, #ef4444 14%, transparent);
+ color: #ef4444; border-color: transparent;
+}
+.res-act kbd {
+ font-family: var(--mono); font-size: 9.5px; opacity: .7;
+ padding: 1px 4px; background: var(--bg-chip); border-radius: 3px; margin-left: 2px;
+}
+.cancelled-badge {
+ font-family: var(--mono); font-size: 10.5px; color: #ef4444;
+ padding: 2px 7px; border-radius: 4px;
+ background: color-mix(in oklab, #ef4444 12%, transparent);
}
/* scrollbars */
diff --git a/src/ui/app.js b/src/ui/app.js
index 30b0883..db68e99 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -249,29 +249,41 @@ export function createApp(env = {}) {
}
// --- query run ---------------------------------------------------------
+ const now = () => (env.now || (() => win.performance.now()))();
+ // Milliseconds since the running query started (0 when idle). Used for the
+ // live counter, computed fresh so each render/tick shows the current value.
+ app.elapsedMs = () => (app.state.runT0 != null ? now() - app.state.runT0 : 0);
+ // Update only the live elapsed-ms readout (no table re-render). Driven by an
+ // interval while running so it ticks even for queries that emit no rows (sleep).
+ function tickElapsed() {
+ if (app.dom.runElapsedEl) app.dom.runElapsedEl.textContent = app.elapsedMs().toFixed(0) + ' ms';
+ }
+ app.tickElapsed = tickElapsed;
+
async function run() {
- if (app.state.running) {
- if (app.state.abortController) app.state.abortController.abort();
- return;
- }
+ if (app.state.running) return; // already running — cancel via cancel()/Esc
const tab = app.activeTab();
if (!tab.sql.trim()) return;
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }
const fmt = app.state.outputFormat || 'Table';
- const t0 = (env.now || (() => win.performance.now()))();
+ const t0 = now();
tab.result = newResult(fmt);
app.state.resultSort = { col: null, dir: 'asc' };
app.state.resultView = 'table';
app.state.running = true;
+ app.state.runT0 = t0;
+ app.state.runQueryId = cryptoObj.randomUUID ? cryptoObj.randomUUID() : 'q' + t0;
setRunBtn(true);
renderResults(app);
app.state.abortController = new AbortController();
+ app.state.runTick = setInterval(tickElapsed, 100);
try {
const out = await ch.runQuery(chCtx, tab.sql, {
format: fmt,
+ queryId: app.state.runQueryId,
signal: app.state.abortController.signal,
onLine: (json) => applyStreamLine(json, tab.result),
onChunk: () => renderResults(app),
@@ -282,24 +294,39 @@ export function createApp(env = {}) {
tab.result.progress.bytes = out.raw.length;
}
} catch (e) {
- if (e.name === 'AbortError') tab.result.error = 'Query was cancelled';
+ // Cancel = abort: keep whatever streamed in, flag it partial (no error).
+ if (e.name === 'AbortError') tab.result.cancelled = true;
else if (e instanceof TypeError) tab.result.error = 'Network error';
else tab.result.error = String((e && e.message) || e);
} finally {
+ clearInterval(app.state.runTick);
+ app.state.runTick = null;
app.state.running = false;
app.state.abortController = null;
- tab.result.progress.elapsed_ns = ((env.now || (() => win.performance.now()))() - t0) * 1e6;
+ app.state.runQueryId = null;
+ app.state.runT0 = null;
+ tab.result.progress.elapsed_ns = (now() - t0) * 1e6;
setRunBtn(false);
renderResults(app);
- if (!tab.result.error) app.recordHistory(tab);
+ if (!tab.result.error && !tab.result.cancelled) app.recordHistory(tab);
}
}
+ // Stop an in-flight query: abort the stream and KILL QUERY on the server.
+ function cancel() {
+ if (!app.state.running) return;
+ if (app.state.abortController) app.state.abortController.abort();
+ ch.killQuery(chCtx, app.state.runQueryId, sqlString);
+ }
function setRunBtn(running) {
if (!app.dom.runBtn) return;
app.dom.runBtn.disabled = running;
- app.dom.runBtn.replaceChildren(Icon.play(), h('span', null, running ? 'Running…' : 'Run'),
- running ? null : h('kbd', null, '⌘↵'));
+ // Build the children and drop the null (replaceChildren would otherwise
+ // coerce a null arg into a "null" text node → "Running…null").
+ app.dom.runBtn.replaceChildren(
+ ...[Icon.play(), h('span', null, running ? 'Running…' : 'Run'),
+ running ? null : h('kbd', null, '⌘↵')].filter(Boolean));
}
+ app.setRunBtn = setRunBtn;
// Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place.
async function formatQuery() {
@@ -524,6 +551,7 @@ export function createApp(env = {}) {
// --- actions registry --------------------------------------------------
app.actions = {
run,
+ cancel,
newTab: () => newTab(app),
selectTab: (id) => selectTab(app, id),
closeTab: (id) => closeTab(app, id),
diff --git a/src/ui/icons.js b/src/ui/icons.js
index 7ce7917..05d62b3 100644
--- a/src/ui/icons.js
+++ b/src/ui/icons.js
@@ -38,6 +38,7 @@ export const Icon = {
play: () => svgFilled('M3 2l7 4-7 4z'),
plus: () => svg('M6 2v8M2 6h8', 12, 12, { stroke: 1.6 }),
close: () => svg('M2 2l6 6M8 2l-6 6', 10, 10, { stroke: 1.6 }),
+ spinner: () => svg('M6 1.2a4.8 4.8 0 1 1-4.8 4.8', 12, 12, { stroke: 1.6 }),
search: () => iconEl('', 12, 12, 1.5),
sun: () => iconEl(''),
moon: () => svg('M11 7.5A4 4 0 1 1 6.5 3a3.2 3.2 0 0 0 4.5 4.5z', 14, 14),
diff --git a/src/ui/results.js b/src/ui/results.js
index 68ca836..602a450 100644
--- a/src/ui/results.js
+++ b/src/ui/results.js
@@ -78,12 +78,14 @@ export function renderResults(app) {
body.appendChild(buildToolbar(app, r));
const inner = h('div', { class: 'res-body' });
+ // While running, pin a streaming strip to the top of the body: a determinate
+ // fill at read/total when known, else an indeterminate sweep.
+ if (app.state.running) inner.appendChild(streamStrip(r));
const streamingBlank = app.state.running && (!r || (r.rows.length === 0 && r.rawText == null));
if (streamingBlank) {
- inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': (r ? r.pct : 0) + '%' } }, h('i')));
- inner.appendChild(h('div', { class: 'placeholder' },
- h('div', null, 'Streaming results…'),
- r ? h('code', null, formatRows(r.progress.rows) + ' rows · ' + formatBytes(r.progress.bytes)) : null));
+ inner.appendChild(h('div', { class: 'placeholder starting' },
+ h('span', { class: 'spin' }, Icon.spinner()),
+ h('div', null, 'Starting query…')));
} else if (!r) {
inner.appendChild(h('div', { class: 'empty-results' },
h('div', { class: 'chip' }, Icon.play()),
@@ -100,14 +102,19 @@ export function renderResults(app) {
inner.appendChild(renderChart(r));
} else {
inner.appendChild(renderTable(app, r));
- if (app.state.running) {
- inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': r.pct + '%' } }, h('i')));
- }
}
body.appendChild(inner);
region.replaceChildren(body);
}
+// 2px progress strip atop the results body while a query streams.
+function streamStrip(r) {
+ return h('div', { class: 'stream-strip' },
+ r && r.pct > 0
+ ? h('i', { class: 'fill', style: { width: r.pct + '%' } })
+ : h('i', { class: 'sweep' }));
+}
+
function buildToolbar(app, r) {
const isRaw = r && r.rawText != null;
const toolbar = h('div', { class: 'res-toolbar' });
@@ -128,7 +135,23 @@ function buildToolbar(app, r) {
}
toolbar.appendChild(tabs);
toolbar.appendChild(h('div', { style: { flex: '1' } }));
- if (r) {
+ if (app.state.running) {
+ // Live counters (accent, mono) + Cancel — replaces the static stats while
+ // streaming. The ms element is updated in place by app.tickElapsed().
+ app.dom.runElapsedEl = h('span', { class: 'v' }, app.elapsedMs().toFixed(0) + ' ms');
+ toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), app.dom.runElapsedEl));
+ toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.rows()),
+ h('span', { class: 'v' }, formatRows(r ? r.progress.rows : 0) + ' rows')));
+ toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.bytes()),
+ h('span', { class: 'v' }, formatBytes(r ? r.progress.bytes : 0))));
+ toolbar.appendChild(h('button', {
+ class: 'res-act cancel-act', title: 'Cancel query (Esc)',
+ onclick: () => app.actions.cancel(),
+ }, Icon.close(), h('span', null, 'Cancel'), h('kbd', null, 'Esc')));
+ } else if (r) {
+ if (r.cancelled) {
+ toolbar.appendChild(h('span', { class: 'cancelled-badge' }, 'Cancelled · partial'));
+ }
const ms = (r.progress.elapsed_ns / 1e6).toFixed(0);
toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms')));
toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()),
diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js
index 8ee2045..3561b65 100644
--- a/src/ui/shortcuts.js
+++ b/src/ui/shortcuts.js
@@ -56,6 +56,12 @@ export function openShortcuts(app) {
export function handleKeydown(e, app) {
const mod = e.metaKey || e.ctrlKey;
const signedIn = app.isSignedIn();
+ // Esc cancels an in-flight query (aborts the stream + KILL QUERY).
+ if (e.key === 'Escape' && app.state.running) {
+ e.preventDefault();
+ app.actions.cancel();
+ return 'cancel';
+ }
if (mod && e.key === 'Enter') {
// ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run.
if (e.shiftKey) {
diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js
index fe1b676..ad6a5d5 100644
--- a/tests/helpers/fake-app.js
+++ b/tests/helpers/fake-app.js
@@ -18,6 +18,7 @@ export function makeApp(over = {}) {
savePref: vi.fn(),
saveJSON: vi.fn(),
updateSaveBtn: vi.fn(),
+ elapsedMs: () => 0,
editingSavedId: null,
showLogin: vi.fn(),
signOut: vi.fn(),
@@ -33,6 +34,7 @@ export function makeApp(over = {}) {
},
actions: {
run: vi.fn(),
+ cancel: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
closeTab: vi.fn(),
diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js
index 7683d75..c0506e6 100644
--- a/tests/unit/app.test.js
+++ b/tests/unit/app.test.js
@@ -206,12 +206,47 @@ describe('query run', () => {
await app.actions.run();
expect(app.activeTab().result).toBeNull();
});
- it('aborts a running query on a second invocation', async () => {
+ it('run() while already running is a no-op (cancel is separate)', async () => {
const { app } = appForRun([]);
app.state.running = true;
- app.state.abortController = { abort: vi.fn() };
+ const ac = { abort: vi.fn() };
+ app.state.abortController = ac;
await app.actions.run();
- expect(app.state.abortController).toBeTruthy();
+ expect(ac.abort).not.toHaveBeenCalled(); // re-running no longer aborts
+ expect(app.state.running).toBe(true);
+ });
+ it('setRunBtn: "Running…" with no trailing "null"; "Run" + kbd when idle', () => {
+ const { app } = appForRun([]);
+ app.setRunBtn(true);
+ expect(app.dom.runBtn.disabled).toBe(true);
+ expect(app.dom.runBtn.textContent).toBe('Running…'); // regression: not "Running…null"
+ app.setRunBtn(false);
+ expect(app.dom.runBtn.disabled).toBe(false);
+ expect(app.dom.runBtn.textContent).toContain('Run');
+ expect(app.dom.runBtn.querySelector('kbd')).not.toBeNull();
+ });
+ it('tickElapsed updates the live ms readout, and no-ops without the element', () => {
+ const { app } = appForRun([]);
+ app.state.runT0 = 0;
+ app.dom.runElapsedEl = document.createElement('span');
+ app.tickElapsed(); // env.now → 0
+ expect(app.dom.runElapsedEl.textContent).toBe('0 ms');
+ app.dom.runElapsedEl = null;
+ expect(() => app.tickElapsed()).not.toThrow();
+ });
+ it('cancel() aborts + issues KILL QUERY when running; no-op when idle', async () => {
+ const { app, e } = appForRun([]);
+ app.actions.cancel(); // idle → no-op, no throw
+ const abort = vi.fn();
+ app.state.running = true;
+ app.state.abortController = { abort, signal: {} };
+ app.state.runQueryId = 'qid-1';
+ app.actions.cancel();
+ expect(abort).toHaveBeenCalled();
+ await new Promise((r) => setTimeout(r)); // let the fire-and-forget KILL QUERY run
+ const kill = e.fetch.mock.calls.find((c) => /KILL QUERY/.test((c[1] && c[1].body) || ''));
+ expect(kill).toBeTruthy();
+ expect(kill[1].body).toContain("query_id = 'qid-1'");
});
it('surfaces a query error', async () => {
const { app } = appForRun([
@@ -589,13 +624,15 @@ describe('exhaustive controller coverage', () => {
await app.actions.run();
expect(app.activeTab().result.error).toBe('Network error');
});
- it('run(): AbortError → "Query was cancelled"', async () => {
+ it('run(): AbortError marks the result cancelled (keeps partial rows, no error)', async () => {
const e = env({ fetch: vi.fn(async () => { const err = new Error('x'); err.name = 'AbortError'; throw err; }) });
const app = createApp(e);
app.renderApp();
app.activeTab().sql = 'SELECT 1';
await app.actions.run();
- expect(app.activeTab().result.error).toBe('Query was cancelled');
+ expect(app.activeTab().result.cancelled).toBe(true);
+ expect(app.activeTab().result.error).toBeNull();
+ expect(app.state.history.length).toBe(0); // cancelled runs are not recorded
});
it('run(): generic error → message', async () => {
const e = env({ fetch: vi.fn(async () => { throw new Error('weird'); }) });
diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js
index 4d1b37d..20b6234 100644
--- a/tests/unit/ch-client.test.js
+++ b/tests/unit/ch-client.test.js
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import {
- chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, runQuery,
+ chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, runQuery, killQuery,
} from '../../src/net/ch-client.js';
import { sqlString } from '../../src/core/format.js';
@@ -218,4 +218,34 @@ describe('runQuery', () => {
await runQuery(ctx, 'x', { signal });
expect(ctx.fetch.mock.calls[0][1].signal).toBe(signal);
});
+ it('tags the run request with query_id when given', async () => {
+ const ctx = ctxWith(async () => streamResp(['{"row":{}}\n']));
+ await runQuery(ctx, 'x', { queryId: 'abc-123' });
+ expect(ctx.fetch.mock.calls[0][0]).toContain('query_id=abc-123');
+ });
+ it('streams without wait_end_of_query; raw modes keep it for clean error status', async () => {
+ const s = ctxWith(async () => streamResp(['{"row":{}}\n']));
+ await runQuery(s, 'x', { format: 'Table' });
+ expect(s.fetch.mock.calls[0][0]).not.toContain('wait_end_of_query'); // progressive first rows
+ const raw = ctxWith(async () => textResp('a\tb'));
+ await runQuery(raw, 'x', { format: 'TSV' });
+ expect(raw.fetch.mock.calls[0][0]).toContain('wait_end_of_query=1');
+ });
+});
+
+describe('killQuery', () => {
+ it('POSTs KILL QUERY for the query_id', async () => {
+ const ctx = ctxWith(async () => jsonResp({ data: [] }));
+ await killQuery(ctx, 'abc-123', sqlString);
+ expect(ctx.fetch.mock.calls[0][1].body).toBe("KILL QUERY WHERE query_id = 'abc-123' ASYNC");
+ });
+ it('no-ops without a query_id', async () => {
+ const ctx = ctxWith(async () => jsonResp({ data: [] }));
+ await killQuery(ctx, null, sqlString);
+ expect(ctx.fetch).not.toHaveBeenCalled();
+ });
+ it('swallows errors (cancellation must never throw)', async () => {
+ const ctx = ctxWith(async () => { throw new Error('boom'); });
+ await expect(killQuery(ctx, 'q', sqlString)).resolves.toBeUndefined();
+ });
});
diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js
index aedaf46..96d1f7e 100644
--- a/tests/unit/results.test.js
+++ b/tests/unit/results.test.js
@@ -31,19 +31,28 @@ describe('renderResults states', () => {
renderResults(app);
expect(app.dom.resultsRegion.textContent).toContain('to run query');
});
- it('streaming-blank with a partial result shows progress', () => {
+ it('streaming-blank shows "Starting query…", a determinate strip, live counters + Cancel, and no "null"', () => {
const r = newResult('Table');
r.pct = 40;
r.progress = { rows: 10, bytes: 50, elapsed_ns: 0 };
const app = appWithResult(r, { running: true });
renderResults(app);
- expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull();
- expect(app.dom.resultsRegion.textContent).toContain('Streaming results…');
- });
- it('streaming-blank with no result object', () => {
+ const region = app.dom.resultsRegion;
+ expect(region.querySelector('.stream-strip .fill')).not.toBeNull(); // pct>0 → determinate
+ expect(region.textContent).toContain('Starting query…');
+ expect(region.textContent).not.toMatch(/null/i); // regression: no "Loading/Streaming null"
+ // live counters (rows/bytes) + Cancel in the toolbar
+ expect(region.textContent).toContain('10 rows');
+ const cancel = region.querySelector('.cancel-act');
+ expect(cancel).not.toBeNull();
+ click(cancel);
+ expect(app.actions.cancel).toHaveBeenCalled();
+ });
+ it('streaming-blank with no result object uses an indeterminate sweep', () => {
const app = appWithResult(null, { running: true });
renderResults(app);
- expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull();
+ expect(app.dom.resultsRegion.querySelector('.stream-strip .sweep')).not.toBeNull();
+ expect(app.dom.resultsRegion.textContent).toContain('Starting query…');
});
it('renders an error', () => {
const r = newResult('Table');
@@ -76,11 +85,20 @@ describe('renderResults states', () => {
renderResults(app);
expect(app.dom.resultsRegion.textContent).toContain('Query returned 0 rows.');
});
- it('table view (default) renders rows + progress bar while running', () => {
+ it('table view (default) renders partial rows + streaming strip while running', () => {
const app = appWithResult(tableResult(), { running: true, resultView: 'table' });
renderResults(app);
expect(app.dom.resultsRegion.querySelectorAll('.res-table tbody tr')).toHaveLength(2);
- expect(app.dom.resultsRegion.querySelector('.progress-bar')).not.toBeNull();
+ expect(app.dom.resultsRegion.querySelector('.stream-strip')).not.toBeNull();
+ });
+ it('a cancelled result shows the "Cancelled · partial" badge with Copy/Export', () => {
+ const r = tableResult();
+ r.cancelled = true;
+ const app = appWithResult(r, { resultView: 'table' });
+ renderResults(app);
+ const region = app.dom.resultsRegion;
+ expect(region.querySelector('.cancelled-badge').textContent).toContain('Cancelled · partial');
+ expect([...region.querySelectorAll('.res-act')].some((b) => /Copy/.test(b.textContent))).toBe(true);
});
it('json view', () => {
const app = appWithResult(tableResult(), { resultView: 'json' });
diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js
index 38b0c14..e176927 100644
--- a/tests/unit/shortcuts.test.js
+++ b/tests/unit/shortcuts.test.js
@@ -61,6 +61,15 @@ describe('handleKeydown', () => {
expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBe('run');
expect(app.actions.run).toHaveBeenCalled();
});
+ it('Escape cancels a running query, and is a no-op otherwise', () => {
+ const app = makeApp();
+ app.state.running = false;
+ expect(handleKeydown(ev({ key: 'Escape' }), app)).toBeNull();
+ expect(app.actions.cancel).not.toHaveBeenCalled();
+ app.state.running = true;
+ expect(handleKeydown(ev({ key: 'Escape' }), app)).toBe('cancel');
+ expect(app.actions.cancel).toHaveBeenCalled();
+ });
it('⌘T / ⌘W are no longer intercepted (browser keeps them)', () => {
const app = makeApp();
expect(handleKeydown(ev({ metaKey: true, key: 't' }), app)).toBeNull();