From ec49d1be413558d2462187d6fc754beff6097b2c Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 18:23:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(editor):=20drag=20a=20Library/History?= =?UTF-8?q?=20query=20into=20the=20editor=20as=20a=20(=20=E2=80=A6=20)=20s?= =?UTF-8?q?ubquery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved-query and history rows are now draggable; dropping one on the SQL editor inserts that query wrapped in parentheses as a subquery, at the drop position, undoably. Extends the existing schema-identifier drag path (IDENT_MIME) with a second MIME (SUBQUERY_MIME) and a position-aware branch in the drop handler (offsetFromXY → replaceRange, falling back to the caret when dropped past the text). A pure toSubquery() strips a trailing FORMAT/; and brackets the SQL. - core/format.js: toSubquery() (100% covered). - ui/editor.js: SUBQUERY_MIME + drop handler branch. - ui/saved-history.js: draggable rows + dragstart payload. - unit + e2e (real DragEvent/DataTransfer) coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/core/format.js | 13 +++++++++++++ src/ui/editor.js | 27 ++++++++++++++++++++++---- src/ui/saved-history.js | 12 ++++++++++-- tests/e2e/editor-insert.spec.js | 21 ++++++++++++++++++++ tests/unit/editor.test.js | 33 +++++++++++++++++++++++++++++++- tests/unit/format.test.js | 23 +++++++++++++++++++++- tests/unit/saved-history.test.js | 30 +++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 8 deletions(-) diff --git a/src/core/format.js b/src/core/format.js index 5b36283..d9432db 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -85,6 +85,19 @@ export function inferQueryName(sql) { return s.length > 48 ? s.slice(0, 45) + '…' : s; } +/** + * Wrap a query's SQL as a parenthesized subquery for dropping into the editor. + * Strips what can't live inside `()` — a trailing `;` and a trailing `FORMAT + * ` clause (FORMAT must be a statement's last clause) — then brackets it on + * its own lines. Empty/whitespace input → '' (caller inserts nothing). Pure. + */ +export function toSubquery(sql) { + let s = String(sql || '').trim(); + s = s.replace(/;\s*$/, '').trim(); + s = s.replace(/\bFORMAT\s+[A-Za-z][A-Za-z0-9]*\s*$/i, '').trim(); + return s ? '(\n' + s + '\n)' : ''; +} + /** True for ClickHouse numeric column types (Int/UInt/Float/Decimal). */ export function isNumericType(type) { return /^(U?Int|Float|Decimal)/.test(type || ''); diff --git a/src/ui/editor.js b/src/ui/editor.js index d6140a0..380d964 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -6,6 +6,7 @@ import { tokenize, maskFromTokens } from '../core/sql-highlight.js'; import { buildMarkSegments } from '../core/editor-marks.js'; import { matchBracketAt, bracketEdit } from '../core/editor-brackets.js'; import { caretXY, caretLineCol, offsetFromXY } from '../core/editor-geometry.js'; +import { toSubquery } from '../core/format.js'; import { createSearch } from './editor-search.js'; import { createComplete } from './editor-complete.js'; import { createIntel } from './editor-intel.js'; @@ -25,6 +26,10 @@ const CHAR_WIDTH_PX = 7.8; // drags, leaving native text drag-within-the-textarea untouched. export const IDENT_MIME = 'application/x-asb-identifier'; +// dataTransfer MIME for dragging a whole saved/history query onto the editor; the +// drop wraps it as a `( … )` subquery at the drop position (see the drop handler). +export const SUBQUERY_MIME = 'application/x-asb-subquery'; + /** * Paint tokenized SQL into `preEl` (whitespace as text, tokens as spans). * `opts` (optional) forwards dynamic keyword/function sets to the tokenizer so @@ -310,10 +315,24 @@ export function mountEditor(app, container) { // Accept schema identifiers dragged from the tree; insert at the cursor. ta.addEventListener('dragover', (e) => e.preventDefault()); ta.addEventListener('drop', (e) => { - const text = e.dataTransfer && e.dataTransfer.getData(IDENT_MIME); - if (!text) return; // not our drag — leave native behavior alone - e.preventDefault(); - insertAtCursor(app, text); + const dt = e.dataTransfer; + if (!dt) return; + // Schema identifier → insert verbatim at the caret (existing behavior). + const ident = dt.getData(IDENT_MIME); + if (ident) { e.preventDefault(); insertAtCursor(app, ident); return; } + // A saved/history query → wrap as a subquery and drop it at the cursor's + // landing point in the text (falling back to the caret when the drop is past + // the text, e.g. the blank area below the last line). + const sub = dt.getData(SUBQUERY_MIME); + if (sub) { + const text = toSubquery(sub); + if (!text) return; + e.preventDefault(); + const off = offsetAt(e.clientX, e.clientY); + if (Number.isFinite(off)) replaceRange(off, off, text); + else insertAtCursor(app, text); + } + // otherwise: not our drag — leave native behavior alone. }); app.dom.editorTextarea = ta; diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 4625e26..7231789 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -6,6 +6,14 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { timeAgo } from '../core/format.js'; +import { SUBQUERY_MIME } from './editor.js'; + +// Make a Library/History row draggable; dropping it on the editor inserts the +// query wrapped as a `( … )` subquery (see the editor's drop handler). +const dragProps = (sql) => ({ + draggable: 'true', + ondragstart: (e) => e.dataTransfer.setData(SUBQUERY_MIME, sql), +}); import { sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, deleteHistory, } from '../state.js'; @@ -99,7 +107,7 @@ function renderSaved(app, list) { onclick: (e) => { e.stopPropagation(); toggleFavorite(state, q.id, app.saveJSON); renderSavedHistory(app); app.updateLibraryTitle(); }, }, Icon.star(q.favorite)); - const row = h('div', { class: 'saved-row', onclick: () => { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } }, + const row = h('div', { class: 'saved-row', ...dragProps(q.sql), onclick: () => { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } }, h('div', { class: 'top' }, star, h('span', { class: 'name' }, q.name), @@ -172,7 +180,7 @@ function renderHistory(app, list) { return; } for (const ent of items) { - list.appendChild(h('div', { class: 'history-row', onclick: () => { app.actions.loadIntoNewTab('From history', ent.sql); app.actions.run(); } }, + list.appendChild(h('div', { class: 'history-row', ...dragProps(ent.sql), onclick: () => { app.actions.loadIntoNewTab('From history', ent.sql); app.actions.run(); } }, h('button', { class: 'sv-act del', title: 'Delete', onclick: (e) => { e.stopPropagation(); deleteHistory(state, ent.id, app.saveJSON); renderSavedHistory(app); }, diff --git a/tests/e2e/editor-insert.spec.js b/tests/e2e/editor-insert.spec.js index 8736e9f..6b192d0 100644 --- a/tests/e2e/editor-insert.spec.js +++ b/tests/e2e/editor-insert.spec.js @@ -79,4 +79,25 @@ test.describe('editor insertion (schema double-click path)', () => { expect(r.value).toBe('SELECT * FROM mytable LIMIT 100'); expect(r.caret).toBe('SELECT * FROM mytable LIMIT 100'.length); }); + + test('dropping a saved/history query inserts it as a ( … ) subquery (real DnD geometry)', async ({ page }) => { + // Real DragEvent + DataTransfer over the textarea — exercises the offsetFromXY + // geometry happy-dom can't compute. Drops a query carrying a trailing FORMAT, + // which must be stripped, wrapped in parens, and spliced at the drop point. + const value = await page.evaluate(() => { + const ta = window.__app.dom.editorTextarea; + window.__setSql('SELECT * FROM t'); + const rect = ta.getBoundingClientRect(); + const dt = new DataTransfer(); + dt.setData('application/x-asb-subquery', 'SELECT 99 FORMAT JSON'); + ta.dispatchEvent(new DragEvent('drop', { + bubbles: true, cancelable: true, dataTransfer: dt, + clientX: rect.left + 60, clientY: rect.top + 10, + })); + return ta.value; + }); + expect(value).toContain('(\nSELECT 99\n)'); // wrapped subquery, trailing FORMAT stripped + expect(value).not.toContain('FORMAT'); + expect(value).toContain('FROM t'); // original text preserved around it + }); }); diff --git a/tests/unit/editor.test.js b/tests/unit/editor.test.js index f6b7df2..c880aa0 100644 --- a/tests/unit/editor.test.js +++ b/tests/unit/editor.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHighlightInto, mountEditor, insertAtCursor, replaceEditor, IDENT_MIME } from '../../src/ui/editor.js'; +import { renderHighlightInto, mountEditor, insertAtCursor, replaceEditor, IDENT_MIME, SUBQUERY_MIME } from '../../src/ui/editor.js'; import { makeApp } from '../helpers/fake-app.js'; describe('renderHighlightInto', () => { @@ -107,6 +107,37 @@ describe('mountEditor', () => { expect(ta.value).toBe('SELECT db.tbl FROM t'); expect(e.defaultPrevented).toBe(true); }); + it('dropping a saved/history query inserts it as a ( … ) subquery at the drop position', () => { + const { ta } = mount(); + ta.value = 'X'; + ta.selectionStart = ta.selectionEnd = 1; // caret at end — drop position must win + const e = new Event('drop', { cancelable: true }); + e.clientX = 14; e.clientY = 13; // maps to text offset 0 (PAD_X/PAD_Y origin) + e.dataTransfer = { getData: (m) => (m === SUBQUERY_MIME ? 'SELECT 1 FORMAT JSON' : '') }; + ta.dispatchEvent(e); + expect(ta.value).toBe('(\nSELECT 1\n)X'); // at offset 0 (not the caret), FORMAT stripped + expect(e.defaultPrevented).toBe(true); + }); + it('falls back to the caret when the subquery is dropped past the text', () => { + const { ta } = mount(); + ta.value = 'X'; + ta.selectionStart = ta.selectionEnd = 1; + const e = new Event('drop', { cancelable: true }); + e.clientX = 14; e.clientY = 9999; // below the last line → offsetFromXY null + e.dataTransfer = { getData: (m) => (m === SUBQUERY_MIME ? 'SELECT 1' : '') }; + ta.dispatchEvent(e); + expect(ta.value).toBe('X(\nSELECT 1\n)'); // inserted at the caret + expect(e.defaultPrevented).toBe(true); + }); + it('a subquery drop of empty SQL is a no-op (native handling)', () => { + const { ta } = mount(); + const before = ta.value; + const e = new Event('drop', { cancelable: true }); + e.dataTransfer = { getData: (m) => (m === SUBQUERY_MIME ? ' ' : '') }; + ta.dispatchEvent(e); + expect(ta.value).toBe(before); + expect(e.defaultPrevented).toBe(false); + }); it('a drop without our identifier is left to native handling', () => { const { ta } = mount(); const before = ta.value; diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 5094f06..989494b 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, toSubquery, } from '../../src/core/format.js'; describe('clamp', () => { @@ -157,3 +157,24 @@ describe('userShortName', () => { expect(userShortName(null)).toBe(''); }); }); + +describe('toSubquery', () => { + it('wraps SQL in parentheses on their own lines', () => { + expect(toSubquery('SELECT 1')).toBe('(\nSELECT 1\n)'); + }); + it('trims and strips a trailing semicolon', () => { + expect(toSubquery(' SELECT 1 ; ')).toBe('(\nSELECT 1\n)'); + }); + it('strips a trailing FORMAT clause (invalid inside a subquery)', () => { + expect(toSubquery('SELECT 1 FORMAT JSON')).toBe('(\nSELECT 1\n)'); + expect(toSubquery('SELECT 1 FORMAT TabSeparated;')).toBe('(\nSELECT 1\n)'); + }); + it('keeps a FORMAT that is not the trailing clause untouched', () => { + expect(toSubquery("SELECT 'FORMAT JSON' AS x")).toBe("(\nSELECT 'FORMAT JSON' AS x\n)"); + }); + it('returns empty string for empty/whitespace input', () => { + expect(toSubquery('')).toBe(''); + expect(toSubquery(' ')).toBe(''); + expect(toSubquery(null)).toBe(''); + }); +}); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index f9f3517..c61660e 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -1,8 +1,15 @@ import { describe, it, expect, vi } from 'vitest'; import { renderSavedHistory } from '../../src/ui/saved-history.js'; +import { SUBQUERY_MIME } from '../../src/ui/editor.js'; import { makeApp } from '../helpers/fake-app.js'; const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true })); +const dragStart = (el) => { + const e = new Event('dragstart', { bubbles: true }); + e.dataTransfer = { setData: vi.fn() }; + el.dispatchEvent(e); + return e.dataTransfer.setData; +}; describe('renderSavedHistory', () => { it('no-ops without mounts', () => { @@ -289,3 +296,26 @@ describe('renderSavedHistory — search/filter', () => { expect(app.state.libraryFilter).toBe(''); }); }); + +describe('drag a row into the editor', () => { + it('a saved row is draggable and carries its SQL as a subquery payload', () => { + const app = makeApp(); + app.state.sidePanel = 'saved'; + app.state.savedQueries = [{ id: 's1', name: 'Q1', sql: 'SELECT 1\n-- more', favorite: false }]; + renderSavedHistory(app); + const row = app.dom.savedList.querySelector('.saved-row'); + expect(row.getAttribute('draggable')).toBe('true'); + const setData = dragStart(row); + expect(setData).toHaveBeenCalledWith(SUBQUERY_MIME, 'SELECT 1\n-- more'); + }); + it('a history row is draggable and carries its SQL as a subquery payload', () => { + const app = makeApp(); + app.state.sidePanel = 'history'; + app.state.history = [{ id: 'h1', sql: 'SELECT 2', ts: Date.now(), rows: 1, ms: 1 }]; + renderSavedHistory(app); + const row = app.dom.savedList.querySelector('.history-row'); + expect(row.getAttribute('draggable')).toBe('true'); + const setData = dragStart(row); + expect(setData).toHaveBeenCalledWith(SUBQUERY_MIME, 'SELECT 2'); + }); +}); From 8d4c13ca37da6511cbc57bd80d75260fae94b9d8 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 18:34:48 +0200 Subject: [PATCH 2/2] refactor(editor): harden toSubquery + document drag-to-insert (review follow-ups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toSubquery: peel trailing `;`/`FORMAT` iteratively (any order, repeated/spaced), so a `FORMAT JSON;;` or `;`-then-FORMAT tail can't leave an unparseable subquery. (A trailing comment after FORMAT is still left as-is — degrades to a visible SQL error, no silent note-dropping.) - README: document drag-to-insert (schema identifier at caret; Library/History query as a ( … ) subquery at the drop point). - saved-history.js: move dragProps below the imports (readability). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- README.md | 4 ++++ src/core/format.js | 10 ++++++++-- src/ui/saved-history.js | 6 +++--- tests/unit/format.test.js | 5 +++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 298593d..6f7ab9d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ editor library — it adds nothing to the single served file). On top of that: ClickHouse keyword shows its signature/description. Both read the same cached reference data — `system.functions.{syntax,description}` (loaded with #25) and a small built-in keyword-doc set — so they never query on the keystroke path. +- **Drag to insert** — drag a schema table/column, or a **Library/History** row, + onto the editor: a schema identifier drops as text at the caret, and a + saved/history query drops as a `( … )` subquery at the drop point (its trailing + `FORMAT`/`;` stripped). Undoable; click-to-load still works for keyboard users. **The keystroke rule:** none of this runs SQL while you type. Reference data — the server's keyword and function lists — is fetched **once per connection** diff --git a/src/core/format.js b/src/core/format.js index d9432db..2621e80 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -93,8 +93,14 @@ export function inferQueryName(sql) { */ export function toSubquery(sql) { let s = String(sql || '').trim(); - s = s.replace(/;\s*$/, '').trim(); - s = s.replace(/\bFORMAT\s+[A-Za-z][A-Za-z0-9]*\s*$/i, '').trim(); + // Peel trailing `;` and `FORMAT ` clauses (either order, repeated) — both + // are invalid inside a subquery. A trailing comment after FORMAT is left as-is + // (rare; degrades to a visible SQL error rather than silently dropping a note). + let prev; + do { + prev = s; + s = s.replace(/;+\s*$/, '').replace(/\bFORMAT\s+[A-Za-z][A-Za-z0-9]*\s*$/i, '').trim(); + } while (s !== prev); return s ? '(\n' + s + '\n)' : ''; } diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 7231789..76e6076 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -7,6 +7,9 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { timeAgo } from '../core/format.js'; import { SUBQUERY_MIME } from './editor.js'; +import { + sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, deleteHistory, +} from '../state.js'; // Make a Library/History row draggable; dropping it on the editor inserts the // query wrapped as a `( … )` subquery (see the editor's drop handler). @@ -14,9 +17,6 @@ const dragProps = (sql) => ({ draggable: 'true', ondragstart: (e) => e.dataTransfer.setData(SUBQUERY_MIME, sql), }); -import { - sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, deleteHistory, -} from '../state.js'; export function renderSavedHistory(app) { const tabsRow = app.dom.savedTabsRow; diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 989494b..f65dd36 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -168,6 +168,11 @@ describe('toSubquery', () => { it('strips a trailing FORMAT clause (invalid inside a subquery)', () => { expect(toSubquery('SELECT 1 FORMAT JSON')).toBe('(\nSELECT 1\n)'); expect(toSubquery('SELECT 1 FORMAT TabSeparated;')).toBe('(\nSELECT 1\n)'); + expect(toSubquery('SELECT 1 FORMAT Null')).toBe('(\nSELECT 1\n)'); + }); + it('peels FORMAT + repeated/spaced trailing semicolons in any order', () => { + expect(toSubquery('SELECT 1 FORMAT JSON ;;')).toBe('(\nSELECT 1\n)'); + expect(toSubquery('SELECT 1 ;')).toBe('(\nSELECT 1\n)'); }); it('keeps a FORMAT that is not the trailing clause untouched', () => { expect(toSubquery("SELECT 'FORMAT JSON' AS x")).toBe("(\nSELECT 'FORMAT JSON' AS x\n)");