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/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/app.js b/src/ui/app.js index 5ba19c4..d463b77 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,42 @@ 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 }); + } + } + + // 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); @@ -315,8 +351,11 @@ export function createApp(env = {}) { login, share, toggleSaved: toggleSavedActive, + formatQuery, + insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), + insertTopLine: (text) => insertTopLine(app, text), loadColumns, rerenderTabs: () => renderTabs(app), rerenderResults: () => renderResults(app), @@ -387,7 +426,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..9abb9fc 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.insertCreate('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.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); 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..ead391b 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -4,14 +4,23 @@ import { h } from './dom.js'; const SHORTCUTS = [ ['Run query', '⌘↵'], - ['New tab', '⌘T'], - ['Close tab', '⌘W'], + ['Format query', '⌘⇧↵'], ['Save / unsave query', '⌘S'], ['Share query', '⌘⇧S'], + ['Undo', '⌘Z'], + ['Redo', '⌘⇧Z'], ['Show this dialog', '?'], ['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; @@ -26,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); @@ -45,22 +57,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.key.toLowerCase() === 't') { - 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'; - } if (mod && e.shiftKey && e.key.toLowerCase() === 's') { if (!signedIn) return null; e.preventDefault(); diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 67073e0..cdddca0 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -39,8 +39,11 @@ export function makeApp(over = {}) { login: vi.fn(), share: vi.fn(), toggleSaved: vi.fn(), + formatQuery: vi.fn(), + insertCreate: 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..d13214a 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -219,6 +219,94 @@ 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('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/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/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 () => { diff --git a/tests/unit/schema.test.js b/tests/unit/schema.test.js index 90f967c..f573d3c 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 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.insertCreate).toHaveBeenCalledWith('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 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.insertCreate).toHaveBeenCalledWith('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..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', () => { @@ -52,21 +61,22 @@ 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('⌘⇧↵ 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: '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 }); - out.state.tabs.push({ id: 't2' }); - expect(handleKeydown(ev({ metaKey: true, key: 'w' }), out)).toBeNull(); + expect(handleKeydown(ev({ metaKey: true, shiftKey: true, key: 'Enter' }), out)).toBeNull(); }); it('⌘⇧S shares; ⌘S toggles saved', () => { const app = makeApp();