feat(editor): select SQL string content on double click

Closes #4261
This commit is contained in:
t8y2 committed 2026-08-24 20:22:38 +00:00
1 parent 68b0ab0476
commit 5c75c2391c
8 files changed
+307 -19

No files matched your search

@@ -128,6 +128,7 @@ import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCare
import { queryEditorCommentTokens, queryEditorLineCommentToken } from "@/lib/editor/queryEditorLineComment";
import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight";
import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection";
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
@@ -191,6 +192,11 @@ function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
}
function queryEditorSelectionLanguage(): "sql" | "text" {
const databaseType = props.databaseType;
return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
}
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
const COMPLETION_DEBOUNCE_DELAY_MS = 150;
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
@@ -1998,11 +2004,10 @@ function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean {
const databaseType = props.databaseType;
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
return extendQueryEditorSelection(currentView, {
databaseType,
dialect: sqlBehaviorDialect(),
language,
language: queryEditorSelectionLanguage(),
});
}
@@ -5151,6 +5156,16 @@ onMounted(async () => {
dropCursor(),
props.readOnly ? [] : scrollPastEnd(),
EditorView.dragMovesSelection.of((event) => !event.ctrlKey && !event.metaKey),
Prec.highest(
EditorView.mouseSelectionStyle.of((currentView, event) =>
createQueryEditorStringMouseSelection(currentView, event, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
language: queryEditorSelectionLanguage(),
composing: isEditorComposing(currentView),
}),
),
),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
@@ -398,6 +398,10 @@ describe("query editor extend selection", () => {
expect(queryEditorSource).toContain("runQueryEditorAltExtendSelection");
});
it("registers a high-priority SQL string mouse selection style", () => {
expect(queryEditorSource).toMatch(/Prec\.highest\(\s*EditorView\.mouseSelectionStyle\.of/);
});
it("matches macOS Option+W by physical key when the event key is transformed", () => {
let commandRuns = 0;
let prevented = false;
@@ -0,0 +1,152 @@
import { EditorSelection, EditorState } from "@codemirror/state";
import type { EditorView, ViewUpdate } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
import { sqlStringContentRangeAt, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges";
function eventAt(position: number, assoc: -1 | 1 = 1, overrides: Partial<MouseEvent> = {}): MouseEvent {
return {
altKey: false,
button: 0,
clientX: position,
clientY: assoc,
detail: 2,
...overrides,
} as MouseEvent;
}
function fakeView(initialState: EditorState): { view: EditorView; setState: (state: EditorState) => void } {
let state = initialState;
return {
view: {
get state() {
return state;
},
posAndSideAtCoords(coords: { x: number; y: number }) {
return { pos: coords.x, assoc: coords.y < 0 ? -1 : 1 };
},
} as EditorView,
setState(nextState) {
state = nextState;
},
};
}
function contentAt(doc: string, position: number, assoc: -1 | 1 = 1, options: SqlSemanticSelectionOptions = {}): string | null {
const range = sqlStringContentRangeAt(doc, position, assoc, options);
return range ? doc.slice(range.from, range.to) : null;
}
describe("query editor SQL string mouse selection", () => {
it("finds the complete reported literal from its first, middle, and last segment", () => {
const content = "2026-ads-1TH0G6A0gnI6EFujlZ3pMQ18";
const doc = `select '${content}'`;
const from = doc.indexOf(content);
expect(contentAt(doc, from, 1)).toBe(content);
expect(contentAt(doc, from + content.indexOf("ads") + 1)).toBe(content);
expect(contentAt(doc, from + content.length, -1)).toBe(content);
});
it("supports punctuation, whitespace, CJK, emoji, escaped quotes, and PostgreSQL dollar quotes", () => {
const cases: Array<{ doc: string; needle: string; expected: string; options?: SqlSemanticSelectionOptions }> = [
{ doc: "select 'a.b / c'", needle: "/", expected: "a.b / c" },
{ doc: "select '中文🙂内容'", needle: "🙂", expected: "中文🙂内容" },
{ doc: "select 'O''Reilly'", needle: "Reilly", expected: "O''Reilly" },
{ doc: "select $tag$a-b 中文$tag$", needle: "中文", expected: "a-b 中文", options: { dialect: "postgres" } },
];
for (const value of cases) expect(contentAt(value.doc, value.doc.indexOf(value.needle), 1, value.options)).toBe(value.expected);
});
it("falls back for delimiters, empty or incomplete strings, and non-string SQL tokens", () => {
const doc = "select 'value', \"identifier\", 123 /* 'comment' */ + 1";
const opening = doc.indexOf("'value'");
const closing = opening + "'value'".length - 1;
expect(contentAt(doc, opening)).toBeNull();
expect(contentAt(doc, closing)).toBeNull();
expect(contentAt("select ''", "select '".length)).toBeNull();
expect(contentAt("select 'unfinished", "select 'unfinished".length, -1)).toBeNull();
expect(contentAt("select '''", "select ''".length, -1)).toBeNull();
for (const needle of ["identifier", "123", "comment", "+"]) expect(contentAt(doc, doc.indexOf(needle))).toBeNull();
});
it("only overrides an unmodified left-button SQL double click", () => {
const doc = "select 'alpha-beta'";
const state = EditorState.create({ doc });
const { view } = fakeView(state);
const position = doc.indexOf("alpha");
expect(createQueryEditorStringMouseSelection(view, eventAt(position))).not.toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { button: 1 }))).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { detail: 1 }))).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { detail: 3 }))).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { altKey: true }))).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position), { composing: true })).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(position), { language: "text" })).toBeNull();
expect(createQueryEditorStringMouseSelection(view, eventAt(doc.indexOf("'")))).toBeNull();
});
it("selects string content in editable and read-only states", () => {
const doc = "select 'alpha-beta'";
for (const readOnly of [false, true]) {
const state = EditorState.create({ doc, extensions: [EditorState.readOnly.of(readOnly)] });
const { view } = fakeView(state);
const start = eventAt(doc.indexOf("alpha"));
const style = createQueryEditorStringMouseSelection(view, start);
expect(style).not.toBeNull();
const selection = style?.get(start, false, false);
expect(selection && doc.slice(selection.main.from, selection.main.to)).toBe("alpha-beta");
}
});
it("preserves Shift extension and Mod-added ranges with the original main index", () => {
const doc = "z select 'alpha-beta' tail";
const initialSelection = EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(doc.length)], 0);
const state = EditorState.create({ doc, selection: initialSelection, extensions: [EditorState.allowMultipleSelections.of(true)] });
const start = eventAt(doc.indexOf("alpha"));
const shiftStyle = createQueryEditorStringMouseSelection(fakeView(state).view, start);
const extended = shiftStyle?.get(start, true, false);
expect(extended?.ranges).toHaveLength(2);
expect(extended?.mainIndex).toBe(0);
expect(extended?.main.anchor).toBe(0);
expect(extended?.main.to).toBe(doc.indexOf("' tail"));
const modStyle = createQueryEditorStringMouseSelection(fakeView(state).view, start);
const added = modStyle?.get(start, false, true);
expect(added?.ranges).toHaveLength(3);
expect(added?.ranges.map((range) => doc.slice(range.from, range.to))).toContain("alpha-beta");
});
it("extends a double-click drag by semantic string groups in both directions", () => {
const doc = "'alpha-beta' + 'gamma-delta'";
const state = EditorState.create({ doc });
const alpha = eventAt(doc.indexOf("alpha"));
const gamma = eventAt(doc.indexOf("gamma"));
const forward = createQueryEditorStringMouseSelection(fakeView(state).view, alpha)?.get(gamma, false, false).main;
expect(forward && doc.slice(forward.from, forward.to)).toBe("alpha-beta' + 'gamma-delta");
expect(forward?.anchor).toBeLessThan(forward?.head ?? 0);
const reverse = createQueryEditorStringMouseSelection(fakeView(state).view, gamma)?.get(alpha, false, false).main;
expect(reverse && doc.slice(reverse.from, reverse.to)).toBe("alpha-beta' + 'gamma-delta");
expect(reverse?.anchor).toBeGreaterThan(reverse?.head ?? Number.POSITIVE_INFINITY);
});
it("maps the start selection and semantic analysis across document changes", () => {
const doc = "select 'alpha-beta'";
const state = EditorState.create({ doc, selection: EditorSelection.cursor(doc.length) });
const fixture = fakeView(state);
const start = eventAt(doc.indexOf("alpha"));
const style = createQueryEditorStringMouseSelection(fixture.view, start);
const transaction = state.update({ changes: { from: 0, insert: "-- " } });
fixture.setState(transaction.state);
style?.update({ docChanged: true, changes: transaction.changes, startState: state, state: transaction.state, view: fixture.view } as ViewUpdate);
const selection = style?.get(eventAt(doc.indexOf("alpha") + 3), false, false);
expect(selection && transaction.state.sliceDoc(selection.main.from, selection.main.to)).toBe("alpha-beta");
});
});
@@ -21,6 +21,14 @@ describe("sqlSemanticTokens", () => {
expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false);
});
it("records whether quoted tokens have a closing delimiter", () => {
const complete = tokenizeSqlSemantic("select 'value'").find((token) => token.kind === "string");
const incomplete = tokenizeSqlSemantic("select '''").find((token) => token.kind === "string");
expect(complete?.closed).toBe(true);
expect(incomplete?.closed).toBe(false);
});
it("handles hash tokens according to the SQL dialect", () => {
const sqlServerSql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp";
const sqlServerTokens = tokenizeSqlSemantic(sqlServerSql, "sqlserver");
@@ -0,0 +1,77 @@
import { EditorSelection, findClusterBreak, type EditorState, type SelectionRange } from "@codemirror/state";
import type { EditorView, MouseSelectionStyle, ViewUpdate } from "@codemirror/view";
import { analyzeSqlSemanticSelectionRanges, sqlStringContentRangeAt, type SqlSemanticSelectionAnalysis, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges";
export interface QueryEditorStringMouseSelectionOptions extends SqlSemanticSelectionOptions {
language?: "sql" | "text";
composing?: boolean;
}
interface PointerPosition {
pos: number;
assoc: -1 | 1;
}
function groupAt(state: EditorState, position: PointerPosition): SelectionRange {
const line = state.doc.lineAt(position.pos);
if (line.length === 0) return EditorSelection.cursor(position.pos);
let bias = position.assoc;
const linePosition = position.pos - line.from;
if (linePosition === 0) bias = 1;
else if (linePosition === line.length) bias = -1;
let from = linePosition;
let to = linePosition;
if (bias < 0) from = findClusterBreak(line.text, linePosition, false);
else to = findClusterBreak(line.text, linePosition);
const categorize = state.charCategorizer(position.pos);
const category = categorize(line.text.slice(from, to));
while (from > 0) {
const previous = findClusterBreak(line.text, from, false);
if (categorize(line.text.slice(previous, from)) !== category) break;
from = previous;
}
while (to < line.length) {
const next = findClusterBreak(line.text, to);
if (categorize(line.text.slice(to, next)) !== category) break;
to = next;
}
return EditorSelection.range(from + line.from, to + line.from);
}
function semanticOrDefaultRange(state: EditorState, position: PointerPosition, options: SqlSemanticSelectionOptions, analysis: SqlSemanticSelectionAnalysis): SelectionRange {
const semantic = sqlStringContentRangeAt(state.doc.toString(), position.pos, position.assoc, options, analysis);
return semantic ? EditorSelection.range(semantic.from, semantic.to) : groupAt(state, position);
}
export function createQueryEditorStringMouseSelection(view: EditorView, event: MouseEvent, options: QueryEditorStringMouseSelectionOptions = {}): MouseSelectionStyle | null {
if (event.button !== 0 || event.detail !== 2 || event.altKey || options.composing || options.language === "text") return null;
let start = view.posAndSideAtCoords({ x: event.clientX, y: event.clientY }, false);
let analysis = analyzeSqlSemanticSelectionRanges(view.state.doc.toString(), options);
const semantic = sqlStringContentRangeAt(view.state.doc.toString(), start.pos, start.assoc, options, analysis);
if (!semantic) return null;
let startRange = EditorSelection.range(semantic.from, semantic.to);
let startSelection = view.state.selection;
return {
update(update: ViewUpdate) {
if (!update.docChanged) return;
start = { pos: update.changes.mapPos(start.pos, start.assoc), assoc: start.assoc };
startRange = startRange.map(update.changes);
startSelection = startSelection.map(update.changes);
analysis = analyzeSqlSemanticSelectionRanges(update.state.doc.toString(), options);
},
get(currentEvent, extend, multiple) {
const current = view.posAndSideAtCoords({ x: currentEvent.clientX, y: currentEvent.clientY }, false);
let range = start.pos === current.pos ? startRange : semanticOrDefaultRange(view.state, current, options, analysis);
if (start.pos !== current.pos && !extend) {
const from = Math.min(startRange.from, range.from);
const to = Math.max(startRange.to, range.to);
range = from < range.from ? EditorSelection.range(from, to, undefined, undefined, range.assoc) : EditorSelection.range(to, from, undefined, undefined, range.assoc);
}
if (extend) return startSelection.replaceRange(startSelection.main.extend(range.from, range.to, range.assoc));
if (multiple) return startSelection.addRange(range);
return EditorSelection.create([range]);
},
};
}
@@ -17,6 +17,7 @@ interface SqlSelectionToken {
to: number;
depth: number;
quote?: string;
closed?: boolean;
}
interface StatementWindow {
@@ -36,6 +37,7 @@ interface QueryBlockRange extends SemanticSelectionRange {
export interface SqlSemanticSelectionAnalysis {
doc: string;
statements: readonly PreparedStatementWindow[];
stringContents: readonly SemanticSelectionRange[];
}
const QUERY_CLAUSE_WORDS = new Set(["where", "having", "on", "using", "limit", "offset", "fetch", "returning", "qualify", "window"]);
@@ -98,7 +100,7 @@ function normalizeTokens(input: string, tokens: SqlSemanticToken[]): SqlSelectio
continue;
}
}
result.push({ kind: token.kind, text: input.slice(token.span.start, token.span.end), normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote });
result.push({ kind: token.kind, text: input.slice(token.span.start, token.span.end), normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote, closed: token.closed });
}
return result;
}
@@ -108,7 +110,7 @@ function validateTokens(input: string, tokens: SqlSelectionToken[]): boolean {
for (const token of tokens) {
if (token.kind === "string") {
const quote = token.quote ?? token.text[0] ?? "";
if (quote.startsWith("$") ? !token.text.endsWith(quote) : !token.text.endsWith(quote)) return false;
if (token.closed === false || !token.text.endsWith(quote)) return false;
}
if (token.kind === "comment" && token.text.startsWith("/*") && !token.text.endsWith("*/")) return false;
if (token.text === "(") stack.push(token);
@@ -382,16 +384,40 @@ function collectSqlRanges(input: string, statement: StatementWindow, maps: { ope
return ranges;
}
function stringContentRange(token: SqlSelectionToken): SemanticSelectionRange | null {
if (token.kind !== "string") return null;
const quote = token.quote ?? token.text[0] ?? "";
const quoteLength = quote.startsWith("$") ? quote.length : 1;
const from = token.from + quoteLength;
const to = token.to - quoteLength;
return token.closed !== false && quote && token.text.startsWith(quote) && token.text.endsWith(quote) && from <= to ? { from, to } : null;
}
export function analyzeSqlSemanticSelectionRanges(doc: string, options: SqlSemanticSelectionOptions = {}): SqlSemanticSelectionAnalysis {
const dialect = sqlSemanticDialectFor(options).id;
const rawTokens = tokenizeSqlSemantic(doc, dialect);
const validationTokens = rawTokens.map((token) => ({ kind: token.kind, text: token.text, normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote }));
const validationTokens = rawTokens.map((token) => ({ kind: token.kind, text: token.text, normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote, closed: token.closed }));
const tokens = normalizeTokens(doc, rawTokens);
const statements: PreparedStatementWindow[] = statementWindows(doc, tokens).filter((statement) => {
const statementValidationTokens = validationTokens.filter((token) => token.from >= statement.start && token.to <= statement.end);
return validateTokens(doc.slice(statement.start, statement.end), statementValidationTokens);
});
return { doc, statements };
const stringContents = statements.flatMap((statement) => statement.tokens.map(stringContentRange).filter((range): range is SemanticSelectionRange => range != null));
return { doc, statements, stringContents };
}
export function sqlStringContentRangeAt(doc: string, position: number, assoc: -1 | 1, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(doc, options)): SemanticSelectionRange | null {
if (analysis.doc !== doc) analysis = analyzeSqlSemanticSelectionRanges(doc, options);
const character = assoc < 0 ? position - 1 : position;
let low = 0;
let high = analysis.stringContents.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((analysis.stringContents[middle]?.to ?? Number.POSITIVE_INFINITY) <= character) low = middle + 1;
else high = middle;
}
const range = analysis.stringContents[low];
return range && range.from <= character && character < range.to ? range : null;
}
function findStatement(context: SemanticSelectionContext, analysis: SqlSemanticSelectionAnalysis): PreparedStatementWindow | undefined {
+18 -13
View File
@@ -3,7 +3,7 @@ import type { SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types
const WORD_START = /[A-Za-z_@$#]/;
const WORD_PART = /[A-Za-z0-9_@$#]/;
function token(kind: SqlSemanticToken["kind"], text: string, start: number, end: number, depth: number, quote?: string): SqlSemanticToken {
function token(kind: SqlSemanticToken["kind"], text: string, start: number, end: number, depth: number, quote?: string, closed?: boolean): SqlSemanticToken {
return {
kind,
text,
@@ -11,10 +11,11 @@ function token(kind: SqlSemanticToken["kind"], text: string, start: number, end:
span: { start, end },
depth,
quote,
...(closed === undefined ? {} : { closed }),
};
}
function readQuoted(input: string, start: number, open: string, close: string): number {
function readQuoted(input: string, start: number, open: string, close: string): { end: number; closed: boolean } {
let index = start + open.length;
while (index < input.length) {
if (input.startsWith(close, index)) {
@@ -22,11 +23,11 @@ function readQuoted(input: string, start: number, open: string, close: string):
index += close.length * 2;
continue;
}
return index + close.length;
return { end: index + close.length, closed: true };
}
index += 1;
}
return input.length;
return { end: input.length, closed: false };
}
const DOLLAR_QUOTE_TAG_PATTERN = /\$[A-Za-z_0-9]*\$/y;
@@ -86,8 +87,9 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
}
if (ch === "'") {
index = readQuoted(input, start, "'", "'");
tokens.push(token("string", input.slice(start, index), start, index, depth, "'"));
const quoted = readQuoted(input, start, "'", "'");
index = quoted.end;
tokens.push(token("string", input.slice(start, index), start, index, depth, "'", quoted.closed));
continue;
}
@@ -96,26 +98,29 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
if (marker) {
const closing = input.indexOf(marker, start + marker.length);
index = closing < 0 ? input.length : closing + marker.length;
tokens.push(token("string", input.slice(start, index), start, index, depth, marker));
tokens.push(token("string", input.slice(start, index), start, index, depth, marker, closing >= 0));
continue;
}
}
if (ch === '"') {
index = readQuoted(input, start, '"', '"');
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"'));
const quoted = readQuoted(input, start, '"', '"');
index = quoted.end;
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"', quoted.closed));
continue;
}
if (ch === "`") {
index = readQuoted(input, start, "`", "`");
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "`"));
const quoted = readQuoted(input, start, "`", "`");
index = quoted.end;
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "`", quoted.closed));
continue;
}
if (ch === "[") {
index = readQuoted(input, start, "[", "]");
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "["));
const quoted = readQuoted(input, start, "[", "]");
index = quoted.end;
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "[", quoted.closed));
continue;
}
@@ -19,6 +19,7 @@ export interface SqlSemanticToken {
span: SqlSemanticSpan;
depth: number;
quote?: string;
closed?: boolean;
}
export interface SqlSemanticIdentifierPart {