From 8c27df7e1286b72624d5a1429069968f15f31314 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 11:42:50 +0200 Subject: [PATCH 1/5] feat(editor): consistent schema gestures, formatter, undo-friendly inserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema tree (consistent click model; was: column single-click inserted, so a double-click pasted twice): - single-click = expand (db/table) / nothing (column) - double-click = insert — db: name (cursor); table: `SELECT * FROM db.t LIMIT 100` (prepended as a top line, not replacing); column: name (cursor) - shift-click = db: `SHOW CREATE DATABASE db`; table: `SHOW CREATE db.t` (ClickHouse SHOW CREATE handles table/MV/dictionary); column: `col::type` (top-line for the statements, cursor for identifiers) Editor: - programmatic edits now go through execCommand('insertText') (with a manual- splice fallback), so drag/double-click/format inserts join the native undo stack — ⌘Z / ⌘⇧Z work for them, not just typing. New `insertTopLine` + `replaceEditor` helpers; Tab-insert routed through the same path. Format query: new ⌘⇧F → `SELECT formatQuery()` on the server, replaces the editor with the result (errors surface via a toast). Shortcuts: drop ⌘T/⌘W (let the browser keep new-tab/close-tab; the +/× buttons remain); add Format, Undo, Redo to the modal list. Verified in-browser: every gesture inserts the right text; execCommand('undo') reverts a programmatic insert; shortcuts modal shows the new list. 346 tests pass; editor/schema/shortcuts at 100%, app.js within gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/ui/app.js | 21 ++++++++++++++-- src/ui/editor.js | 47 ++++++++++++++++++++++++++++------- src/ui/schema.js | 23 +++++++++++------ src/ui/shortcuts.js | 17 +++++-------- tests/helpers/fake-app.js | 2 ++ tests/unit/app.test.js | 41 ++++++++++++++++++++++++++++++ tests/unit/editor.test.js | 48 +++++++++++++++++++++++++++++++++++- tests/unit/schema.test.js | 42 ++++++++++++++++++++++++++----- tests/unit/shortcuts.test.js | 22 ++++++++--------- 9 files changed, 216 insertions(+), 47 deletions(-) diff --git a/src/ui/app.js b/src/ui/app.js index 5ba19c4..69958dd 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -18,7 +18,7 @@ import { generatePKCE, randomState } from '../core/pkce.js'; import * as oauthCfg from '../net/oauth-config.js'; import * as oauth from '../net/oauth.js'; import * as ch from '../net/ch-client.js'; -import { mountEditor, insertAtCursor } from './editor.js'; +import { mountEditor, insertAtCursor, insertTopLine, replaceEditor } from './editor.js'; import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; @@ -264,6 +264,21 @@ export function createApp(env = {}) { running ? null : h('kbd', null, '⌘↵')); } + // Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place. + async function formatQuery() { + const sql = (app.activeTab().sql || '').trim(); + if (!sql) return; + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); return; } + try { + const json = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(sql) + ') AS q FORMAT JSON'); + const q = (json.data && json.data[0] && json.data[0].q) || ''; + if (q) replaceEditor(app, q); + } catch (e) { + flashToast('Format failed: ' + String((e && e.message) || e), { document: doc }); + } + } + // --- saved / history bridges ------------------------------------------ app.recordHistory = (tab) => { recordHistory(app.state, tab, saveJSON); @@ -315,8 +330,10 @@ export function createApp(env = {}) { login, share, toggleSaved: toggleSavedActive, + formatQuery, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), + insertTopLine: (text) => insertTopLine(app, text), loadColumns, rerenderTabs: () => renderTabs(app), rerenderResults: () => renderResults(app), @@ -387,7 +404,7 @@ export function renderApp(app, helpers) { app.dom.qtabsInner = h('div', { class: 'qtabs-inner' }); const qtabsRow = h('div', { class: 'qtabs' }, app.dom.qtabsInner, - h('button', { class: 'new-tab', title: 'New query (⌘T)', onclick: () => app.actions.newTab() }, Icon.plus())); + h('button', { class: 'new-tab', title: 'New query', onclick: () => app.actions.newTab() }, Icon.plus())); app.dom.runBtn = h('button', { class: 'run-btn', onclick: () => app.actions.run() }, Icon.play(), h('span', null, 'Run'), h('kbd', null, '⌘↵')); app.dom.fmtSelect = h('select', { diff --git a/src/ui/editor.js b/src/ui/editor.js index c9f7111..6020ff9 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -71,10 +71,7 @@ export function mountEditor(app, container) { ta.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; e.preventDefault(); - const { selectionStart: s, selectionEnd: en } = ta; - ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(en); - ta.selectionStart = ta.selectionEnd = s + 2; - ta.dispatchEvent(new Event('input')); + applyEdit(ta, ' '); }); // Accept schema identifiers dragged from the tree; insert at the cursor. ta.addEventListener('dragover', (e) => e.preventDefault()); @@ -92,13 +89,45 @@ export function mountEditor(app, container) { sync(); } -/** Insert `text` at the textarea cursor and fire an input event. */ -export function insertAtCursor(app, text) { - const ta = app.dom.editorTextarea; - if (!ta) return; +/** + * Replace the textarea's current selection with `text`. Uses + * execCommand('insertText') so the edit joins the native undo stack (⌘Z / ⌘⇧Z); + * falls back to a manual splice + 'input' dispatch where execCommand is absent + * (older browsers, happy-dom). execCommand fires 'input' itself, so either path + * runs the input listener that syncs tab.sql + repaints. + */ +function applyEdit(ta, text) { + ta.focus(); + let ok = false; + try { ok = ta.ownerDocument.execCommand('insertText', false, text); } catch { ok = false; } + if (ok) return; const { selectionStart: s, selectionEnd: e } = ta; ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); ta.selectionStart = ta.selectionEnd = s + text.length; - ta.focus(); ta.dispatchEvent(new Event('input')); } + +/** Insert `text` at the textarea cursor (undoable). */ +export function insertAtCursor(app, text) { + const ta = app.dom.editorTextarea; + if (!ta) return; + applyEdit(ta, text); +} + +/** Prepend `text` as a new first line (does not replace existing content). */ +export function insertTopLine(app, text) { + const ta = app.dom.editorTextarea; + if (!ta) return; + ta.focus(); + ta.selectionStart = ta.selectionEnd = 0; + applyEdit(ta, text + (ta.value ? '\n' : '')); +} + +/** Replace the whole editor content with `text` (undoable). */ +export function replaceEditor(app, text) { + const ta = app.dom.editorTextarea; + if (!ta) return; + ta.focus(); + ta.select(); + applyEdit(ta, text); +} diff --git a/src/ui/schema.js b/src/ui/schema.js index 7e884cd..8b10ccb 100644 --- a/src/ui/schema.js +++ b/src/ui/schema.js @@ -40,7 +40,13 @@ export function renderSchema(app) { for (const db of state.schema) { list.appendChild(h('div', { class: 'tree-row bold', - onclick: () => { db.expanded = !db.expanded; renderSchema(app); }, + title: 'Click to expand · double-click to insert · shift-click for SHOW CREATE', + onclick: (e) => { + if (e.shiftKey) { app.actions.insertTopLine('SHOW CREATE DATABASE ' + db.db); return; } + db.expanded = !db.expanded; + renderSchema(app); + }, + ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(db.db); }, ...dragProps(db.db), }, h('span', { class: 'chev' }, db.expanded ? Icon.chevDown() : Icon.chev()), @@ -60,20 +66,21 @@ export function renderSchema(app) { const tbComment = (tb.comment || '').trim(); const title = tbComment ? tbComment + ' · ' + formatRows(tb.total_rows) + ' rows' - : 'Click to expand/collapse · double-click or drag to insert'; + : 'Click to expand · double-click for SELECT * · shift-click for SHOW CREATE'; list.appendChild(h('div', { class: 'tree-row' + (filter && tableMatch ? ' match' : ''), style: { paddingLeft: '24px' }, title, - ...dragProps(db.db + '.' + tb.name), - onclick: () => { + ...dragProps(key), + onclick: (e) => { + if (e.shiftKey) { app.actions.insertTopLine('SHOW CREATE ' + key); return; } if (state.expandedTables.has(key)) state.expandedTables.delete(key); else state.expandedTables.add(key); if (state.expandedTables.has(key) && tb.columns == null) app.actions.loadColumns(db.db, tb.name, tb); else renderSchema(app); }, - ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(db.db + '.' + tb.name); }, + ondblclick: (e) => { e.stopPropagation(); app.actions.insertTopLine('SELECT * FROM ' + key + ' LIMIT 100'); }, }, h('span', { class: 'chev' }, isOpen ? Icon.chevDown() : Icon.chev()), h('span', { class: 'icon', style: { color: 'var(--accent)' } }, Icon.table()), @@ -93,8 +100,10 @@ export function renderSchema(app) { list.appendChild(h('div', { class: 'tree-row small mono' + (filter && matches(c.name) ? ' match' : ''), style: { paddingLeft: '38px' }, - title: (c.comment && c.comment.trim()) || 'Click or drag to insert ' + c.name, - onclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(c.name); }, + title: (c.comment && c.comment.trim()) + || ('Double-click or drag to insert ' + c.name + ' · shift-click for ' + c.name + '::' + c.type), + onclick: (e) => { e.stopPropagation(); if (e.shiftKey) app.actions.insertAtCursor(c.name + '::' + c.type); }, + ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(c.name); }, ...dragProps(c.name), }, h('span', { class: 'chev' }), diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index 0029de0..f9e7aa6 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -4,10 +4,11 @@ import { h } from './dom.js'; const SHORTCUTS = [ ['Run query', '⌘↵'], - ['New tab', '⌘T'], - ['Close tab', '⌘W'], + ['Format query', '⌘⇧F'], ['Save / unsave query', '⌘S'], ['Share query', '⌘⇧S'], + ['Undo', '⌘Z'], + ['Redo', '⌘⇧Z'], ['Show this dialog', '?'], ['Close dialog', 'Esc'], ]; @@ -49,17 +50,11 @@ export function handleKeydown(e, app) { app.actions.run(); return 'run'; } - if (mod && e.key.toLowerCase() === 't') { + if (mod && e.shiftKey && e.key.toLowerCase() === 'f') { if (!signedIn) return null; e.preventDefault(); - app.actions.newTab(); - return 'newTab'; - } - if (mod && e.key.toLowerCase() === 'w') { - if (!signedIn || app.state.tabs.length <= 1) return null; - e.preventDefault(); - app.actions.closeTab(app.state.activeTabId); - return 'closeTab'; + app.actions.formatQuery(); + return 'formatQuery'; } if (mod && e.shiftKey && e.key.toLowerCase() === 's') { if (!signedIn) return null; diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 67073e0..c5ec43b 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -39,8 +39,10 @@ export function makeApp(over = {}) { login: vi.fn(), share: vi.fn(), toggleSaved: vi.fn(), + formatQuery: vi.fn(), openShortcuts: vi.fn(), insertAtCursor: vi.fn(), + insertTopLine: vi.fn(), loadColumns: vi.fn(), rerenderTabs: vi.fn(), rerenderResults: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index e1e9eda..2c200b4 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -219,6 +219,47 @@ describe('query run', () => { }); }); +describe('formatQuery', () => { + function appFor(routes, over) { + const e = env({ fetch: makeFetch(routes), ...over }); + const app = createApp(e); + app.renderApp(); + return { app, e }; + } + it('replaces the editor with the server-formatted SQL', async () => { + const { app } = appFor([ + [(u, sql) => /formatQuery/.test(sql), resp({ json: { data: [{ q: 'SELECT\n 1' }] } })], + ]); + app.activeTab().sql = 'select 1'; + await app.actions.formatQuery(); + expect(app.dom.editorTextarea.value).toBe('SELECT\n 1'); + }); + it('no-ops on empty SQL', async () => { + const { app, e } = appFor([]); + await Promise.resolve(); // let render's loadVersion/loadSchema settle + e.fetch.mockClear(); + app.activeTab().sql = ' '; + await app.actions.formatQuery(); + expect(e.fetch).not.toHaveBeenCalled(); + }); + it('signs out when there is no usable token', async () => { + const { app } = appFor([], { sessionStorage: memSession({}) }); // no token + app.activeTab().sql = 'select 1'; + await app.actions.formatQuery(); + expect(app.root.querySelector('.login-screen')).not.toBeNull(); + }); + it('surfaces a format failure without changing the editor', async () => { + const { app } = appFor([ + [(u, sql) => /formatQuery/.test(sql), resp({ ok: false, status: 500, text: '{"exception":"DB::Exception: syntax"}' })], + ]); + app.activeTab().sql = 'select 1'; + app.dom.editorTextarea.value = 'select 1'; + await app.actions.formatQuery(); + expect(app.dom.editorTextarea.value).toBe('select 1'); // unchanged + expect(document.body.querySelector('.share-toast')).not.toBeNull(); + }); +}); + describe('auth flows', () => { it('login builds the redirect URL and stashes pkce/state', async () => { const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' }; diff --git a/tests/unit/editor.test.js b/tests/unit/editor.test.js index b1b8b83..3a93878 100644 --- a/tests/unit/editor.test.js +++ b/tests/unit/editor.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderHighlightInto, mountEditor, insertAtCursor, IDENT_MIME } from '../../src/ui/editor.js'; +import { renderHighlightInto, mountEditor, insertAtCursor, insertTopLine, replaceEditor, IDENT_MIME } from '../../src/ui/editor.js'; import { makeApp } from '../helpers/fake-app.js'; describe('renderHighlightInto', () => { @@ -110,4 +110,50 @@ describe('insertAtCursor', () => { const app = makeApp(); expect(() => insertAtCursor(app, 'x')).not.toThrow(); }); + it('uses execCommand(insertText) when available, skipping the manual splice', () => { + const app = makeApp(); + mountEditor(app, document.createElement('div')); + const ta = app.dom.editorTextarea; + ta.value = 'AB'; + ta.selectionStart = ta.selectionEnd = 1; + const spy = vi.fn(() => true); + document.execCommand = spy; + try { + insertAtCursor(app, 'x'); + expect(spy).toHaveBeenCalledWith('insertText', false, 'x'); + expect(ta.value).toBe('AB'); // execCommand owns the insert; manual splice skipped + } finally { + delete document.execCommand; + } + }); +}); + +describe('insertTopLine / replaceEditor', () => { + function mounted(sql = '') { + const app = makeApp(); + app.activeTab().sql = sql; + mountEditor(app, document.createElement('div')); + return { app, ta: app.dom.editorTextarea }; + } + it('insertTopLine prepends a new first line above existing content', () => { + const { app, ta } = mounted('SELECT 1'); + insertTopLine(app, 'SHOW CREATE db.t'); + expect(ta.value).toBe('SHOW CREATE db.t\nSELECT 1'); + expect(app.activeTab().sql).toBe('SHOW CREATE db.t\nSELECT 1'); + }); + it('insertTopLine on an empty editor adds no trailing newline', () => { + const { ta, app } = mounted(''); + insertTopLine(app, 'SELECT 1'); + expect(ta.value).toBe('SELECT 1'); + }); + it('replaceEditor swaps the whole content', () => { + const { ta, app } = mounted('select 1'); + replaceEditor(app, 'SELECT\n 1'); + expect(ta.value).toBe('SELECT\n 1'); + }); + it('insertTopLine / replaceEditor no-op without a textarea', () => { + const app = makeApp(); + expect(() => insertTopLine(app, 'x')).not.toThrow(); + expect(() => replaceEditor(app, 'x')).not.toThrow(); + }); }); diff --git a/tests/unit/schema.test.js b/tests/unit/schema.test.js index 90f967c..149d9fc 100644 --- a/tests/unit/schema.test.js +++ b/tests/unit/schema.test.js @@ -5,6 +5,8 @@ import { makeApp } from '../helpers/fake-app.js'; const rows = (app) => [...app.dom.schemaList.querySelectorAll('.tree-row')]; const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true })); +const shiftClick = (el) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); +const dblclick = (el) => el.dispatchEvent(new Event('dblclick', { bubbles: true })); // Fire a dragstart with a stub dataTransfer and return what setData captured. const dragstart = (el) => { const e = new Event('dragstart', { bubbles: true }); @@ -72,6 +74,21 @@ describe('renderSchema tree', () => { click(db2Row); expect(app.state.schema[1].expanded).toBe(true); }); + it('shift-clicking a db inserts SHOW CREATE DATABASE without expanding', () => { + const app = withSchema(); + renderSchema(app); + const db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2'); + shiftClick(db2Row); + expect(app.actions.insertTopLine).toHaveBeenCalledWith('SHOW CREATE DATABASE db2'); + expect(app.state.schema[1].expanded).toBe(false); + }); + it('double-clicking a db inserts its name', () => { + const app = withSchema(); + renderSchema(app); + const db1Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db1'); + dblclick(db1Row); + expect(app.actions.insertAtCursor).toHaveBeenCalledWith('db1'); + }); it('expanding a table with no columns triggers loadColumns', () => { const app = withSchema(); renderSchema(app); @@ -89,12 +106,21 @@ describe('renderSchema tree', () => { click(ordersRow); // collapse expect(app.state.expandedTables.has('db1.orders')).toBe(false); }); - it('double-clicking a table inserts its qualified name', () => { + it('double-clicking a table inserts a SELECT * as a top line', () => { const app = withSchema(); renderSchema(app); const ordersRow = rows(app).find((r) => r.querySelector('.label').textContent === 'orders'); - ordersRow.dispatchEvent(new Event('dblclick', { bubbles: true })); - expect(app.actions.insertAtCursor).toHaveBeenCalledWith('db1.orders'); + dblclick(ordersRow); + expect(app.actions.insertTopLine).toHaveBeenCalledWith('SELECT * FROM db1.orders LIMIT 100'); + }); + it('shift-clicking a table inserts SHOW CREATE without expanding', () => { + const app = withSchema(); + renderSchema(app); + const eventsRow = rows(app).find((r) => r.querySelector('.label').textContent === 'events'); + shiftClick(eventsRow); + expect(app.actions.insertTopLine).toHaveBeenCalledWith('SHOW CREATE db1.events'); + expect(app.state.expandedTables.has('db1.events')).toBe(false); + expect(app.actions.loadColumns).not.toHaveBeenCalled(); }); it('shows a loading row while columns load', () => { const app = withSchema(); @@ -103,18 +129,22 @@ describe('renderSchema tree', () => { renderSchema(app); expect(app.dom.schemaList.textContent).toContain('loading columns…'); }); - it('renders columns (with + without comment) and inserts on click', () => { + it('columns: plain click inserts nothing; double-click inserts name; shift-click inserts ::type', () => { const app = withSchema(); app.state.schema[0].tables[0].columns = [ - { name: 'id', type: 'UInt64', comment: 'pk' }, - { name: 'ts', type: 'DateTime', comment: '' }, + { name: 'id', type: 'UInt64', comment: 'pk' }, // comment → title branch + { name: 'ts', type: 'DateTime', comment: '' }, // no comment → default title branch ]; app.state.expandedTables.add('db1.orders'); renderSchema(app); const colRow = [...app.dom.schemaList.querySelectorAll('.tree-row.small')] .find((r) => r.querySelector('.label').textContent === 'id'); click(colRow); + expect(app.actions.insertAtCursor).not.toHaveBeenCalled(); // single click does nothing + dblclick(colRow); expect(app.actions.insertAtCursor).toHaveBeenCalledWith('id'); + shiftClick(colRow); + expect(app.actions.insertAtCursor).toHaveBeenCalledWith('id::UInt64'); }); }); diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js index 892d817..df153ae 100644 --- a/tests/unit/shortcuts.test.js +++ b/tests/unit/shortcuts.test.js @@ -52,21 +52,21 @@ describe('handleKeydown', () => { expect(handleKeydown(ev({ metaKey: true, key: 'Enter' }), app)).toBe('run'); expect(app.actions.run).toHaveBeenCalled(); }); - it('⌘T new tab; gated by sign-in', () => { + it('⌘T / ⌘W are no longer intercepted (browser keeps them)', () => { const app = makeApp(); - expect(handleKeydown(ev({ ctrlKey: true, key: 't' }), app)).toBe('newTab'); - const out = makeApp({ isSignedIn: () => false }); - expect(handleKeydown(ev({ ctrlKey: true, key: 'T' }), out)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, key: 't' }), app)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, key: 'w' }), app)).toBeNull(); + expect(app.actions.newTab).not.toHaveBeenCalled(); + expect(app.actions.closeTab).not.toHaveBeenCalled(); }); - it('⌘W closes only with >1 tab and signed in', () => { + it('⌘⇧F formats the query; gated by sign-in', () => { const app = makeApp(); - app.state.tabs.push({ id: 't2' }); - expect(handleKeydown(ev({ metaKey: true, key: 'w' }), app)).toBe('closeTab'); - const single = makeApp(); - expect(handleKeydown(ev({ metaKey: true, key: 'w' }), single)).toBeNull(); + const e = ev({ metaKey: true, shiftKey: true, key: 'F' }); + expect(handleKeydown(e, app)).toBe('formatQuery'); + expect(app.actions.formatQuery).toHaveBeenCalled(); + expect(e.preventDefault).toHaveBeenCalled(); const out = makeApp({ isSignedIn: () => false }); - out.state.tabs.push({ id: 't2' }); - expect(handleKeydown(ev({ metaKey: true, key: 'w' }), out)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'f' }), out)).toBeNull(); }); it('⌘⇧S shares; ⌘S toggles saved', () => { const app = makeApp(); From bfaabad59b4e952fdb6849cb888db8fb0e4904b1 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 11:53:20 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(shortcuts):=20move=20Format=20query=20t?= =?UTF-8?q?o=20=E2=8C=98/Ctrl+Shift+Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘⇧F / Ctrl+Shift+F collides with browser find/Lens muscle memory (Ctrl+Shift+F is Google Lens "search page" on Chrome Win/Linux). Rebind Format to ⌘/Ctrl+Shift+Enter — it pairs with Run (⌘/Ctrl+Enter), and Shift+Enter has no browser default on macOS, Windows, or Linux. The Enter handler now branches on Shift (format when signed in, else run). Shortcuts modal updated to ⌘⇧↵. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/ui/shortcuts.js | 15 ++++++++------- tests/unit/shortcuts.test.js | 7 ++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index f9e7aa6..3ad98ac 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -4,7 +4,7 @@ import { h } from './dom.js'; const SHORTCUTS = [ ['Run query', '⌘↵'], - ['Format query', '⌘⇧F'], + ['Format query', '⌘⇧↵'], ['Save / unsave query', '⌘S'], ['Share query', '⌘⇧S'], ['Undo', '⌘Z'], @@ -46,16 +46,17 @@ export function handleKeydown(e, app) { const mod = e.metaKey || e.ctrlKey; const signedIn = app.isSignedIn(); if (mod && e.key === 'Enter') { + // ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run. + if (e.shiftKey) { + if (!signedIn) return null; + e.preventDefault(); + app.actions.formatQuery(); + return 'formatQuery'; + } e.preventDefault(); app.actions.run(); return 'run'; } - if (mod && e.shiftKey && e.key.toLowerCase() === 'f') { - if (!signedIn) return null; - e.preventDefault(); - app.actions.formatQuery(); - return 'formatQuery'; - } if (mod && e.shiftKey && e.key.toLowerCase() === 's') { if (!signedIn) return null; e.preventDefault(); diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js index df153ae..868f1d5 100644 --- a/tests/unit/shortcuts.test.js +++ b/tests/unit/shortcuts.test.js @@ -59,14 +59,15 @@ describe('handleKeydown', () => { expect(app.actions.newTab).not.toHaveBeenCalled(); expect(app.actions.closeTab).not.toHaveBeenCalled(); }); - it('⌘⇧F formats the query; gated by sign-in', () => { + it('⌘⇧↵ formats the query; gated by sign-in', () => { const app = makeApp(); - const e = ev({ metaKey: true, shiftKey: true, key: 'F' }); + const e = ev({ metaKey: true, shiftKey: true, key: 'Enter' }); expect(handleKeydown(e, app)).toBe('formatQuery'); expect(app.actions.formatQuery).toHaveBeenCalled(); + expect(app.actions.run).not.toHaveBeenCalled(); expect(e.preventDefault).toHaveBeenCalled(); const out = makeApp({ isSignedIn: () => false }); - expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'f' }), out)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'Enter' }), out)).toBeNull(); }); it('⌘⇧S shares; ⌘S toggles saved', () => { const app = makeApp(); From 167dc6403f9c6ad292029241ad24faf6257efd73 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 11:56:09 +0200 Subject: [PATCH 3/5] feat(shortcuts): add schema-tree mouse gestures to the help modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shortcuts modal now has a "Schema tree" section listing the click / double-click / shift-click gestures (expand, insert, insert DDL/col::type), reusing the existing row+chip styling under a small divider+subheading — so the gestures are discoverable next to the keyboard shortcuts. Terse by design; the per-row tooltips still carry the detail. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/styles.css | 5 +++++ src/ui/shortcuts.js | 15 +++++++++++++-- tests/unit/shortcuts.test.js | 9 +++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/styles.css b/src/styles.css index fc16264..c18a59d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -317,6 +317,11 @@ body { .modal-card h2 { margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--fg); } +.modal-card .section-label { + margin: 14px 0 2px; font-size: 10.5px; font-weight: 600; + color: var(--fg-faint); text-transform: uppercase; letter-spacing: .04em; + border-top: 1px solid var(--border-faint); padding-top: 12px; +} .modal-card .row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 12.5px; color: var(--fg-mute); diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index 3ad98ac..ead391b 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -13,6 +13,14 @@ const SHORTCUTS = [ ['Close dialog', 'Esc'], ]; +// Mouse gestures on the schema tree (db / table / column). Kept terse — the +// per-row tooltips carry the detail; this just signals the gestures exist. +const GESTURES = [ + ['Expand / collapse', 'Click'], + ['Insert into editor', 'Double-click'], + ['Insert DDL / col::type', 'Shift-click'], +]; + /** Open the shortcuts modal. Idempotent while open (tracked on state). */ export function openShortcuts(app) { const doc = app.document || document; @@ -27,10 +35,13 @@ export function openShortcuts(app) { if (e.key === 'Escape') close(); }; doc.addEventListener('keydown', escHandler); + const rowOf = ([label, key]) => + h('div', { class: 'row' }, h('span', { class: 'label' }, label), h('kbd', null, key)); const card = h('div', { class: 'modal-card', onclick: (e) => e.stopPropagation() }, h('h2', null, 'Keyboard shortcuts'), - ...SHORTCUTS.map(([label, key]) => - h('div', { class: 'row' }, h('span', { class: 'label' }, label), h('kbd', null, key))), + ...SHORTCUTS.map(rowOf), + h('div', { class: 'section-label' }, 'Schema tree — database · table · column'), + ...GESTURES.map(rowOf), h('div', { class: 'close-row' }, h('button', { class: 'close-btn', onclick: close }, 'Close')), ); const backdrop = h('div', { class: 'modal-backdrop', onclick: close }, card); diff --git a/tests/unit/shortcuts.test.js b/tests/unit/shortcuts.test.js index 868f1d5..8f6fa9c 100644 --- a/tests/unit/shortcuts.test.js +++ b/tests/unit/shortcuts.test.js @@ -42,6 +42,15 @@ describe('openShortcuts', () => { openShortcuts(app); expect(document.querySelector('.modal-card')).not.toBeNull(); }); + it('lists keyboard shortcuts plus a schema-tree gestures section', () => { + const app = makeApp({ document }); + openShortcuts(app); + const text = document.querySelector('.modal-card').textContent; + expect(text).toContain('Format query'); + expect(document.querySelector('.modal-card .section-label')).not.toBeNull(); + expect(text).toContain('Double-click'); + expect(text).toContain('Shift-click'); + }); }); describe('handleKeydown', () => { From 6ba2943da9abf79b5a7f9c6a95152e9fdd1b55b9 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 12:10:23 +0200 Subject: [PATCH 4/5] feat(schema): shift-click inserts formatted DDL (SHOW CREATE + formatQuery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift-click on a table/db now fetches the real DDL via SHOW CREATE and pretty- prints it through formatQuery(), inserting the formatted CREATE statement as a top line — instead of inserting the literal "SHOW CREATE …" command. Two server round-trips by design (show + format); if formatting fails it falls back to the raw DDL, and a SHOW CREATE failure surfaces via a toast. New `insertCreate` action; schema shift-click handlers call it ('db.table' for tables, 'DATABASE db' for databases). 352 tests pass; schema 100%, app.js within gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/ui/app.js | 22 ++++++++++++++++++ src/ui/schema.js | 4 ++-- tests/helpers/fake-app.js | 1 + tests/unit/app.test.js | 47 +++++++++++++++++++++++++++++++++++++++ tests/unit/schema.test.js | 8 +++---- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/ui/app.js b/src/ui/app.js index 69958dd..d463b77 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -279,6 +279,27 @@ export function createApp(env = {}) { } } + // Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with + // SHOW CREATE, pretty-print it through formatQuery(), and drop it in as a top + // line. Two round-trips by design; if formatting fails the raw DDL is used. + async function insertCreate(target) { + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); return; } + try { + const show = await ch.queryJson(chCtx, 'SHOW CREATE ' + target + ' FORMAT JSON'); + const stmt = (show.data && show.data[0] && show.data[0].statement) || ''; + if (!stmt) return; + let out = stmt; + try { + const fmt = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(stmt) + ') AS q FORMAT JSON'); + out = (fmt.data && fmt.data[0] && fmt.data[0].q) || stmt; + } catch { /* formatting is best-effort — fall back to the raw DDL */ } + insertTopLine(app, out); + } catch (e) { + flashToast('SHOW CREATE failed: ' + String((e && e.message) || e), { document: doc }); + } + } + // --- saved / history bridges ------------------------------------------ app.recordHistory = (tab) => { recordHistory(app.state, tab, saveJSON); @@ -331,6 +352,7 @@ export function createApp(env = {}) { share, toggleSaved: toggleSavedActive, formatQuery, + insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), insertTopLine: (text) => insertTopLine(app, text), diff --git a/src/ui/schema.js b/src/ui/schema.js index 8b10ccb..9abb9fc 100644 --- a/src/ui/schema.js +++ b/src/ui/schema.js @@ -42,7 +42,7 @@ export function renderSchema(app) { class: 'tree-row bold', title: 'Click to expand · double-click to insert · shift-click for SHOW CREATE', onclick: (e) => { - if (e.shiftKey) { app.actions.insertTopLine('SHOW CREATE DATABASE ' + db.db); return; } + if (e.shiftKey) { app.actions.insertCreate('DATABASE ' + db.db); return; } db.expanded = !db.expanded; renderSchema(app); }, @@ -74,7 +74,7 @@ export function renderSchema(app) { title, ...dragProps(key), onclick: (e) => { - if (e.shiftKey) { app.actions.insertTopLine('SHOW CREATE ' + key); return; } + if (e.shiftKey) { app.actions.insertCreate(key); return; } if (state.expandedTables.has(key)) state.expandedTables.delete(key); else state.expandedTables.add(key); if (state.expandedTables.has(key) && tb.columns == null) app.actions.loadColumns(db.db, tb.name, tb); diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index c5ec43b..cdddca0 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -40,6 +40,7 @@ export function makeApp(over = {}) { share: vi.fn(), toggleSaved: vi.fn(), formatQuery: vi.fn(), + insertCreate: vi.fn(), openShortcuts: vi.fn(), insertAtCursor: vi.fn(), insertTopLine: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 2c200b4..d13214a 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -260,6 +260,53 @@ describe('formatQuery', () => { }); }); +describe('insertCreate', () => { + function appFor(routes, over) { + const e = env({ fetch: makeFetch(routes), ...over }); + const app = createApp(e); + app.renderApp(); + return { app, e }; + } + it('fetches DDL, formats it, and inserts as a top line', async () => { + const { app } = appFor([ + [(u, sql) => /SHOW CREATE/.test(sql), resp({ json: { data: [{ statement: 'CREATE TABLE db.t (a Int)' }] } })], + [(u, sql) => /formatQuery/.test(sql), resp({ json: { data: [{ q: 'CREATE TABLE db.t\n(\n a Int\n)' }] } })], + ]); + await app.actions.insertCreate('db.t'); + expect(app.dom.editorTextarea.value).toBe('CREATE TABLE db.t\n(\n a Int\n)'); + }); + it('falls back to the raw DDL when formatting fails', async () => { + const { app } = appFor([ + [(u, sql) => /SHOW CREATE/.test(sql), resp({ json: { data: [{ statement: 'CREATE TABLE db.t (a Int)' }] } })], + [(u, sql) => /formatQuery/.test(sql), resp({ ok: false, status: 500, text: '{"exception":"x"}' })], + ]); + await app.actions.insertCreate('db.t'); + expect(app.dom.editorTextarea.value).toBe('CREATE TABLE db.t (a Int)'); + }); + it('no-ops when SHOW CREATE returns no statement', async () => { + const { app } = appFor([ + [(u, sql) => /SHOW CREATE/.test(sql), resp({ json: { data: [] } })], + ]); + app.dom.editorTextarea.value = 'keep'; + await app.actions.insertCreate('db.t'); + expect(app.dom.editorTextarea.value).toBe('keep'); + }); + it('surfaces a SHOW CREATE failure without changing the editor', async () => { + const { app } = appFor([ + [(u, sql) => /SHOW CREATE/.test(sql), resp({ ok: false, status: 500, text: '{"exception":"DB::Exception: no table"}' })], + ]); + app.dom.editorTextarea.value = 'keep'; + await app.actions.insertCreate('db.t'); + expect(app.dom.editorTextarea.value).toBe('keep'); + expect(document.body.querySelector('.share-toast')).not.toBeNull(); + }); + it('signs out when there is no usable token', async () => { + const { app } = appFor([], { sessionStorage: memSession({}) }); + await app.actions.insertCreate('db.t'); + expect(app.root.querySelector('.login-screen')).not.toBeNull(); + }); +}); + describe('auth flows', () => { it('login builds the redirect URL and stashes pkce/state', async () => { const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' }; diff --git a/tests/unit/schema.test.js b/tests/unit/schema.test.js index 149d9fc..f573d3c 100644 --- a/tests/unit/schema.test.js +++ b/tests/unit/schema.test.js @@ -74,12 +74,12 @@ describe('renderSchema tree', () => { click(db2Row); expect(app.state.schema[1].expanded).toBe(true); }); - it('shift-clicking a db inserts SHOW CREATE DATABASE without expanding', () => { + it('shift-clicking a db inserts its formatted DDL without expanding', () => { const app = withSchema(); renderSchema(app); const db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2'); shiftClick(db2Row); - expect(app.actions.insertTopLine).toHaveBeenCalledWith('SHOW CREATE DATABASE db2'); + expect(app.actions.insertCreate).toHaveBeenCalledWith('DATABASE db2'); expect(app.state.schema[1].expanded).toBe(false); }); it('double-clicking a db inserts its name', () => { @@ -113,12 +113,12 @@ describe('renderSchema tree', () => { dblclick(ordersRow); expect(app.actions.insertTopLine).toHaveBeenCalledWith('SELECT * FROM db1.orders LIMIT 100'); }); - it('shift-clicking a table inserts SHOW CREATE without expanding', () => { + it('shift-clicking a table inserts its formatted DDL without expanding', () => { const app = withSchema(); renderSchema(app); const eventsRow = rows(app).find((r) => r.querySelector('.label').textContent === 'events'); shiftClick(eventsRow); - expect(app.actions.insertTopLine).toHaveBeenCalledWith('SHOW CREATE db1.events'); + expect(app.actions.insertCreate).toHaveBeenCalledWith('db1.events'); expect(app.state.expandedTables.has('db1.events')).toBe(false); expect(app.actions.loadColumns).not.toHaveBeenCalled(); }); From 72d332c70ff65f0dd64b77e00111b26558da504d Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sat, 20 Jun 2026 12:39:23 +0200 Subject: [PATCH 5/5] fix(share): preserve the shared query across the OAuth login redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A share link carries the SQL in the URL hash (#). When the recipient wasn't signed in, the OAuth redirect to the IdP dropped the hash (redirect_uri is path-only and fragments don't round-trip), and the in-memory tab was wiped by the full-page navigation — so they landed on a blank /sql after signing in. bootstrap now stashes the decoded query in sessionStorage (`oauth_shared_sql`), which survives the same-tab redirect like oauth_state/verifier, and restores it when the hash is gone, clearing it once consumed on a signed-in render. Verified in-browser: not-signed-in load stashes the query + shows login; the post-redirect load (no hash, signed in) restores it into the editor and clears the stash. 353 tests pass; main.js at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/main.js | 15 ++++++++++++--- tests/unit/main.test.js | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main.js b/src/main.js index cba7f2b..49048f8 100644 --- a/src/main.js +++ b/src/main.js @@ -49,15 +49,24 @@ export async function bootstrap(app, env) { hist.replaceState(null, '', loc.origin + loc.pathname + (qs ? '?' + qs : '') + loc.hash); } - const sharedSql = decodeSqlFromHash(loc.hash); + // A shared query rides in the URL hash, which is lost through the OAuth + // redirect (and we strip it below). Stash it in sessionStorage so it survives + // the round-trip and restore it once we're back, signed in. + let sharedSql = decodeSqlFromHash(loc.hash); + if (sharedSql) ss.setItem('oauth_shared_sql', sharedSql); + else sharedSql = ss.getItem('oauth_shared_sql') || ''; if (sharedSql) { app.state.tabs[0].sql = sharedSql; app.state.tabs[0].name = 'Shared query'; hist.replaceState(null, '', loc.pathname + loc.search); } - if (app.token && !isTokenExpired(app.token, 0)) app.renderApp(); - else app.showLogin(callbackError); + if (app.token && !isTokenExpired(app.token, 0)) { + ss.removeItem('oauth_shared_sql'); // consumed + app.renderApp(); + } else { + app.showLogin(callbackError); + } return { callbackError, signedIn: app.isSignedIn() }; } diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js index 01fae81..ed7318f 100644 --- a/tests/unit/main.test.js +++ b/tests/unit/main.test.js @@ -120,7 +120,7 @@ describe('bootstrap', () => { expect(app.showLogin).toHaveBeenCalledWith('OAuth token exchange failed: plain failure'); }); - it('seeds the first tab from a share-link hash', async () => { + it('seeds the first tab from a share-link hash (and stashes it for login)', async () => { const app = fakeApp(); const sql = 'SELECT 1'; const hash = '#' + btoa(unescape(encodeURIComponent(sql))); @@ -128,6 +128,19 @@ describe('bootstrap', () => { await bootstrap(app, env); expect(app.state.tabs[0].sql).toBe('SELECT 1'); expect(app.state.tabs[0].name).toBe('Shared query'); + expect(env.sessionStorage.getItem('oauth_shared_sql')).toBe('SELECT 1'); // survives a login redirect + }); + + it('restores a shared query from sessionStorage after the OAuth round-trip', async () => { + // The hash is gone after the IdP redirect; the stash carries it through. + const app = fakeApp({ token: valid, isSignedIn: () => true }); + const env = fakeEnv({ location: { href: 'https://ch/sql', origin: 'https://ch', pathname: '/sql', search: '', hash: '' } }); + env.sessionStorage.setItem('oauth_shared_sql', 'SELECT 42'); + await bootstrap(app, env); + expect(app.state.tabs[0].sql).toBe('SELECT 42'); + expect(app.state.tabs[0].name).toBe('Shared query'); + expect(app.renderApp).toHaveBeenCalled(); + expect(env.sessionStorage.getItem('oauth_shared_sql')).toBeNull(); // consumed on render }); it('preserves extra query params while stripping oauth ones', async () => {