fix(editor): bound analysis for large source documents

Closes #9208
This commit is contained in:
t8y2
2026-09-26 14:27:19 +00:00
parent 94b5856e8d
commit b4f503e1f1
7 changed files with 386 additions and 171 deletions
@@ -112,6 +112,7 @@ import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, suppor
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
@@ -437,6 +438,8 @@ interface EditorGestureEvent extends Event {
}
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue);
const fullEditorFeaturesEnabled = () => !largeDocumentMode;
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let editorIsActive = true;
@@ -447,6 +450,7 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
let postCompositionKeyGuardCleanup: (() => void) | null = null;
function runStatementGutterExtension(): import("@codemirror/state").Extension {
if (!fullEditorFeaturesEnabled()) return [];
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
}
@@ -776,7 +780,7 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) {
}
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const sql = currentEditorDocText(currentView);
const selection = currentView.state.selection.main;
@@ -863,6 +867,11 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
return;
}
if (!fullEditorFeaturesEnabled()) {
contextObjectTarget.value = null;
return;
}
const sql = currentEditorDocText(currentView);
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({
@@ -928,6 +937,7 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
function resolvePreviewDmlCandidate(position?: number): string {
if (!fullEditorFeaturesEnabled()) return "";
const currentView = view.value;
if (!currentView) return "";
const selection = currentView.state.selection.main;
@@ -1510,6 +1520,7 @@ const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnos
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
@@ -1739,7 +1750,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
view,
runtime: codeMirrorRuntime,
beforeLoad() {
hoverContent.initializeHighlighter();
if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter();
},
async prepare(modules) {
const initializedRuntime = modules.runtime;
@@ -1796,6 +1807,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
currentExecutableStatementRange,
executeSqlStatementFromGutter,
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
});
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
@@ -1829,21 +1841,23 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
currentStatementFrameExtension,
fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [],
highlightActiveLineGutter(),
highlightSpecialChars(),
initializedRuntime.historyResetComp.of(history()),
foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
}),
sqlBlockFoldService,
fullEditorFeaturesEnabled()
? foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
})
: [],
fullEditorFeaturesEnabled() ? sqlBlockFoldService : [],
drawSelection(),
editorClipboardLineEndingsExtension(EditorView),
trimmedSelectionLayer(),
@@ -1862,8 +1876,8 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
),
),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
fullEditorFeaturesEnabled() ? indentOnInput() : [],
fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [],
crosshairCursor(),
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
@@ -1873,12 +1887,14 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
}),
fullEditorFeaturesEnabled()
? createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
})
: [],
tooltips({ parent: tooltipParent }),
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
@@ -1905,15 +1921,17 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
return true;
}),
),
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [],
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
}),
fullEditorFeaturesEnabled()
? createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
})
: [],
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
sqlExtensions.buildResultSourceRangeExtension(),
Prec.highest(
@@ -2624,7 +2642,15 @@ defineExpose({
</script>
<template>
<div class="h-full w-full overflow-hidden relative" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
<div
class="h-full w-full overflow-hidden relative"
:data-large-document-mode="largeDocumentMode || undefined"
@wheel="recordExecutionViewportInteraction"
@pointerdown="recordExecutionViewportInteraction"
@gesturestart="onEditorGestureStart"
@gesturechange="onEditorGestureChange"
@gestureend="onEditorGestureEnd"
>
<QueryEditorContextMenu :get-state="getContextMenuState" :actions="contextMenuActions" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
<div
ref="editorRef"
@@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, ref, shallowRef, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { language } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
import type { QueryEditorProps } from "../queryEditorTypes";
const analysisProbe = vi.hoisted(() => ({
documentLengths: [] as number[],
failAtLength: Number.POSITIVE_INFINITY,
}));
vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/sql/executableStatementRangeCache")>();
return {
...actual,
executableStatementRangeCacheForDoc: (...args: Parameters<typeof actual.executableStatementRangeCacheForDoc>) => {
analysisProbe.documentLengths.push(args[1].length);
if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis");
return actual.executableStatementRangeCacheForDoc(...args);
},
};
});
import QueryEditor from "../QueryEditor.vue";
function oraclePackage(procedureCount: number): string {
const procedures = Array.from(
{ length: procedureCount },
(_, index) => ` PROCEDURE process_order_${index}(p_order_id NUMBER) IS
l_status VARCHAR2(30);
BEGIN
SELECT status INTO l_status FROM orders WHERE id = p_order_id;
IF l_status = 'PENDING' THEN
UPDATE orders SET status = 'PROCESSED' WHERE id = p_order_id;
END IF;
EXCEPTION
WHEN NO_DATA_FOUND THEN NULL;
END process_order_${index};
`,
).join("");
return `CREATE OR REPLACE PACKAGE BODY huge_pkg AS\n${procedures}END huge_pkg;\n/\n`;
}
const cleanups: Array<() => void> = [];
beforeEach(() => {
analysisProbe.documentLengths = [];
analysisProbe.failAtLength = Number.POSITIVE_INFINITY;
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
});
async function mountEditor(modelValue: string, overrides: Partial<QueryEditorProps> = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const host = document.createElement("div");
document.body.append(host);
const editor = shallowRef<InstanceType<typeof QueryEditor>>();
const source = ref(modelValue);
const onUpdateModelValue = vi.fn((value: string) => {
source.value = value;
});
const app: App = createApp({
render: () =>
h(QueryEditor, {
modelValue: source.value,
tabId: "oracle-source",
databaseType: "oracle",
dialect: "postgres",
autoFocus: false,
ref: editor,
"onUpdate:modelValue": onUpdateModelValue,
...overrides,
}),
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
return { editor: editor.value!, host, onUpdateModelValue, view };
}
describe("QueryEditor large document mode", () => {
it("keeps the full editor feature path for ordinary documents", async () => {
const source = oraclePackage(8);
const { host, view } = await mountEditor(source);
expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false);
expect(analysisProbe.documentLengths).toContain(source.length);
expect(view.state.facet(language)).not.toBeNull();
expect(view.state.doc.toString()).toBe(source);
});
it("keeps a realistically large package editable and searchable without full-document analysis", async () => {
const source = oraclePackage(4_000);
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
const { editor, host, onUpdateModelValue, view } = await mountEditor(source);
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLengths).not.toContain(source.length);
expect(view.state.facet(language)).toBeNull();
expect(view.state.doc.toString()).toBe(source);
expect(view.state.readOnly).toBe(false);
expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24));
expect(editor.openSearch()).toBe(true);
await nextTick();
expect(host.querySelector(".editor-search-panel input")).not.toBeNull();
view.dispatch({ changes: { from: source.length, insert: "-- edited\n" }, userEvent: "input" });
await nextTick();
expect(onUpdateModelValue).toHaveBeenLastCalledWith(`${source}-- edited\n`);
});
it("preserves read-only state when full-document analysis would fail", async () => {
const source = oraclePackage(4_000);
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
const { host, view } = await mountEditor(source, { readOnly: true });
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLengths).not.toContain(source.length);
expect(view.state.doc.toString()).toBe(source);
expect(view.state.readOnly).toBe(true);
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
});
});
@@ -50,10 +50,11 @@ interface QueryEditorSqlExtensionsOptions {
currentExecutableStatementRange: (view: EditorViewType) => SqlTextRange | null;
executeSqlStatementFromGutter: (view: EditorViewType, line: { from: number; to: number }, event: Event) => boolean;
signatureHelpWindowChars: number;
fullFeaturesEnabled: () => boolean;
}
export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensionsOptions) {
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter } = options;
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, fullFeaturesEnabled } = options;
const { EditorView, Decoration, StateField, StateEffect, GutterMarker, RangeSet, lineNumberMarkers, gutter, showTooltip, autocompletion, ViewPlugin, highlightingFor, syntaxTree, langSql, Prec, EditorState, ensureSyntaxTree, layer, RectangleMarker } = options.modules;
const { provideSqlCompletions } = options.completion;
const { renderBatchColumnSelectionActionMarker, renderBatchColumnSelectionCheckbox } = batchSelection;
@@ -73,6 +74,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
});
codeMirrorRuntime.buildSqlDiagnosticExtension = () => {
if (!fullFeaturesEnabled()) return [];
const diagnosticEffect = codeMirrorRuntime.setSqlDiagnosticsEffect;
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
@@ -241,6 +243,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
codeMirrorRuntime.setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
codeMirrorRuntime.buildRunStatementGutterExtension = () => {
if (!fullFeaturesEnabled()) return [];
const effectType = codeMirrorRuntime.setStatementExecutionMarkersEffect!;
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
@@ -290,8 +293,9 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
return field;
};
codeMirrorRuntime.buildSqlSignatureExtension = () =>
showTooltip.compute(["doc", "selection"], (currentState) => {
codeMirrorRuntime.buildSqlSignatureExtension = () => {
if (!fullFeaturesEnabled()) return [];
return showTooltip.compute(["doc", "selection"], (currentState) => {
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
@@ -306,9 +310,11 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
};
});
};
codeMirrorRuntime.buildSqlCompletionExtension = () =>
autocompletion({
codeMirrorRuntime.buildSqlCompletionExtension = () => {
if (!fullFeaturesEnabled()) return [];
return autocompletion({
activateOnTyping: true,
defaultKeymap: false,
selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen,
@@ -322,13 +328,16 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
],
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
});
};
const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });
codeMirrorRuntime.buildSqlLanguageExtension = () => [
langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
}),
fullFeaturesEnabled()
? langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
})
: [],
// Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the
// comment marker that toggleLineComment reads from language data.
Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])),
@@ -345,148 +354,151 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
const refreshSqlSemanticHighlightEffect = StateEffect.define<null>();
codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => [
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
private refreshTask = createDeferredEditorTask(() => {
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
private cachedDoc: import("@codemirror/state").Text | null = null;
private prewarmedDoc: import("@codemirror/state").Text | null = null;
private cachedSql = "";
private cachedDialectId = "";
private cachedDatabaseType: DatabaseType | undefined;
private cachedWindows: Array<{
from: number;
to: number;
spans: Array<{ start: number; end: number }>;
}> = [];
constructor(private currentView: import("@codemirror/view").EditorView) {
this.decorations = Decoration.none;
this.decorations = this.buildDecorations(currentView);
}
update(update: import("@codemirror/view").ViewUpdate) {
const refreshRequested = update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlSemanticHighlightEffect)));
if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
this.scheduleRefresh(update.view);
return;
}
if (refreshRequested) {
this.cancelRefresh();
this.decorations = this.buildDecorations(update.view);
} else if (update.viewportChanged) {
// Keep existing decorations while scrolling. Parse only after the
// viewport settles instead of blocking CodeMirror's layout update.
this.scheduleRefresh(update.view);
}
}
scheduleRefresh(currentView: import("@codemirror/view").EditorView) {
this.currentView = currentView;
this.refreshTask.schedule();
}
cancelRefresh() {
this.refreshTask.cancel();
}
destroy() {
this.cancelRefresh();
}
buildDecorations(currentView: import("@codemirror/view").EditorView) {
const dialectId = resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() });
const doc = currentView.state.doc;
if (this.cachedDoc !== doc || this.cachedDialectId !== dialectId || this.cachedDatabaseType !== props.databaseType) {
this.cachedDoc = doc;
this.cachedSql = doc.toString();
this.cachedDialectId = dialectId;
this.cachedDatabaseType = props.databaseType;
this.cachedWindows = [];
}
const sql = this.cachedSql;
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
const windows: Array<{
codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => {
if (!fullFeaturesEnabled()) return [];
return [
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
private refreshTask = createDeferredEditorTask(() => {
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
private cachedDoc: import("@codemirror/state").Text | null = null;
private prewarmedDoc: import("@codemirror/state").Text | null = null;
private cachedSql = "";
private cachedDialectId = "";
private cachedDatabaseType: DatabaseType | undefined;
private cachedWindows: Array<{
from: number;
to: number;
spans: Array<{ start: number; end: number }>;
}> = [];
const pendingWindows: Array<{ from: number; to: number }> = [];
const rangesToHighlight = shouldPrewarmFullDocument ? [{ from: 0, to: sql.length }] : currentView.visibleRanges;
for (const visibleRange of rangesToHighlight) {
const cached = this.cachedWindows.find((candidate) => candidate.from <= visibleRange.from && candidate.to >= visibleRange.to);
if (cached) {
if (!windows.includes(cached)) windows.push(cached);
continue;
}
const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to, dialectId);
const cachedWindow = this.cachedWindows.find((candidate) => candidate.from <= next.from && candidate.to >= next.to);
if (cachedWindow) {
if (!windows.includes(cachedWindow)) windows.push(cachedWindow);
continue;
}
const previous = pendingWindows[pendingWindows.length - 1];
if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to);
else pendingWindows.push({ ...next });
constructor(private currentView: import("@codemirror/view").EditorView) {
this.decorations = Decoration.none;
this.decorations = this.buildDecorations(currentView);
}
if (pendingWindows.length > 0) {
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
if (!tree) {
// The Lezer parse has not reached the pending windows yet (long
// documents). Keep the decorations that are still valid and let
// the deferred refresh rebuild them once parsing catches up —
// returning an empty set here wiped table-name colors after
// scrolling stopped or when a freshly mounted editor (tab
// switch) had no later viewport change to trigger a rebuild.
this.scheduleRefresh(currentView);
return this.decorations;
update(update: import("@codemirror/view").ViewUpdate) {
const refreshRequested = update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlSemanticHighlightEffect)));
if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
this.scheduleRefresh(update.view);
return;
}
if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;
for (const window of pendingWindows) {
const entry = {
...window,
spans: sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
}),
};
this.cachedWindows.push(entry);
windows.push(entry);
}
if (this.cachedWindows.length > MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS) {
this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS);
if (refreshRequested) {
this.cancelRefresh();
this.decorations = this.buildDecorations(update.view);
} else if (update.viewportChanged) {
// Keep existing decorations while scrolling. Parse only after the
// viewport settles instead of blocking CodeMirror's layout update.
this.scheduleRefresh(update.view);
}
}
const ranges = windows.flatMap((window) => window.spans);
return Decoration.set(
ranges.map((range) =>
Decoration.mark({
class: "cm-sql-table-name",
attributes: {
"data-sql-token": "table",
},
}).range(range.start, range.end),
),
true,
);
}
},
{ decorations: (value) => value.decorations },
),
sqlSemanticHighlightTheme(EditorView),
shellLineCommentTheme(EditorView),
];
scheduleRefresh(currentView: import("@codemirror/view").EditorView) {
this.currentView = currentView;
this.refreshTask.schedule();
}
cancelRefresh() {
this.refreshTask.cancel();
}
destroy() {
this.cancelRefresh();
}
buildDecorations(currentView: import("@codemirror/view").EditorView) {
const dialectId = resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() });
const doc = currentView.state.doc;
if (this.cachedDoc !== doc || this.cachedDialectId !== dialectId || this.cachedDatabaseType !== props.databaseType) {
this.cachedDoc = doc;
this.cachedSql = doc.toString();
this.cachedDialectId = dialectId;
this.cachedDatabaseType = props.databaseType;
this.cachedWindows = [];
}
const sql = this.cachedSql;
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
const windows: Array<{
from: number;
to: number;
spans: Array<{ start: number; end: number }>;
}> = [];
const pendingWindows: Array<{ from: number; to: number }> = [];
const rangesToHighlight = shouldPrewarmFullDocument ? [{ from: 0, to: sql.length }] : currentView.visibleRanges;
for (const visibleRange of rangesToHighlight) {
const cached = this.cachedWindows.find((candidate) => candidate.from <= visibleRange.from && candidate.to >= visibleRange.to);
if (cached) {
if (!windows.includes(cached)) windows.push(cached);
continue;
}
const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to, dialectId);
const cachedWindow = this.cachedWindows.find((candidate) => candidate.from <= next.from && candidate.to >= next.to);
if (cachedWindow) {
if (!windows.includes(cachedWindow)) windows.push(cachedWindow);
continue;
}
const previous = pendingWindows[pendingWindows.length - 1];
if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to);
else pendingWindows.push({ ...next });
}
if (pendingWindows.length > 0) {
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
if (!tree) {
// The Lezer parse has not reached the pending windows yet (long
// documents). Keep the decorations that are still valid and let
// the deferred refresh rebuild them once parsing catches up —
// returning an empty set here wiped table-name colors after
// scrolling stopped or when a freshly mounted editor (tab
// switch) had no later viewport change to trigger a rebuild.
this.scheduleRefresh(currentView);
return this.decorations;
}
if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;
for (const window of pendingWindows) {
const entry = {
...window,
spans: sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
}),
};
this.cachedWindows.push(entry);
windows.push(entry);
}
if (this.cachedWindows.length > MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS) {
this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS);
}
}
const ranges = windows.flatMap((window) => window.spans);
return Decoration.set(
ranges.map((range) =>
Decoration.mark({
class: "cm-sql-table-name",
attributes: {
"data-sql-token": "table",
},
}).range(range.start, range.end),
),
true,
);
}
},
{ decorations: (value) => value.decorations },
),
sqlSemanticHighlightTheme(EditorView),
shellLineCommentTheme(EditorView),
];
};
function createViewDecorations() {
const currentStatementFrameExtension = currentStatementFrameLayer(
@@ -69,6 +69,7 @@ interface QueryEditorDiagnosticsOptions {
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
unknownObjectHighlightEnabled: boolean;
fullFeaturesEnabled?: () => boolean;
runtime: QueryEditorDiagnosticsRuntime;
metadata: QueryEditorDiagnosticMetadata;
}
@@ -200,7 +201,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
}
function shouldSkipSqlSemanticDiagnostics() {
return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
return options.fullFeaturesEnabled?.() === false || props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
describe("query editor large document policy", () => {
it("keeps the default feature set through the configured budget", () => {
expect(shouldUseQueryEditorLargeDocumentMode("x".repeat(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH))).toBe(false);
});
it("switches to bounded features above the configured budget", () => {
expect(shouldUseQueryEditorLargeDocumentMode("x".repeat(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1))).toBe(true);
});
});
@@ -0,0 +1,5 @@
export const QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH = 1024 * 1024;
export function shouldUseQueryEditorLargeDocumentMode(document: string): boolean {
return document.length > QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH;
}
@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
// issue #9035:源码 tab 必须先出现再加载。这批用例锁定 pending 占位 tab 的
// 行为契约:立即出现、原地填充、就地重试、加载中被关闭则丢弃、重复点击去重。
@@ -37,7 +38,7 @@ const CONNECTION_ID = "ora-1";
const DATABASE = "ORCL";
const SCHEMA = "APP";
const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE"; signature?: string }) => ({
const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY"; signature?: string }) => ({
connectionId: CONNECTION_ID,
database: DATABASE,
title: `Source - ${request.name}`,
@@ -104,6 +105,24 @@ describe("queryStore pending object source tab", () => {
expect(tab.objectSource).toMatchObject({ schema: SCHEMA, name: "v_orders", objectType: "VIEW" });
});
it("preserves every character of a realistically large package source", async () => {
const procedures = Array.from({ length: 25_000 }, (_, index) => `PROCEDURE p_${index} IS BEGIN NULL; END p_${index};\n`).join("");
const source = `CREATE OR REPLACE PACKAGE BODY huge_pkg AS\n${procedures}END huge_pkg;\n/`;
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
mocks.getObjectSource.mockResolvedValue(objectSource(source));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.openObjectSourceTabPending(pendingOptions({ name: "huge_pkg", objectType: "PACKAGE_BODY" }));
await settle();
const tab = store.tabs.find((candidate) => candidate.id === id)!;
expect(tab.sql).toBe(source);
expect(tab.sql.length).toBe(source.length);
expect(tab.objectSource).toMatchObject({ schema: SCHEMA, name: "huge_pkg", objectType: "PACKAGE_BODY" });
expect(tab.sourceLoad).toBeUndefined();
});
it("keeps the resolved (fallback) object type on the tab, not the requested one", async () => {
// 请求 PROCEDURE,后端返回空 → routine fallback 回落到 FUNCTION
mocks.getObjectSource.mockImplementation(async (_connectionId, _database, _schema, _name, objectType: string) => objectSource(objectType === "FUNCTION" ? "CREATE OR REPLACE FUNCTION app.foo RETURN NUMBER IS BEGIN RETURN 1; END;" : ""));