Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
19 changes: 19 additions & 0 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <name>` 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 <name>` 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 || '');
Expand Down
27 changes: 23 additions & 4 deletions src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions src/ui/saved-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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); },
Expand Down
21 changes: 21 additions & 0 deletions tests/e2e/editor-insert.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
33 changes: 32 additions & 1 deletion tests/unit/editor.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion tests/unit/format.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('');
});
});
30 changes: 30 additions & 0 deletions tests/unit/saved-history.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Loading