feat(editor): extend insert column hints to select

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-01 13:11:58 +08:00
1 parent 2e8ed4b485
commit 600af87532
14 files changed
+341 -25

No files matched your search

@@ -0,0 +1,20 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildInsertValueHintDecorations, insertValueHintsRefreshDispatchSpec, isInsertValueHintDecorationAt } from "@/lib/editor/codemirrorInsertValueHints";
test("insertValueHintsRefreshDispatchSpec disables scrollIntoView", () => {
assert.equal(insertValueHintsRefreshDispatchSpec.scrollIntoView, false);
});
test("isInsertValueHintDecorationAt detects widget anchors", () => {
const sql = "INSERT INTO t (a, b) VALUES (1, 2)";
const valueOne = sql.indexOf("1");
const valueTwo = sql.indexOf("2");
const decorations = buildInsertValueHintDecorations([
{ from: valueOne, column: "a" },
{ from: valueTwo, column: "b" },
]);
assert.equal(isInsertValueHintDecorationAt(decorations, valueOne), true);
assert.equal(isInsertValueHintDecorationAt(decorations, valueTwo), true);
assert.equal(isInsertValueHintDecorationAt(decorations, sql.indexOf("INSERT")), false);
});
@@ -1,9 +1,15 @@
import { StateEffect, type Extension } from "@codemirror/state";
import { EditorSelection, StateEffect, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { buildInsertValueHints, expandToSqlStatementWindow, insertValueHintsNeedTableColumns, parseInsertValuesClauses, type InsertValueHint, type InsertValuesClause, type TextRange } from "@/lib/sql/insertValueHints";
import { matchDollarQuoteTag } from "@/lib/sql/semantic/tokens";
import { buildInsertValueHints, expandToSqlStatementWindow, findEnclosingDollarQuoteStartInText, insertValueHintsNeedTableColumns, parseInsertValuesClauses, type InsertValueHint, type InsertValuesClause, type TextRange } from "@/lib/sql/insertValueHints";
export const refreshInsertValueHintsEffect = StateEffect.define<null>();
/** Shared dispatch options so hint refreshes never scroll the viewport. */
export const insertValueHintsRefreshDispatchSpec = {
scrollIntoView: false as const,
};
/** Debounce hint reparse while the user is typing / holding a key. */
const INSERT_HINT_REPARSE_DELAY_MS = 80;
@@ -57,6 +63,30 @@ function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet {
return Decoration.set(ranges);
}
/** @internal Exported for unit tests. */
/**
* Whether a database type runs SQL-family statements, where INSERT column
* hints (and their SELECT extension) apply. NoSQL/document stores are excluded;
* keep this list in sync instead of re-inlining it at call sites.
*/
export function supportsInsertValueHints(databaseType: string | undefined | null): boolean {
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "victoriametrics";
}
export function buildInsertValueHintDecorations(hints: readonly InsertValueHint[]): DecorationSet {
return decorationsForHints(hints);
}
/** True when `pos` sits on an insert-value-hint widget anchor (value expression start). */
export function isInsertValueHintDecorationAt(decorations: DecorationSet, pos: number): boolean {
let found = false;
decorations.between(Math.max(0, pos - 1), pos + 1, (_from, _to, decoration) => {
const widget = decoration.spec?.widget;
if (widget instanceof InsertValueHintWidget) found = true;
});
return found;
}
function interestRanges(view: EditorView): TextRange[] {
const ranges: TextRange[] = view.visibleRanges.map((range) => ({ from: range.from, to: range.to }));
const cursor = view.state.selection.main.head;
@@ -82,8 +112,20 @@ function parseClausesNearView(view: EditorView, dialectId: string): InsertValues
for (const range of interestRanges(view)) {
// Pull a bounded neighborhood from the doc instead of materializing the whole script.
const pad = 32 * 1024;
const sliceFrom = Math.max(0, range.from - pad);
let sliceFrom = Math.max(0, range.from - pad);
// When the cursor sits inside a PostgreSQL dollar-quoted routine body, a slice that still
// includes the opening $tag$ makes expandToSqlStatementWindow treat the whole body as one
// opaque literal. Advance sliceFrom past the opening delimiter instead.
const sliceTo = Math.min(doc.length, range.to + pad);
let dollarQuoteStart: number | null = null;
if (sliceTo > sliceFrom && doc.sliceString(sliceFrom, sliceTo).includes("$")) {
const probePos = Math.min(doc.length, Math.max(range.from, range.to));
dollarQuoteStart = findEnclosingDollarQuoteStartInText(doc, probePos) ?? findEnclosingDollarQuoteStartInText(doc, range.from);
}
if (dollarQuoteStart !== null) {
const openingTag = matchDollarQuoteTag(doc.sliceString(dollarQuoteStart, Math.min(doc.length, dollarQuoteStart + 64)), 0);
if (openingTag) sliceFrom = Math.max(sliceFrom, dollarQuoteStart + openingTag.length);
}
if (sliceTo <= sliceFrom) continue;
const slice = doc.sliceString(sliceFrom, sliceTo);
const window = expandToSqlStatementWindow(slice, range.from - sliceFrom, range.to - sliceFrom, dialectId);
@@ -185,7 +227,10 @@ export function createInsertValueHintsExtension(options: InsertValueHintsExtensi
this.reparseTimer = setTimeout(() => {
this.reparseTimer = null;
// Trigger a lightweight transaction so update() runs compute once typing pauses.
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) });
view.dispatch({
effects: refreshInsertValueHintsEffect.of(null),
...insertValueHintsRefreshDispatchSpec,
});
}, INSERT_HINT_REPARSE_DELAY_MS);
}
@@ -204,11 +249,31 @@ export function createInsertValueHintsExtension(options: InsertValueHintsExtensi
{ decorations: (value) => value.decorations },
);
return [insertValueHintsTheme, plugin];
const clickWithoutScroll = EditorView.domEventHandlers({
mousedown(event, view) {
if (!(options.isEnabled?.() ?? true)) return false;
const pluginState = view.plugin(plugin);
if (!pluginState) return false;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null || !isInsertValueHintDecorationAt(pluginState.decorations, pos)) return false;
event.preventDefault();
view.dispatch({
selection: EditorSelection.cursor(pos),
...insertValueHintsRefreshDispatchSpec,
});
view.focus();
return true;
},
});
return [insertValueHintsTheme, plugin, clickWithoutScroll];
}
export function requestInsertValueHintsRefresh(view: EditorView) {
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) });
view.dispatch({
effects: refreshInsertValueHintsEffect.of(null),
...insertValueHintsRefreshDispatchSpec,
});
}
export { insertValueHintsNeedTableColumns };
+72 -2
View File
@@ -173,6 +173,14 @@ function findWordIndexAtDepth(tokens: readonly SqlSemanticToken[], word: string,
return -1;
}
/** INSERT ... (SELECT ...) wraps the source query in an extra paren pair at sourceDepth. */
function findInsertSourceSelectIndex(tokens: readonly SqlSemanticToken[], from: number, sourceDepth: number): number {
if (tokens[from]?.text === "(" && tokens[from + 1]?.depth === sourceDepth + 1 && tokens[from + 1]?.kind === "word" && tokens[from + 1]?.normalized === "select") {
return from + 1;
}
return findWordIndexAtDepth(tokens, "select", from, sourceDepth);
}
function findClosingParenIndex(tokens: readonly SqlSemanticToken[], openIndex: number): number {
const open = tokens[openIndex];
if (!open || open.text !== "(") return -1;
@@ -203,8 +211,16 @@ function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex
const depth = select.depth;
let index = selectIndex + 1;
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && (tokens[index]?.normalized === "all" || tokens[index]?.normalized === "distinct")) {
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "all") {
index += 1;
} else if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "distinct") {
index += 1;
// PostgreSQL DISTINCT ON (cols) — skip the ON (...) clause before splitting projections.
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "on" && tokens[index + 1]?.text === "(") {
const closeIndex = findClosingParenIndex(tokens, index + 1);
if (closeIndex < 0) return [];
index = closeIndex + 1;
}
}
const maybeTop = tokens[index];
@@ -278,7 +294,7 @@ function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanti
const sourceDepth = tokens[insertIndex]?.depth ?? 0;
const valuesIndex = findWordIndexAtDepth(tokens, "values", index, sourceDepth);
const selectIndex = findWordIndexAtDepth(tokens, "select", index, sourceDepth);
const selectIndex = findInsertSourceSelectIndex(tokens, index, sourceDepth);
if (valuesIndex < 0 && selectIndex < 0) return null;
const rows = selectIndex >= 0 && (valuesIndex < 0 || selectIndex < valuesIndex) ? [selectProjectionStarts(tokens, selectIndex)] : parseValuesRows(tokens, valuesIndex);
@@ -540,6 +556,60 @@ interface StatementWindowMemo {
// function -- a cache hit is guaranteed to be the same answer, not a staleness risk.
let statementWindowMemo: StatementWindowMemo | null = null;
export interface SqlTextSlice {
readonly length: number;
sliceString(from: number, to: number): string;
}
/**
* When `position` sits inside an unclosed PostgreSQL dollar-quoted body (`$tag$ ...`), return the
* opening tag offset; otherwise `null`. Scans from document start so callers can advance a bounded
* editor slice to start just past the opening delimiter (not include it) before statement-window
* heuristics run.
*/
export function findEnclosingDollarQuoteStart(sql: string, position: number): number | null {
return findEnclosingDollarQuoteStartInText({ length: sql.length, sliceString: (from, to) => sql.slice(from, to) }, position);
}
const DOLLAR_QUOTE_SLICE_SCAN_CHUNK = 64 * 1024;
/** When a chunk ends on `$`, extend the read so a `$tag$` marker split across chunks is still visible. */
const DOLLAR_QUOTE_CHUNK_TAIL_EXTENSION = 128;
export function findEnclosingDollarQuoteStartInText(doc: SqlTextSlice, position: number): number | null {
let openTag: string | null = null;
let openPos = -1;
const end = Math.max(0, Math.min(position, doc.length));
let scanned = 0;
while (scanned < end) {
let chunkEnd = Math.min(scanned + DOLLAR_QUOTE_SLICE_SCAN_CHUNK, end);
if (chunkEnd < end && chunkEnd > scanned && doc.sliceString(chunkEnd - 1, chunkEnd) === "$") {
chunkEnd = Math.min(chunkEnd + DOLLAR_QUOTE_CHUNK_TAIL_EXTENSION, end);
}
const chunk = doc.sliceString(scanned, chunkEnd);
for (let local = 0; local < chunk.length; ) {
const index = scanned + local;
if (index >= end) break;
if (chunk[local] === "$") {
const marker = matchDollarQuoteTag(chunk, local);
if (marker) {
if (openTag === null) {
openTag = marker;
openPos = index;
} else if (marker === openTag) {
openTag = null;
openPos = -1;
}
local += marker.length;
continue;
}
}
local += 1;
}
scanned = chunkEnd;
}
return openTag !== null ? openPos : null;
}
export function expandToSqlStatementWindow(sql: string, from: number, to: number, dialectId = "mysql"): TextRange {
if (statementWindowMemo && statementWindowMemo.sql === sql && statementWindowMemo.from === from && statementWindowMemo.to === to && statementWindowMemo.dialectId === dialectId) {
return statementWindowMemo.result;