diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 9bdc1e73de..dbab1dfa8a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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 | null = null; let semanticDiagnosticRunId = 0; let pendingSemanticDiagnosticPreserveOutsideRanges = false; let deferredCompletionTriggerTimer: ReturnType | null = null; +let previewContextRefreshTimer: ReturnType | 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; +} | null = null; + +function getEditorSqlCompletionContext(sql: string, position: number, editorState = view.value?.state): ReturnType { + 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(); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts index 14aafffc45..ad18889a36 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts @@ -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); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts index c707d77bc3..eb7a1f444c 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts @@ -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)"); + }); }); diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index 32b459aec4..d73c7b4dd6 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -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[] = [];