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 5b36283..2621e80 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -85,6 +85,25 @@ 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(); + // 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)' : ''; +} + /** 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..76e6076 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -6,10 +6,18 @@ 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). +const dragProps = (sql) => ({ + draggable: 'true', + ondragstart: (e) => e.dataTransfer.setData(SUBQUERY_MIME, sql), +}); + export function renderSavedHistory(app) { const tabsRow = app.dom.savedTabsRow; const list = app.dom.savedList; @@ -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..f65dd36 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,29 @@ 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)'); + 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)"); + }); + 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'); + }); +});