mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
@@ -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"
|
||||
|
||||
+140
@@ -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;" : ""));
|
||||
|
||||
Reference in New Issue
Block a user