mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(editor): stop per-keystroke full-document statement reparse
Closes #8600
This commit is contained in:
@@ -32,7 +32,16 @@ import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import {
|
||||
executableStatementRangeAtCursor,
|
||||
executableStatementRangeCacheForDoc,
|
||||
executableStatementRangeStartingAt as executableStatementRangeStartingAtLine,
|
||||
mapStatementGutterStartIndex,
|
||||
statementGutterStartIndexForCache,
|
||||
statementGutterStartIndexHasStartAt,
|
||||
type ExecutableStatementRangeCache,
|
||||
type StatementGutterStartIndex,
|
||||
} from "@/lib/sql/executableStatementRangeCache";
|
||||
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
|
||||
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
|
||||
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
|
||||
@@ -247,6 +256,9 @@ const COMPLETION_TRIGGER_DEFER_DELAY_MS = 50;
|
||||
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
|
||||
const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100;
|
||||
const COMPLETION_ENTER_MAX_WAIT_MS = 125;
|
||||
// Signature-help only ever looks backward from the cursor; past this distance
|
||||
// no human-authored call site is worth the scan on huge documents.
|
||||
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
|
||||
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
|
||||
|
||||
@@ -681,6 +693,68 @@ function runStatementGutterExtension(): import("@codemirror/state").Extension {
|
||||
}
|
||||
|
||||
let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
|
||||
|
||||
// Lenient statement-boundary view shared by the run-statement gutter and the
|
||||
// current-statement frame. Re-parsing the whole document on every keystroke is
|
||||
// the dominant typing cost on large scripts, so while typing we only shift the
|
||||
// known start positions / frame range through the ChangeSet (O(statements)) and
|
||||
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
|
||||
// click-to-execute, execution picker) keep using the full on-demand parse.
|
||||
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
|
||||
let statementBoundariesView: {
|
||||
doc: import("@codemirror/state").Text;
|
||||
startsIndex: StatementGutterStartIndex;
|
||||
frameRange: { from: number; to: number } | null;
|
||||
fresh: boolean;
|
||||
generation: number;
|
||||
} | null = null;
|
||||
let statementBoundariesGeneration = 0;
|
||||
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let statementBoundariesRefreshEffect: import("@codemirror/state").StateEffectType<null> | null = null;
|
||||
|
||||
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
statementBoundariesView = {
|
||||
doc: state.doc,
|
||||
startsIndex: statementGutterStartIndexForCache(executableStatementRangeCache),
|
||||
frameRange: null,
|
||||
fresh: true,
|
||||
generation: statementBoundariesGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
interface StatementBoundariesView {
|
||||
doc: import("@codemirror/state").Text;
|
||||
startsIndex: StatementGutterStartIndex;
|
||||
frameRange: { from: number; to: number } | null;
|
||||
fresh: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// Returns the view matching `state`, rebuilding it synchronously when the
|
||||
// tracked doc fell out of sync (first use, tab switch via setState) or the
|
||||
// dialect generation moved. While typing, the tracking plugin keeps the doc
|
||||
// reference current through ChangeSet mapping, so this stays cheap.
|
||||
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
|
||||
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
|
||||
refreshStatementBoundaries(state);
|
||||
return statementBoundariesView!;
|
||||
}
|
||||
|
||||
function scheduleStatementBoundariesRefresh(currentView: EditorViewType) {
|
||||
// True debounce: continuous typing must never pay a full-document parse —
|
||||
// mapped positions serve the gutter/frame, and one rebuild lands only after
|
||||
// the pause.
|
||||
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
|
||||
statementBoundariesRefreshTimer = setTimeout(() => {
|
||||
statementBoundariesRefreshTimer = null;
|
||||
if (view.value !== currentView || !currentView.dom.isConnected) return;
|
||||
refreshStatementBoundaries(currentView.state);
|
||||
if (statementBoundariesRefreshEffect) {
|
||||
currentView.dispatch({ effects: statementBoundariesRefreshEffect.of(null) });
|
||||
}
|
||||
}, STATEMENT_BOUNDARIES_REFRESH_MS);
|
||||
}
|
||||
let editorScrollbarPointerCleanup: (() => void) | null = null;
|
||||
let editorSelectionDragCleanup: (() => void) | null = null;
|
||||
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
|
||||
@@ -1344,7 +1418,11 @@ function tableNavigationIdentifierAt(currentView: EditorViewType, event: MouseEv
|
||||
if (!props.connectionId || props.database == null) return null;
|
||||
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
|
||||
if (pos == null) return null;
|
||||
const extracted = extractIdentifierDetailsAt(currentView.state.doc.toString(), pos);
|
||||
// Identifier extraction only looks around `pos`; slice a small window so
|
||||
// modifier-held mouse moves never materialize the whole document string.
|
||||
const windowFrom = Math.max(0, pos - 1024);
|
||||
const doc = currentView.state.doc;
|
||||
const extracted = extractIdentifierDetailsAt(doc.sliceString(windowFrom, Math.min(doc.length, pos + 1024)), pos - windowFrom);
|
||||
if (!extracted || (!extracted.quoted && isSqlKeyword(extracted.identifier))) return null;
|
||||
return extracted.identifier;
|
||||
}
|
||||
@@ -5873,6 +5951,45 @@ onMounted(async () => {
|
||||
previewRangeComp = new Compartment();
|
||||
indentComp = new Compartment();
|
||||
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
|
||||
statementBoundariesRefreshEffect = StateEffect.define<null>();
|
||||
// Keeps the lenient statement-boundary view glued to the live document:
|
||||
// each keystroke shifts the known starts / frame range through the
|
||||
// ChangeSet (O(statements)) and schedules one full rebuild after typing
|
||||
// pauses, so neither the run gutter nor the statement frame re-parses the
|
||||
// whole document synchronously on every keystroke.
|
||||
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: import("@codemirror/view").ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
const boundaries = statementBoundariesView;
|
||||
// No consumer (run gutter off + statement frame off) ever initialized
|
||||
// the view — nothing to maintain and no refresh to schedule.
|
||||
if (!boundaries) return;
|
||||
if (boundaries.doc === update.startState.doc) {
|
||||
statementBoundariesView = {
|
||||
doc: update.state.doc,
|
||||
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
|
||||
frameRange: boundaries.frameRange
|
||||
? {
|
||||
from: update.changes.mapPos(boundaries.frameRange.from, 1),
|
||||
to: update.changes.mapPos(boundaries.frameRange.to, 1),
|
||||
}
|
||||
: null,
|
||||
fresh: false,
|
||||
generation: boundaries.generation,
|
||||
};
|
||||
}
|
||||
scheduleStatementBoundariesRefresh(update.view);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (statementBoundariesRefreshTimer !== null) {
|
||||
clearTimeout(statementBoundariesRefreshTimer);
|
||||
statementBoundariesRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
codeMirrorCompletionStatus = completionStatus;
|
||||
codeMirrorAcceptCompletion = acceptCompletion;
|
||||
codeMirrorCurrentCompletions = currentCompletions;
|
||||
@@ -6108,9 +6225,18 @@ onMounted(async () => {
|
||||
markers: (currentView) => currentView.state.field(field),
|
||||
lineMarker(currentView, line, markers) {
|
||||
const executionMarker = markers.find((marker) => marker instanceof StatementExecutionStateMarker)?.marker;
|
||||
const canExecute = showRunButtons && !!executableStatementRangeStartingAt(currentView, line.from);
|
||||
// Membership check against the lenient index (mapped through the
|
||||
// ChangeSet while typing) instead of a full-document re-parse per
|
||||
// keystroke. The click handler still resolves the exact range on
|
||||
// demand, so display-level approximation never affects execution.
|
||||
const canExecute = showRunButtons && statementGutterStartIndexHasStartAt(statementBoundariesForState(currentView.state).startsIndex, line.from);
|
||||
return canExecute || executionMarker ? new StatementGutterMarker(canExecute, executionMarker) : null;
|
||||
},
|
||||
lineMarkerChange(update) {
|
||||
// Redraw once the debounced full rebuild has landed.
|
||||
const refreshEffect = statementBoundariesRefreshEffect;
|
||||
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
|
||||
},
|
||||
domEventHandlers: showRunButtons
|
||||
? {
|
||||
mousedown: executeSqlStatementFromGutter,
|
||||
@@ -6122,10 +6248,15 @@ onMounted(async () => {
|
||||
};
|
||||
buildSqlSignatureExtension = () =>
|
||||
showTooltip.compute(["doc", "selection"], (currentState) => {
|
||||
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head, props.databaseType, sqlDriverProfile.value);
|
||||
const cursor = currentState.selection.main.head;
|
||||
// Signature detection only scans backward from the cursor, so window the
|
||||
// text instead of materializing the whole document prefix on every
|
||||
// keystroke — doc.toString() was O(document) per key on large scripts.
|
||||
const windowFrom = Math.max(0, cursor - SQL_SIGNATURE_HELP_WINDOW_CHARS);
|
||||
const signature = getSqlFunctionSignatureHelp(currentState.doc.sliceString(windowFrom, cursor), cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 });
|
||||
if (!signature) return null;
|
||||
return {
|
||||
pos: currentState.selection.main.head,
|
||||
pos: cursor,
|
||||
above: false,
|
||||
clip: false,
|
||||
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
|
||||
@@ -6313,43 +6444,60 @@ onMounted(async () => {
|
||||
await ensureCodeMirrorVim();
|
||||
}
|
||||
|
||||
const currentStatementFrameExtension = currentStatementFrameLayer({ layer, RectangleMarker }, (view) => {
|
||||
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
|
||||
let range = currentExecutableStatementRange(view);
|
||||
if (!range) {
|
||||
const currentStatementFrameExtension = currentStatementFrameLayer(
|
||||
{ layer, RectangleMarker },
|
||||
(view) => {
|
||||
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const cursorLine = view.state.doc.lineAt(cursorPos);
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
// Find ranges that overlap the cursor line, then expand to include
|
||||
// adjacent ranges (handles parser fragments from edge cases like
|
||||
// ultra-long comments splitting a statement).
|
||||
let mergedFrom = cursorLine.from;
|
||||
let mergedTo = cursorLine.from;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const cachedRange of executableStatementRangeCache.ranges) {
|
||||
// Only merge ranges that overlap the cursor line or are adjacent
|
||||
// to the current merged region
|
||||
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
|
||||
const newFrom = Math.min(mergedFrom, cachedRange.from);
|
||||
const newTo = Math.max(mergedTo, cachedRange.to);
|
||||
if (newFrom !== mergedFrom || newTo !== mergedTo) {
|
||||
mergedFrom = newFrom;
|
||||
mergedTo = newTo;
|
||||
changed = true;
|
||||
const boundaries = statementBoundariesForState(view.state);
|
||||
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
|
||||
// Typing in progress: reuse the ChangeSet-shifted range instead of
|
||||
// re-parsing the whole document. The debounced refresh rebuilds and
|
||||
// repaints the exact frame once typing pauses.
|
||||
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
|
||||
}
|
||||
let range = currentExecutableStatementRange(view);
|
||||
if (!range) {
|
||||
const cursorLine = view.state.doc.lineAt(cursorPos);
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
// Find ranges that overlap the cursor line, then expand to include
|
||||
// adjacent ranges (handles parser fragments from edge cases like
|
||||
// ultra-long comments splitting a statement).
|
||||
let mergedFrom = cursorLine.from;
|
||||
let mergedTo = cursorLine.from;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const cachedRange of executableStatementRangeCache.ranges) {
|
||||
// Only merge ranges that overlap the cursor line or are adjacent
|
||||
// to the current merged region
|
||||
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
|
||||
const newFrom = Math.min(mergedFrom, cachedRange.from);
|
||||
const newTo = Math.max(mergedTo, cachedRange.to);
|
||||
if (newFrom !== mergedFrom || newTo !== mergedTo) {
|
||||
mergedFrom = newFrom;
|
||||
mergedTo = newTo;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mergedTo > mergedFrom) {
|
||||
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
|
||||
}
|
||||
}
|
||||
if (mergedTo > mergedFrom) {
|
||||
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
|
||||
}
|
||||
}
|
||||
if (!range) return null;
|
||||
return { from: range.from, to: currentStatementFrameTo(view, range) };
|
||||
});
|
||||
if (boundaries) boundaries.frameRange = range ? { from: range.from, to: range.to } : null;
|
||||
if (!range) return null;
|
||||
return { from: range.from, to: currentStatementFrameTo(view, range) };
|
||||
},
|
||||
{
|
||||
shouldRefresh(update) {
|
||||
const refreshEffect = statementBoundariesRefreshEffect;
|
||||
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
|
||||
return currentStatementFrameRangeTo(view.state.doc, range);
|
||||
@@ -6401,6 +6549,10 @@ onMounted(async () => {
|
||||
// immediate drag-select there trigger CodeMirror's edge autoscroll.
|
||||
scrollToMatch: (range) => EditorView.scrollIntoView(range, { y: "center" }),
|
||||
}),
|
||||
// Must update before the run gutter's plugin registers below: each
|
||||
// keystroke maps the boundary view to the new doc before any lineMarker
|
||||
// callback reads it, otherwise the gutter would trigger a full parse.
|
||||
statementBoundariesTrackingPlugin,
|
||||
runGutterComp.of(runStatementGutterExtension()),
|
||||
lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
|
||||
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
|
||||
@@ -7167,6 +7319,7 @@ watch([() => props.clientSessionId, () => props.completionContextVersion], () =>
|
||||
|
||||
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], () => {
|
||||
executableStatementRangeCache = null;
|
||||
statementBoundariesGeneration += 1;
|
||||
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return;
|
||||
// Signature tooltips depend on the external dialect, so refresh them even when the document and selection stay unchanged.
|
||||
view.value.dispatch({
|
||||
@@ -7183,6 +7336,7 @@ watch(
|
||||
(now, before) => {
|
||||
if (now === before) return;
|
||||
executableStatementRangeCache = null;
|
||||
statementBoundariesGeneration += 1;
|
||||
if (props.databaseType !== "opengauss") return;
|
||||
if (!view.value) return;
|
||||
refreshCompletionCache();
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Text } from "@codemirror/state";
|
||||
import { ChangeSet, Text } from "@codemirror/state";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache";
|
||||
import {
|
||||
executableStatementRangeAtCursor,
|
||||
executableStatementRangeCacheForDoc,
|
||||
executableStatementRangeStartingAt,
|
||||
mapStatementGutterStartIndex,
|
||||
statementGutterStartIndexForCache,
|
||||
statementGutterStartIndexHasStartAt,
|
||||
type ExecutableStatementRangeParser,
|
||||
} from "@/lib/sql/executableStatementRangeCache";
|
||||
|
||||
describe("executableStatementRangeCacheForDoc", () => {
|
||||
it("tracks MongoDB commands for current-statement framing", () => {
|
||||
@@ -237,3 +245,33 @@ describe("executableStatementRangeCacheForDoc", () => {
|
||||
expect(parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statementGutterStartIndex", () => {
|
||||
it("derives membership from both statement starts and executable line starts", () => {
|
||||
const doc = Text.of(["/*+ hint */", "SELECT 1;", "", "SELECT 2;"]);
|
||||
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
||||
const index = statementGutterStartIndexForCache(cache);
|
||||
|
||||
// First statement: executable start lands on the directive's following line.
|
||||
expect(statementGutterStartIndexHasStartAt(index, doc.line(2).from)).toBe(true);
|
||||
expect(statementGutterStartIndexHasStartAt(index, doc.line(4).from)).toBe(true);
|
||||
expect(statementGutterStartIndexHasStartAt(index, doc.line(3).from)).toBe(false);
|
||||
});
|
||||
|
||||
it("shifts start positions through insertions and deletions", () => {
|
||||
const doc = Text.of(["SELECT 1;", "SELECT 2;"]);
|
||||
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
|
||||
const index = statementGutterStartIndexForCache(cache);
|
||||
const secondLineStart = doc.line(2).from;
|
||||
expect(statementGutterStartIndexHasStartAt(index, secondLineStart)).toBe(true);
|
||||
|
||||
const header = "-- lead\n";
|
||||
const afterInsert = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: 0, insert: header }, doc.length));
|
||||
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart)).toBe(false);
|
||||
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart + header.length)).toBe(true);
|
||||
|
||||
const afterDelete = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: "SELECT 1;\n".length, insert: "" }, doc.length));
|
||||
expect(statementGutterStartIndexHasStartAt(afterDelete, secondLineStart - "SELECT 1;\n".length)).toBe(true);
|
||||
expect(statementGutterStartIndexHasStartAt(afterDelete, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,9 +148,11 @@ interface CurrentStatementFrameModule {
|
||||
/**
|
||||
* Draw one continuous green rectangle around the current executable
|
||||
* statement. `resolve` decides visibility and returns the statement range;
|
||||
* returning null hides the frame.
|
||||
* returning null hides the frame. `options.shouldRefresh` forces a repaint on
|
||||
* updates that change statement boundaries without touching the document
|
||||
* (e.g. a debounced boundary rebuild landing after a typing pause).
|
||||
*/
|
||||
export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver): Extension {
|
||||
export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver, options?: { shouldRefresh?: (update: ViewUpdate) => boolean }): Extension {
|
||||
let lastRequest: StatementFrameRequest | null | undefined;
|
||||
|
||||
const sameRequest = (left: StatementFrameRequest | null | undefined, right: StatementFrameRequest | null): boolean => left === right || (!!left && !!right && left.from === right.from && left.to === right.to);
|
||||
@@ -173,6 +175,10 @@ export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModu
|
||||
lastRequest = undefined;
|
||||
return true;
|
||||
}
|
||||
if (options?.shouldRefresh?.(update)) {
|
||||
lastRequest = undefined;
|
||||
return true;
|
||||
}
|
||||
if (!update.selectionSet) return false;
|
||||
|
||||
// Moving inside one statement does not change its frame. Avoid repeating
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { ChangeSet, Text } from "@codemirror/state";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
@@ -14,6 +14,36 @@ export interface ExecutableStatementRangeCache {
|
||||
ranges: SqlTextRange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership-only view of the statement-start positions a run-statement gutter
|
||||
* needs (`byStart` keys plus `byExecutableLineStart` keys). Kept separate from
|
||||
* the full cache so it can be cheaply shifted through a ChangeSet on every
|
||||
* keystroke and fully rebuilt only after typing pauses, instead of re-parsing
|
||||
* the whole document synchronously per keystroke.
|
||||
*/
|
||||
export interface StatementGutterStartIndex {
|
||||
starts: ReadonlySet<number>;
|
||||
executableLineStarts: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export function statementGutterStartIndexForCache(cache: ExecutableStatementRangeCache): StatementGutterStartIndex {
|
||||
return { starts: new Set(cache.byStart.keys()), executableLineStarts: new Set(cache.byExecutableLineStart.keys()) };
|
||||
}
|
||||
|
||||
export function mapStatementGutterStartIndex(index: StatementGutterStartIndex, changes: ChangeSet): StatementGutterStartIndex {
|
||||
return { starts: mapStartPositions(index.starts, changes), executableLineStarts: mapStartPositions(index.executableLineStarts, changes) };
|
||||
}
|
||||
|
||||
function mapStartPositions(positions: ReadonlySet<number>, changes: ChangeSet): Set<number> {
|
||||
const mapped = new Set<number>();
|
||||
for (const position of positions) mapped.add(changes.mapPos(position, 1));
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function statementGutterStartIndexHasStartAt(index: StatementGutterStartIndex, lineFrom: number): boolean {
|
||||
return index.starts.has(lineFrom) || index.executableLineStarts.has(lineFrom);
|
||||
}
|
||||
|
||||
export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions) => SqlTextRange[];
|
||||
|
||||
export function executableStatementRangeCacheForDoc(
|
||||
|
||||
@@ -1717,16 +1717,22 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options
|
||||
if (isSqlCompletionSuppressedContext(sql, cursor, options)) return false;
|
||||
const previousChar = sql[cursor - 1];
|
||||
if (!previousChar) return false;
|
||||
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
|
||||
if (isAfterJoinModifierContext(sql.slice(0, cursor), options.databaseType)) return true;
|
||||
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
|
||||
// Statement-bounded prefix: every probe below is anchored at the cursor and
|
||||
// only looks back within the current statement. Slicing the whole prefix (and
|
||||
// the literal-masking scans inside the modifier/expression helpers) made each
|
||||
// completion trigger O(document) on large scripts.
|
||||
const statementSpan = sqlCompletionStatementSpan(sql, cursor, options);
|
||||
const beforeCursor = sql.slice(statementSpan.start, cursor);
|
||||
if (/\bon\s+$/i.test(beforeCursor)) return true;
|
||||
if (isAfterJoinModifierContext(beforeCursor, options.databaseType)) return true;
|
||||
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(beforeCursor)) return true;
|
||||
const context = getSqlCompletionContext(sql, cursor, options);
|
||||
if (previousChar === "(" && (context.insertTable || context.preferredValueKeywords?.length)) return true;
|
||||
if (/[,;()[\]]/.test(previousChar)) return false;
|
||||
if (context.exclusiveTableSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) {
|
||||
return true;
|
||||
}
|
||||
if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, sql, cursor, options.databaseType)) return true;
|
||||
if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, beforeCursor, beforeCursor.length, options.databaseType)) return true;
|
||||
return /[A-Za-z_$@.]/.test(previousChar);
|
||||
}
|
||||
|
||||
@@ -2150,9 +2156,9 @@ export function getSqlCompletionResultValidFor(sql: string, cursor: number): Reg
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType, driverProfile?: string): SqlFunctionSignatureHelp | null {
|
||||
export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType, driverProfile?: string, options?: { truncatedPrefix?: boolean }): SqlFunctionSignatureHelp | null {
|
||||
const beforeCursor = sql.slice(0, cursor);
|
||||
const call = findActiveFunctionCall(beforeCursor);
|
||||
const call = findActiveFunctionCall(beforeCursor, options?.truncatedPrefix === true);
|
||||
if (!call) return null;
|
||||
|
||||
const observedParameter = countTopLevelCommas(call.groupText);
|
||||
@@ -5519,8 +5525,8 @@ interface ActiveFunctionCall {
|
||||
groupText: string;
|
||||
}
|
||||
|
||||
function findActiveFunctionCall(sqlBeforeCursor: string): ActiveFunctionCall | null {
|
||||
const activeOpenParen = findActiveFunctionOpenParen(sqlBeforeCursor);
|
||||
function findActiveFunctionCall(sqlBeforeCursor: string, truncatedPrefix = false): ActiveFunctionCall | null {
|
||||
const activeOpenParen = findActiveFunctionOpenParen(sqlBeforeCursor, truncatedPrefix);
|
||||
if (activeOpenParen == null) return null;
|
||||
|
||||
const beforeActiveGroup = sqlBeforeCursor.slice(0, activeOpenParen).trimEnd();
|
||||
@@ -5569,12 +5575,23 @@ function findMatchingOpenParen(text: string, closeParenIndex: number): number |
|
||||
return null;
|
||||
}
|
||||
|
||||
function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null {
|
||||
/**
|
||||
* Backward signature scans stop after this many characters: no human-authored
|
||||
* argument group is worth a longer scan, and generated SQL can embed
|
||||
* megabyte-long value lists inside a single call.
|
||||
*/
|
||||
const SQL_SIGNATURE_SCAN_LIMIT_CHARS = 100_000;
|
||||
|
||||
function findActiveFunctionOpenParen(sqlBeforeCursor: string, truncatedPrefix = false): number | null {
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
// With a truncated window the scan must not trust index 0: an "unmatched"
|
||||
// open paren sitting exactly on the window edge may have its match before
|
||||
// the window, which would fabricate a signature tooltip.
|
||||
const floorIndex = Math.max(truncatedPrefix ? 1 : 0, sqlBeforeCursor.length - SQL_SIGNATURE_SCAN_LIMIT_CHARS);
|
||||
|
||||
for (let i = sqlBeforeCursor.length - 1; i >= 0; i--) {
|
||||
for (let i = sqlBeforeCursor.length - 1; i >= floorIndex; i--) {
|
||||
const ch = sqlBeforeCursor[i];
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
|
||||
Reference in New Issue
Block a user