fix(editor): smooth SQL editor state synchronization

This commit is contained in:
t8y2 committed 2026-09-09 22:34:52 +08:00
1 parent 7434295b1f
commit c8c7a40e46
4 files changed
+157 -33

No files matched your search

@@ -4,6 +4,7 @@ import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste,
import { useI18n } from "vue-i18n";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { Transaction, StateEffect } from "@codemirror/state";
import type { EditorState, Text } from "@codemirror/state";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { search as cmSearch } from "@codemirror/search";
import EditorSearchPanel from "./EditorSearchPanel.vue";
@@ -259,6 +260,8 @@ let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = prop
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
let contextMenuDoc: Text | null = null;
let contextMenuDocText = "";
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@@ -632,6 +635,7 @@ let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
let semanticDiagnosticRunId = 0;
let pendingSemanticDiagnosticPreserveOutsideRanges = false;
let deferredCompletionTriggerTimer: ReturnType<typeof setTimeout> | null = null;
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let editorIsActive = true;
let tableReferenceDropListenerRegistered = false;
let imeCompositionActive = false;
@@ -678,6 +682,40 @@ function sqlCompletionDialectOptions() {
};
}
let editorCompletionContextCache: {
doc: Text;
editorState: EditorState | undefined;
position: number;
databaseType: DatabaseType | undefined;
dialect: string | undefined;
context: ReturnType<typeof getSqlCompletionContext>;
} | null = null;
function getEditorSqlCompletionContext(sql: string, position: number, editorState = view.value?.state): ReturnType<typeof getSqlCompletionContext> {
const dialect = sqlBehaviorDialect();
const doc = editorState?.doc;
if (doc && editorCompletionContextCache?.doc === doc && editorCompletionContextCache.editorState === editorState && editorCompletionContextCache.position === position && editorCompletionContextCache.databaseType === props.databaseType && editorCompletionContextCache.dialect === dialect) {
return editorCompletionContextCache.context;
}
const context = getSqlCompletionContext(sql, position, {
databaseType: props.databaseType,
dialect,
editorState,
});
if (doc) {
editorCompletionContextCache = {
doc,
editorState,
position,
databaseType: props.databaseType,
dialect,
context,
};
}
return context;
}
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
return shouldUseOracleSessionCompletionColumns({
databaseType: props.databaseType,
@@ -1079,17 +1117,49 @@ function insertLineBelow(currentView: EditorViewType): boolean {
return true;
}
function syncContextMenuState(currentView: EditorViewType, starPosition?: number, previewPosition?: number) {
function currentEditorDocText(currentView: EditorViewType): string {
const doc = currentView.state.doc;
if (contextMenuDoc !== doc) {
contextMenuDoc = doc;
contextMenuDocText = doc.toString();
}
return contextMenuDocText;
}
function syncEditorSelectionState(currentView: EditorViewType) {
selectedSql.value = selectedSqlFromView(currentView);
executableSql.value = executableSqlFromView(currentView);
executableSql.value = resolveExecutableSql(currentEditorDocText(currentView), selectedSql.value);
}
function syncContextMenuState(currentView: EditorViewType, starPosition?: number, previewPosition?: number) {
syncEditorSelectionState(currentView);
previewContextSql.value = resolvePreviewDmlCandidate(previewPosition);
selectStarExpansionTarget.value = selectStarExpansionTargetForView(currentView, starPosition);
}
function clearScheduledPreviewContextRefresh() {
if (previewContextRefreshTimer === null) return;
clearTimeout(previewContextRefreshTimer);
previewContextRefreshTimer = null;
}
function schedulePreviewContextRefresh(currentView: EditorViewType) {
clearScheduledPreviewContextRefresh();
const expectedDoc = currentView.state.doc;
const expectedSelection = currentView.state.selection.main;
previewContextRefreshTimer = setTimeout(() => {
previewContextRefreshTimer = null;
const currentSelection = currentView.state.selection.main;
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentSelection.from !== expectedSelection.from || currentSelection.to !== expectedSelection.to || !editorIsActive) return;
previewContextSql.value = resolvePreviewDmlCandidate();
emit("previewChangesAvailable", !!previewContextSql.value);
}, 120);
}
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const sql = currentView.state.doc.toString();
const sql = currentEditorDocText(currentView);
const selection = currentView.state.selection.main;
let cursor: number;
if (position != null) {
@@ -1149,7 +1219,7 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
aliasSql: source.aliasSpan ? sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
};
});
const legacyContext = getSqlCompletionContext(sql, cursor, sqlCompletionDialectOptions());
const legacyContext = getEditorSqlCompletionContext(sql, cursor);
const context = sqlCompletionContextFromSemantic(model, legacyContext);
if (context.statementKind !== "select" || !context.onStar) return null;
@@ -1166,6 +1236,7 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
clearScheduledPreviewContextRefresh();
// 预览按“右键点击处”解析当前语句(执行按光标处),右键处与光标一致时才直觉一致。
syncContextMenuState(currentView, pos ?? undefined, pos ?? undefined);
if (pos == null) {
@@ -1173,7 +1244,7 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
return;
}
const sql = currentView.state.doc.toString();
const sql = currentEditorDocText(currentView);
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({
connectionId: "",
@@ -1454,12 +1525,16 @@ function resolvePreviewDmlCandidate(position?: number): string {
const text = currentView.state.sliceDoc(selection.from, selection.to);
return looksLikeDmlStatement(text) ? text : "";
}
const doc = currentView.state.doc.toString();
const cursorPos = position ?? selection.head;
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
const cursorCandidate = candidates.find((item) => item.supportedKinds.includes("cursor"));
return cursorCandidate && looksLikeDmlStatement(cursorCandidate.sql) ? cursorCandidate.sql : "";
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos);
return cursorRange && looksLikeDmlStatement(cursorRange.sql) ? cursorRange.sql : "";
}
function emitModelValue(currentView: EditorViewType): string {
const sql = currentEditorDocText(currentView);
emit("update:modelValue", sql);
return sql;
}
// 「预览变更」:把当前 DML 语句改写为只读 SELECT,作为新结果标签执行(干跑,不写库)。
@@ -2416,10 +2491,6 @@ function selectedSqlFromView(currentView: EditorViewType): string {
return currentView.state.sliceDoc(selection.from, selection.to);
}
function executableSqlFromView(currentView: EditorViewType): string {
return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView));
}
function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecutionSnapshot {
const selection = currentView.state.selection.main;
return {
@@ -3050,7 +3121,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
};
}
const legacyContext = getSqlCompletionContext(sql, pos, sqlCompletionDialectOptions());
const legacyContext = getEditorSqlCompletionContext(sql, pos);
const context = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyContext) : legacyContext;
const candidates = qualifier ? context.referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : context.referencedTables;
@@ -3340,7 +3411,10 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
}
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType);
if (props.databaseType !== "sqlserver") {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
}
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges);
if (diagnosticRanges.length === 0) {
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
return;
@@ -4534,7 +4608,7 @@ async function provideSqlCompletions(context: CompletionContext) {
// require-prefix: only compute local facts (no positionalEligible).
if (mode === "require-prefix") {
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin,
@@ -4547,7 +4621,7 @@ async function provideSqlCompletions(context: CompletionContext) {
// positional: compute positionalEligible (lazy).
if (mode === "positional") {
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
@@ -4596,7 +4670,7 @@ async function provideSqlCompletions(context: CompletionContext) {
return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix);
}
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position);
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
@@ -4777,10 +4851,11 @@ function flushImeComposition() {
const currentView = view.value;
if (!currentView || !pendingImeModelEmit) return;
pendingImeModelEmit = false;
emit("update:modelValue", currentView.state.doc.toString());
emitModelValue(currentView);
invalidateSemanticDiagnosticsForDocumentChange();
scheduleSemanticDiagnostics();
syncContextMenuState(currentView);
syncEditorSelectionState(currentView);
schedulePreviewContextRefresh(currentView);
emit("selectionChange", selectedSqlFromView(currentView));
emit("cursorChange", currentView.state.selection.main.head);
latestSelection = readEditorSelection(currentView);
@@ -4810,7 +4885,7 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (mode === "require-prefix") {
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
@@ -4822,7 +4897,7 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
}
// positional
const ctx = sequenceLiteralContext ?? getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
@@ -4847,19 +4922,19 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
// Preserve old character-based checks for non-SQL providers.
if (!insertedText && removedText) {
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
if (insertedText.endsWith(".")) return true;
if (/[,(]$/.test(insertedText)) {
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
return !!completionContext.insertTable;
}
if (/\s$/.test(insertedText)) {
return shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
if (!/[\w$@]$/.test(insertedText)) return false;
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
const completionContext = getEditorSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
}
@@ -6305,7 +6380,7 @@ onMounted(async () => {
pendingImeModelEmit = true;
completionEpoch++;
} else {
emit("update:modelValue", update.state.doc.toString());
emitModelValue(update.view);
invalidateSemanticDiagnosticsForDocumentChange();
scheduleSemanticDiagnostics();
let insertedText = "";
@@ -6322,10 +6397,10 @@ onMounted(async () => {
}
}
if (update.selectionSet || update.docChanged) {
syncContextMenuState(update.view);
emit("selectionChange", selectedSqlFromView(update.view));
syncEditorSelectionState(update.view);
schedulePreviewContextRefresh(update.view);
emit("selectionChange", selectedSql.value);
emit("cursorChange", update.state.selection.main.head);
emit("previewChangesAvailable", !!previewContextSql.value);
latestSelection = readEditorSelection(update.view);
if (editorIsActive) emitEditorSelection(latestSelection);
}
@@ -6753,6 +6828,7 @@ function swapEditorDocument(doc: string) {
function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) {
const currentView = view.value;
if (!currentView) return;
clearScheduledPreviewContextRefresh();
if (prevTabId !== undefined) {
tabStateCache.set(prevTabId, currentView.state);
if (tabStateCache.size > MAX_CACHED_TAB_STATES) {
@@ -6771,6 +6847,9 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
// to the previous tab's latest position (#8378).
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 });
restoreEditorViewport(props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 });
clearScheduledPreviewContextRefresh();
syncContextMenuState(currentView);
emit("previewChangesAvailable", !!previewContextSql.value);
return;
}
// setState swaps doc, selection, undo history and all fields at once, but it
@@ -6793,6 +6872,9 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
invalidateSemanticDiagnosticsForDocumentChange();
restoreEditorSelection();
restoreEditorViewport();
clearScheduledPreviewContextRefresh();
syncContextMenuState(currentView);
emit("previewChangesAvailable", !!previewContextSql.value);
scheduleSemanticDiagnostics();
}
@@ -6803,7 +6885,7 @@ watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) =
if (props.autoFocus) restoreEditorFocus();
return;
}
if (val !== view.value.state.doc.toString()) {
if (val !== currentEditorDocText(view.value)) {
if (isEditorComposing(view.value)) return;
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
@@ -7049,6 +7131,7 @@ function pauseQueryEditorBackgroundWork() {
clearPendingCompletionEnter();
clearPendingCompletionTab();
clearDeferredCompletionTrigger();
clearScheduledPreviewContextRefresh();
executionViewportOwnership.reset();
editorIsActive = false;
clearScheduledSemanticDiagnostics();
@@ -7060,6 +7143,7 @@ function resumeQueryEditorBackgroundWork() {
editorIsActive = true;
registerTableReferenceDropListener();
scheduleSemanticDiagnostics();
if (view.value) schedulePreviewContextRefresh(view.value);
restoreEditorSelection();
restoreEditorFocus();
restoreEditorViewport();
@@ -9,6 +9,40 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
const source = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
describe("QueryEditor context menu lifecycle", () => {
it("keeps heavyweight context derivation out of the per-update path", () => {
const updateStart = source.indexOf("EditorView.updateListener.of((update) => {");
const updateEnd = source.indexOf("\n fontThemeComp.of(", updateStart);
const updateSource = source.slice(updateStart, updateEnd);
expect(updateStart).toBeGreaterThanOrEqual(0);
expect(updateEnd).toBeGreaterThan(updateStart);
expect(updateSource).toContain("syncEditorSelectionState(update.view);");
expect(updateSource).toContain("schedulePreviewContextRefresh(update.view);");
expect(updateSource).not.toContain("syncContextMenuState(update.view);");
});
it("does not suppress legitimate external model updates", () => {
expect(source).not.toContain("lastEmittedModelValue");
expect(source).toContain("if (val !== currentEditorDocText(view.value)) {");
});
it("resynchronizes context state after restoring a tab document", () => {
const activateStart = source.indexOf("function activateTabDocument");
const activateEnd = source.indexOf("\nwatch([() => props.tabId", activateStart);
const activateSource = source.slice(activateStart, activateEnd);
expect(activateStart).toBeGreaterThanOrEqual(0);
expect(activateEnd).toBeGreaterThan(activateStart);
expect(activateSource).toContain("syncContextMenuState(currentView);");
expect(activateSource).toContain('emit("previewChangesAvailable", !!previewContextSql.value);');
});
it("guards deferred preview refresh against stale selection ranges", () => {
expect(source).toContain("const expectedSelection = currentView.state.selection.main;");
expect(source).toContain("currentSelection.from !== expectedSelection.from");
expect(source).toContain("currentSelection.to !== expectedSelection.to");
});
it("resolves menu items after synchronizing the right-click target", () => {
const syncStart = source.indexOf("function syncContextMenuStateAtEvent");
const syncEnd = source.indexOf("\n}", syncStart);
@@ -28,4 +28,10 @@ describe("QueryEditor semantic highlighting while scrolling", () => {
expect(queryEditorSource).toContain("this.decorations = this.decorations.map(update.changes)");
expect(queryEditorSource).toContain("refreshSqlSemanticHighlightEffect.of(null)");
});
it("keeps preview and diagnostics on the shared statement-range cache", () => {
expect(queryEditorSource).toContain("executableStatementRangeCache = executableStatementRangeCacheForDoc");
expect(queryEditorSource).toContain('props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges');
expect(queryEditorSource).toContain("const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos)");
});
});
@@ -24,8 +24,8 @@ export interface SqlSemanticDiagnosticVisibleRange {
to: number;
}
export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRanges: readonly SqlSemanticDiagnosticVisibleRange[], databaseType?: DatabaseType): SqlTextRange[] {
const statements = databaseType === "sqlserver" ? sqlServerSemanticDiagnosticRanges(sql) : executableStatementRanges(sql, databaseType);
export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRanges: readonly SqlSemanticDiagnosticVisibleRange[], databaseType?: DatabaseType, cachedStatements?: readonly SqlTextRange[]): SqlTextRange[] {
const statements = databaseType === "sqlserver" ? sqlServerSemanticDiagnosticRanges(sql) : (cachedStatements ?? executableStatementRanges(sql, databaseType));
if (statements.length === 0 || visibleRanges.length === 0) return [];
const selected: SqlTextRange[] = [];