fix(editor): split query editor into focused components

This commit is contained in:
t8y2
2026-09-23 16:06:00 +08:00
parent 6869c7fd6e
commit 121bb33b2e
63 changed files with 11857 additions and 7011 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Columns3, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { canFormatSqlForDatabaseType } from "@/lib/sql/sqlFormatter";
import { supportsQueryEditorBlockComments } from "@/lib/database/databaseFeatureSupport";
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import { queryContextObjectActions, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import type { QueryEditorProps } from "./queryEditorTypes";
export interface QueryEditorContextMenuState {
readOnly: QueryEditorProps["readOnly"];
hideExecutionControls: QueryEditorProps["hideExecutionControls"];
databaseType: QueryEditorProps["databaseType"];
selectedSql: string;
executableSql: string;
previewContextSql: string;
contextObjectTarget: SqlObjectNavigationTarget | null;
shortcuts: ShortcutSettings;
expandSelectStar: (() => void) | undefined;
}
export interface QueryEditorContextMenuActions {
executeFromContextMenu: () => void;
executeInNewResultTabFromContextMenu: () => void;
requestPreviewChanges: (sql?: string) => void;
exportQueryFromContextMenu: (format: "csv" | "xlsx" | "txt") => void;
toggleCommentFromContextMenu: () => void;
toggleBlockCommentFromContextMenu: () => void;
formatCurrentSql: () => void;
compressCurrentSql: () => void;
copySelectedSqlFromContextMenu: () => void;
copySelectedSqlAsRichTextFromContextMenu: () => void;
cutSelectedSqlFromContextMenu: () => void;
pasteClipboardSqlFromContextMenu: () => void;
convertSelectedSqlCase: (mode: "upper" | "lower") => void;
convertSelectedNamingStyle: () => void;
openDelimitedListDialog: () => void;
addNextSelectionOccurrenceFromContextMenu: () => void;
selectAllSelectionOccurrencesFromContextMenu: () => void;
openFindReplaceFromContextMenu: () => void;
deleteEmptyLines: () => void;
selectAllSqlFromContextMenu: () => void;
emitContextObjectAction: (action: QueryContextObjectAction) => void;
openCodeSnapshot: () => void;
sendSelectionToAi: () => void;
}
const props = defineProps<{ getState: () => QueryEditorContextMenuState; actions: QueryEditorContextMenuActions }>();
const emit = defineEmits<{ close: [] }>();
defineSlots<{ default(props: { onContextMenu: (event: MouseEvent) => void; isOpen: boolean }): unknown }>();
const { t } = useI18n();
function contextObjectMenuItem(action: QueryContextObjectAction, target: SqlObjectNavigationTarget | null): ContextMenuItem {
const disabled = !target;
switch (action) {
case "view-data":
return {
label: t("contextMenu.viewData"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: Table2,
};
case "peek-table-structure":
return {
label: t("contextMenu.peekStructure"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: Columns3,
};
case "edit-table-structure":
return {
label: t("contextMenu.editStructure"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: PencilRuler,
};
case "edit-view":
return {
label: t("contextMenu.editView"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: Pencil,
};
case "view-source":
return {
label: t("contextMenu.viewSource"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: Code2,
};
case "view-ddl":
return {
label: t("contextMenu.viewDdl"),
action: () => props.actions.emitContextObjectAction(action),
disabled,
icon: FileCode,
};
}
}
const contextMenuItems = computed<ContextMenuItem[]>(() => {
const state = props.getState();
const actions = props.actions;
const shortcuts = normalizeShortcutSettings(state.shortcuts);
const canCopySelectedSql = state.selectedSql.length > 0;
const canExecuteContextSql = state.executableSql.trim().length > 0;
const executeContextMenuLabel = t(state.selectedSql.trim().length > 0 ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent");
// The menu closes before running its action, so retain this right-click's
// resolved target instead of reading state after the close handler runs.
const expandSelectStar = state.expandSelectStar;
return [
...(state.hideExecutionControls
? []
: [
{
label: executeContextMenuLabel,
action: actions.executeFromContextMenu,
disabled: !canExecuteContextSql,
icon: Play,
shortcut: shortcuts.executeSql,
},
{
label: t("settings.shortcutExecuteSqlInNewResultTab"),
action: actions.executeInNewResultTabFromContextMenu,
disabled: !canExecuteContextSql,
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
{
label: t("editor.previewChanges"),
action: () => void actions.requestPreviewChanges(props.getState().previewContextSql),
disabled: !state.previewContextSql,
icon: Eye,
},
{
label: t("editor.contextMenu.export"),
icon: Download,
disabled: !canExecuteContextSql,
children: [
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "CSV" }), action: () => actions.exportQueryFromContextMenu("csv") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "XLSX" }), action: () => actions.exportQueryFromContextMenu("xlsx") },
{ label: t("editor.contextMenu.exportQueryResultTo", { format: "TXT" }), action: () => actions.exportQueryFromContextMenu("txt") },
],
},
]),
...queryContextObjectActions(state.contextObjectTarget?.type).map((action) => contextObjectMenuItem(action, state.contextObjectTarget)),
{
label: t("editor.contextMenu.expandSelectStar"),
action: () => expandSelectStar?.(),
disabled: !expandSelectStar,
icon: Table2,
shortcut: shortcuts.expandSelectStar,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.commentSelection"),
action: actions.toggleCommentFromContextMenu,
disabled: state.readOnly || !canCopySelectedSql,
icon: MessageSquareText,
shortcut: shortcuts.toggleLineComment,
},
{
label: t("editor.contextMenu.blockCommentSelection"),
action: actions.toggleBlockCommentFromContextMenu,
disabled: state.readOnly || !canCopySelectedSql || !supportsQueryEditorBlockComments(state.databaseType),
icon: MessageSquareText,
shortcut: shortcuts.toggleBlockComment,
},
{
label: t("editor.contextMenu.formatSelectionSql"),
action: () => void actions.formatCurrentSql(),
disabled: state.readOnly || !canCopySelectedSql || !canFormatSqlForDatabaseType(state.databaseType),
icon: AlignLeft,
shortcut: shortcuts.formatSql,
},
{
label: t("editor.contextMenu.compressSelectionSql"),
action: actions.compressCurrentSql,
disabled: state.readOnly || !canCopySelectedSql,
icon: Minimize2,
},
{
label: t("editor.contextMenu.copySelection"),
action: actions.copySelectedSqlFromContextMenu,
disabled: !canCopySelectedSql,
icon: Copy,
shortcut: "Mod+C",
},
{
label: t("editor.contextMenu.copySelectionAsRichText"),
action: actions.copySelectedSqlAsRichTextFromContextMenu,
disabled: !canCopySelectedSql,
icon: Highlighter,
},
{
label: t("editor.contextMenu.screenshotSelection"),
action: actions.openCodeSnapshot,
disabled: !canCopySelectedSql,
icon: Camera,
},
{
label: t("editor.contextMenu.cutSelection"),
action: actions.cutSelectedSqlFromContextMenu,
disabled: !canCopySelectedSql || state.readOnly,
icon: Scissors,
shortcut: "Mod+X",
},
{
label: t("editor.contextMenu.pasteFromClipboard"),
action: actions.pasteClipboardSqlFromContextMenu,
disabled: state.readOnly,
icon: ClipboardPaste,
shortcut: "Mod+V",
},
{
label: t("editor.contextMenu.sendToAi"),
action: () => {
actions.sendSelectionToAi();
},
disabled: !canCopySelectedSql,
icon: Sparkles,
shortcut: shortcuts.sendSelectionToAi,
},
{
label: t("editor.contextMenu.uppercaseSelection"),
action: () => actions.convertSelectedSqlCase("upper"),
disabled: !canCopySelectedSql,
icon: CaseUpper,
shortcut: shortcuts.uppercaseSelection,
},
{
label: t("editor.contextMenu.lowercaseSelection"),
action: () => actions.convertSelectedSqlCase("lower"),
disabled: !canCopySelectedSql,
icon: CaseLower,
shortcut: shortcuts.lowercaseSelection,
},
{
label: t("editor.contextMenu.convertNamingStyle"),
action: actions.convertSelectedNamingStyle,
disabled: !canCopySelectedSql,
icon: CaseSensitive,
shortcut: shortcuts.convertNamingStyle,
},
{
label: t("editor.contextMenu.delimitedList"),
action: actions.openDelimitedListDialog,
disabled: state.readOnly || !canCopySelectedSql,
icon: List,
},
{
label: t("editor.contextMenu.addNextSelectionOccurrence"),
action: actions.addNextSelectionOccurrenceFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.addNextSelectionOccurrence,
},
{
label: t("editor.contextMenu.selectAllSelectionOccurrences"),
action: actions.selectAllSelectionOccurrencesFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.selectAllSelectionOccurrences,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.findReplace"),
action: actions.openFindReplaceFromContextMenu,
icon: Search,
shortcut: shortcuts.find,
},
{
label: t("editor.contextMenu.deleteEmptyLines"),
action: actions.deleteEmptyLines,
disabled: state.readOnly,
icon: Trash2,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.selectAll"),
action: actions.selectAllSqlFromContextMenu,
icon: TextSelect,
shortcut: shortcuts.selectAll,
},
];
});
function currentContextMenuItems(): ContextMenuItem[] {
return contextMenuItems.value;
}
</script>
<template>
<CustomContextMenu :items="currentContextMenuItems" @close="emit('close')" v-slot="slotProps">
<slot v-bind="slotProps" />
</CustomContextMenu>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { IntentionAction } from "@/lib/editor/sqlIntentionActions";
import type { IntentionPopupState } from "./useQueryEditorIntentions";
defineProps<{ state: IntentionPopupState | null }>();
const emit = defineEmits<{ close: []; confirm: [action: IntentionAction]; select: [index: number] }>();
const { t } = useI18n();
function getIntentionActionLabel(kind: string): string {
switch (kind) {
case "expand_wildcard":
return t("intentionExpandWildcard");
case "qualify_identifier":
return t("intentionQualifyIdentifier");
case "unqualify_identifier":
return t("intentionUnqualifyIdentifier");
case "batch_qualify_identifiers":
return t("intentionBatchQualifyIdentifiers");
default:
return kind;
}
}
</script>
<template>
<Teleport to="body">
<div v-if="state?.visible" class="intention-popup-overlay" @click.self="emit('close')">
<div class="intention-popup" :style="{ left: state.position.x + 'px', top: state.position.y + 'px' }">
<div v-for="(action, index) in state.actions" :key="index" class="intention-popup-item" :class="{ 'intention-popup-item--active': index === state.selectedIndex }" @click="emit('confirm', action)" @mouseenter="emit('select', index)">
<span class="intention-popup-item__icon">💡</span>
<span class="intention-popup-item__label">{{ action.label || getIntentionActionLabel(action.kind) }}</span>
</div>
</div>
</div>
</Teleport>
</template>
<style>
.intention-popup-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: transparent;
}
.intention-popup {
position: fixed;
z-index: 10000;
min-width: 220px;
max-width: 360px;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 4px 0;
overflow: hidden;
}
.intention-popup-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
color: var(--foreground);
font-size: 13px;
line-height: 1.5;
transition: background 0.1s;
}
.intention-popup-item:hover,
.intention-popup-item--active {
background: var(--accent);
color: var(--accent-foreground);
}
.intention-popup-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.intention-popup-item__label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
</style>
@@ -0,0 +1,397 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive, shallowRef } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { toggleLineComment, undo } from "@codemirror/commands";
import { closeCompletion, completionStatus, currentCompletions, selectedCompletionIndex, startCompletion } from "@codemirror/autocomplete";
import { afterEach, describe, expect, it, vi } from "vitest";
import QueryEditor from "../QueryEditor.vue";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { createTableReferenceDropEvent, createTableReferenceHoverEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import type { QueryEditorProps } from "../queryEditorTypes";
import { focusedQueryEditorView, queryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: vi.fn().mockResolvedValue(undefined),
readTextFromClipboard: vi.fn().mockResolvedValue("alice\nbob"),
}));
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
setActiveTableReferencePayload(null);
vi.restoreAllMocks();
});
async function mountEditor(overrides: Partial<QueryEditorProps> = {}) {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
settingsStore.editorSettings.showExecutionTargetPicker = false;
settingsStore.editorSettings.executeMode = "current";
settingsStore.editorSettings.executeAllOnBlankLine = false;
const props = reactive<QueryEditorProps>({ modelValue: "SELECT 1;\nSELECT 2;", tabId: "split-a", databaseType: "mysql", dialect: "mysql", autoFocus: false, ...overrides });
const editor = shallowRef<InstanceType<typeof QueryEditor>>();
const onExecute = vi.fn();
const onExecuteInNewResultTab = vi.fn();
const onClickTable = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () =>
h(QueryEditor, {
...props,
ref: editor,
onExecute,
onExecuteInNewResultTab,
onClickTable,
"onUpdate:modelValue": (value: string) => {
props.modelValue = value;
},
}),
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
let mounted = true;
const unmount = () => {
if (!mounted) return;
mounted = false;
app.unmount();
host.remove();
};
cleanups.push(unmount);
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!, props, view, host, settingsStore, connectionStore, onExecute, onExecuteInNewResultTab, onClickTable, unmount };
}
function keydown(target: HTMLElement, key: string) {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
describe("QueryEditor component split integration", () => {
it("preserves upstream structure-peek focus and insertion context across updates and unmount", async () => {
const { view, props, unmount } = await mountEditor({ connectionId: "peek-a", database: "demo", schema: "public" });
expect(queryEditorInsertContext(view)).toEqual({ connectionId: "peek-a", database: "demo", schema: "public", databaseType: "mysql" });
view.focus();
await nextTick();
const panel = document.createElement("div");
panel.setAttribute("data-structure-peek-panel", "");
document.body.append(panel);
cleanups.push(() => panel.remove());
await vi.waitFor(() => expect(focusedQueryEditorView(panel)).toBe(view));
props.connectionId = "peek-b";
props.database = "updated";
props.schema = "reporting";
await nextTick();
expect(queryEditorInsertContext(view)).toEqual({ connectionId: "peek-b", database: "updated", schema: "reporting", databaseType: "mysql" });
unmount();
expect(queryEditorInsertContext(view)).toBeUndefined();
expect(focusedQueryEditorView(panel)).toBeNull();
});
it("preserves the public editor API and the direct CodeMirror host", async () => {
const { editor, host, view } = await mountEditor();
for (const method of [
"openSearch",
"openReplace",
"scrollCursorIntoView",
"beginExecutionViewportTracking",
"acceptGutterExecutionViewport",
"cancelGutterExecutionViewport",
"shouldBlockExecutionShortcut",
"requestExecute",
"requestExecuteInNewResultTab",
"requestPreviewChanges",
"captureExecutionSnapshot",
"pasteClipboardAsSqlInCondition",
"focusStatementRange",
"focusErrorPosition",
"previewStatementRange",
"refreshCompletionCache",
]) {
expect(typeof editor[method as keyof typeof editor], method).toBe("function");
}
expect(host.querySelector("[data-query-editor-root] > .cm-editor")).toBe(view.dom);
expect(host.querySelectorAll(".cm-editor")).toHaveLength(1);
expect(editor.openSearch()).toBe(true);
await nextTick();
expect(host.querySelector(".cm-editor")).toBe(view.dom);
});
it.each(["current", "all"] as const)("keeps selection priority in %s mode and immutable toolbar snapshots", async (executeMode) => {
const { editor, props, view, settingsStore, onExecute } = await mountEditor();
settingsStore.editorSettings.executeMode = executeMode;
view.dispatch({ selection: EditorSelection.range(10, 18) });
const snapshot = editor.captureExecutionSnapshot();
expect(snapshot).toEqual({ fullSql: props.modelValue, selectedSql: "SELECT 2", cursorPos: 18, selectionFrom: 10, selectionTo: 18 });
expect(editor.requestExecute()).toBe(true);
expect(onExecute).toHaveBeenLastCalledWith(snapshot);
view.dispatch({ selection: { anchor: 0 } });
expect(snapshot?.selectedSql).toBe("SELECT 2");
expect(editor.captureExecutionSnapshot()).toBeUndefined();
});
it.each(["current", "all"] as const)("keeps %s execution ranges without a manual selection", async (executeMode) => {
const { editor, props, view, settingsStore, onExecute } = await mountEditor();
settingsStore.editorSettings.executeMode = executeMode;
view.dispatch({ selection: { anchor: 12 } });
expect(editor.requestExecute({ bypassPicker: true })).toBe(true);
const source = onExecute.mock.calls[0][0];
expect(source.fullSql).toBe(props.modelValue);
expect(source.cursorPos).toBe(12);
expect(source.selectedSql).toBe(executeMode === "current" ? "SELECT 2" : props.modelValue);
expect(source.selectionFrom).toBe(executeMode === "current" ? 10 : 0);
expect(source.selectionTo).toBe(executeMode === "current" ? 18 : props.modelValue.length);
});
it("keeps the new-result-tab shortcut independent of the optional picker", async () => {
const { editor, host, settingsStore, onExecute, onExecuteInNewResultTab } = await mountEditor();
settingsStore.editorSettings.showExecutionTargetPicker = true;
expect(editor.requestExecuteInNewResultTab()).toBe(true);
await nextTick();
expect(host.querySelector('[role="listbox"]')).toBeNull();
expect(onExecute).not.toHaveBeenCalled();
expect(onExecuteInNewResultTab).toHaveBeenCalledTimes(1);
expect(onExecuteInNewResultTab.mock.calls[0][0].selectedSql).toBe("SELECT 1");
});
it("opens, navigates, cancels and confirms the execution picker without replacing the editor", async () => {
const { editor, host, view, props, settingsStore, onExecute } = await mountEditor();
settingsStore.editorSettings.showExecutionTargetPicker = true;
vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 10, right: 12, top: 20, bottom: 40 });
vi.spyOn(host.querySelector("[data-query-editor-root]")!, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 800, 600));
expect(editor.requestExecute()).toBe(true);
await nextTick();
let picker = host.querySelector<HTMLElement>('[role="listbox"]')!;
expect(picker).not.toBeNull();
expect(picker.style.left).toBe("400px");
expect(onExecute).not.toHaveBeenCalled();
keydown(picker, "ArrowDown");
await nextTick();
expect(picker.querySelectorAll('[role="option"]')[1].getAttribute("aria-selected")).toBe("true");
keydown(picker, "Escape");
await nextTick();
expect(host.querySelector('[role="listbox"]')).toBeNull();
expect(onExecute).not.toHaveBeenCalled();
expect(view.hasFocus).toBe(true);
editor.requestExecute();
await nextTick();
picker = host.querySelector<HTMLElement>('[role="listbox"]')!;
keydown(picker, "ArrowDown");
await nextTick();
keydown(picker, "Enter");
await nextTick();
expect(onExecute).toHaveBeenCalledTimes(1);
expect(onExecute.mock.calls[0][0].selectedSql).toBe(props.modelValue);
expect(host.querySelector('[role="listbox"]')).toBeNull();
expect(host.querySelector(".cm-editor")).toBe(view.dom);
});
it.each(["redis", "mongodb", "elasticsearch"] as const)("preserves the non-SQL %s execution path", async (databaseType) => {
const { editor, props, host, settingsStore, onExecute } = await mountEditor({ databaseType, modelValue: databaseType === "redis" ? "GET key" : "{}" });
settingsStore.editorSettings.showExecutionTargetPicker = true;
expect(editor.requestExecute()).toBe(true);
await nextTick();
expect(onExecute).toHaveBeenCalledWith({ fullSql: props.modelValue, selectedSql: databaseType === "mongodb" ? "" : props.modelValue, cursorPos: 0, selectionFrom: 0, selectionTo: databaseType === "mongodb" ? 0 : props.modelValue.length });
expect(host.querySelector('[role="listbox"]')).toBeNull();
expect(await editor.pasteClipboardAsSqlInCondition()).toBe(false);
});
it.each([false, true])("preserves the blank-line execution fallback setting (%s)", async (fallback) => {
const { editor, view, props, settingsStore, onExecute } = await mountEditor({ modelValue: "SELECT 1;\n\nSELECT 2;" });
settingsStore.editorSettings.executeAllOnBlankLine = fallback;
view.dispatch({ selection: { anchor: 10 } });
expect(editor.requestExecute()).toBe(true);
expect(onExecute).toHaveBeenCalledTimes(fallback ? 1 : 0);
if (fallback) expect(onExecute.mock.calls[0][0].selectedSql).toBe(props.modelValue);
});
it("reads current read-only props for extracted clipboard actions and keeps undo history", async () => {
const { editor, props, view } = await mountEditor({ modelValue: "" });
expect(await editor.pasteClipboardAsSqlInCondition()).toBe(true);
expect(view.state.doc.toString()).toContain("'alice'");
expect(view.state.doc.toString()).toContain("'bob'");
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe("");
props.readOnly = true;
await nextTick();
expect(await editor.pasteClipboardAsSqlInCondition()).toBe(false);
expect(view.state.doc.toString()).toBe("");
});
it("preserves per-tab undo isolation, external model updates and the editor instance", async () => {
const { editor, props, view, host } = await mountEditor({ modelValue: "SELECT 1;" });
view.dispatch({ changes: { from: 7, to: 8, insert: "10" }, userEvent: "input" });
await nextTick();
props.tabId = "split-b";
props.modelValue = "SELECT 2;";
await nextTick();
expect(view.state.doc.toString()).toBe("SELECT 2;");
expect(undo(view)).toBe(false);
props.tabId = "split-a";
props.modelValue = "SELECT 10;";
await nextTick();
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe("SELECT 1;");
props.modelValue = "SELECT 3;";
await nextTick();
expect(view.state.doc.toString()).toBe("SELECT 3;");
expect(editor.captureExecutionSnapshot()).toBeUndefined();
expect(host.querySelector(".cm-editor")).toBe(view.dom);
});
it("connects manual completion to the provider without preselecting a candidate", async () => {
const { view, settingsStore } = await mountEditor({ modelValue: "SEL", initialSelection: { anchor: 3, head: 3 }, connectionId: "completion-test" });
settingsStore.editorSettings.completionTriggerMode = "manual";
settingsStore.editorSettings.selectFirstCompletionOnOpen = false;
await nextTick();
expect(startCompletion(view)).toBe(true);
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
expect(currentCompletions(view.state).map((option) => option.label)).toContain("SELECT");
expect(selectedCompletionIndex(view.state)).toBeNull();
expect(view.state.doc.toString()).toBe("SEL");
expect(closeCompletion(view)).toBe(true);
});
it("reconfigures language and wrapping extensions without replacing the view", async () => {
const { view, props, host } = await mountEditor({ modelValue: "SELECT 1" });
expect(toggleLineComment(view)).toBe(true);
expect(view.state.doc.toString()).toBe("-- SELECT 1");
props.databaseType = "mongodb";
props.modelValue = "db.users.find({})";
await nextTick();
expect(toggleLineComment(view)).toBe(true);
expect(view.state.doc.toString()).toBe("// db.users.find({})");
props.forceWordWrap = true;
await nextTick();
expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(true);
props.forceWordWrap = false;
await nextTick();
expect(view.contentDOM.classList.contains("cm-lineWrapping")).toBe(false);
expect(host.querySelector(".cm-editor")).toBe(view.dom);
});
it("keeps result-source decorations connected to public navigation methods", async () => {
const { view, host, editor } = await mountEditor();
editor.previewStatementRange({ from: 10, to: 19 });
expect(view.state.selection.main.anchor).toBe(10);
expect(host.querySelector(".cm-db-result-source-highlight")?.textContent).toBe("SELECT 2;");
editor.previewStatementRange(null);
expect(host.querySelector(".cm-db-result-source-highlight")).toBeNull();
expect(view.state.doc.toString()).toBe("SELECT 1;\nSELECT 2;");
});
it("isolates extension reconfiguration and disposal between editor instances", async () => {
const first = await mountEditor({ modelValue: "SELECT 1;", tabId: "independent-a" });
const second = await mountEditor({ modelValue: "SELECT 2;", tabId: "independent-b" });
first.props.forceWordWrap = true;
await nextTick();
expect(first.view.contentDOM.classList.contains("cm-lineWrapping")).toBe(true);
expect(second.view.contentDOM.classList.contains("cm-lineWrapping")).toBe(false);
first.unmount();
second.view.dispatch({ changes: { from: 7, to: 8, insert: "3" } });
expect(second.view.state.doc.toString()).toBe("SELECT 3;");
expect(second.host.querySelectorAll(".cm-editor")).toHaveLength(1);
});
it("selects the CTE definition and renders its source range before focusing", async () => {
const sql = "WITH picked AS (SELECT id FROM users) SELECT id FROM picked";
const { view, host, connectionStore, onClickTable } = await mountEditor({ modelValue: sql, connectionId: "navigation-test", database: "demo", databaseType: "postgres", dialect: "postgres" });
const originalFocus = view.focus.bind(view);
let navigationSnapshot: { selection: string; highlightedSource: string | null | undefined } | undefined;
const focus = vi.spyOn(view, "focus").mockImplementation(() => {
navigationSnapshot = {
selection: view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to),
highlightedSource: host.querySelector(".cm-db-result-source-highlight")?.textContent,
};
originalFocus();
});
vi.spyOn(view, "posAtCoords").mockReturnValue(sql.lastIndexOf("picked") + 2);
const lookupTables = vi.spyOn(connectionStore, "lookupLocalCompletionTables");
view.contentDOM.dispatchEvent(new MouseEvent("mousedown", { ctrlKey: true, button: 0, bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(focus).toHaveBeenCalledOnce());
expect(navigationSnapshot).toEqual({ selection: "picked", highlightedSource: "picked AS (SELECT id FROM users)" });
expect(view.state.selection.main.from).toBe(sql.indexOf("picked"));
expect(onClickTable).not.toHaveBeenCalled();
expect(lookupTables).not.toHaveBeenCalled();
});
it("emits table navigation from the local cache without consuming Alt-modified gestures", async () => {
const sql = "SELECT * FROM users";
const { view, connectionStore, onClickTable } = await mountEditor({ modelValue: sql, connectionId: "navigation-test", database: "demo" });
vi.spyOn(view, "posAtCoords").mockReturnValue(sql.indexOf("users") + 2);
vi.spyOn(connectionStore, "lookupLocalCompletionTables").mockReturnValue([{ name: "users", schema: "demo" }]);
view.contentDOM.dispatchEvent(new MouseEvent("mousedown", { ctrlKey: true, altKey: true, button: 0, bubbles: true, cancelable: true }));
await nextTick();
expect(onClickTable).not.toHaveBeenCalled();
view.dispatch({ selection: { anchor: 0 } });
view.contentDOM.dispatchEvent(new MouseEvent("mousedown", { ctrlKey: true, button: 0, bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(onClickTable).toHaveBeenCalledWith(expect.objectContaining({ name: "users" })));
expect(view.state.doc.toString()).toBe(sql);
});
it.each([false, true])("preserves selection drag edits with copy=%s through the real mouse handlers", async (copy) => {
const { view, props } = await mountEditor({ modelValue: "abc def ghi" });
view.dispatch({ selection: { anchor: 4, head: 7 } });
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(5);
vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 50, right: 51, top: 20, bottom: 38 });
view.contentDOM.dispatchEvent(new MouseEvent("mousedown", { button: 0, detail: 1, clientX: 10, clientY: 10, ctrlKey: copy, bubbles: true, cancelable: true }));
position.mockReturnValue(11);
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 30, clientY: 10, ctrlKey: copy, cancelable: true }));
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).not.toBeNull();
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 30, clientY: 10, ctrlKey: copy, cancelable: true }));
await nextTick();
expect(view.state.doc.toString()).toBe(copy ? "abc def ghidef" : "abc ghidef");
expect(props.modelValue).toBe(view.state.doc.toString());
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
});
it.each(["native", "window"])("connects %s table-reference drops to document updates and caret cleanup", async (source) => {
const { view, props, host } = await mountEditor({ modelValue: "SELECT old", databaseType: "postgres", dialect: "postgres" });
const root = host.querySelector("[data-query-editor-root]") as HTMLElement;
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 200, 100));
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(7);
vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 25, right: 26, top: 30, bottom: 48 });
view.dispatch({ selection: { anchor: 7, head: 10 } });
const payload: QueryEditorTableReferencePayload = { kind: "dbx-table-reference", connectionId: "drop-test", database: "demo", tableName: "users", referenceType: "column", columnName: "id" };
setActiveTableReferencePayload(payload);
window.dispatchEvent(createTableReferenceHoverEvent({ clientX: 30, clientY: 40 }));
await nextTick();
const caret = host.querySelector("[data-query-editor-drop-caret]") as HTMLElement;
expect(caret.style.display).not.toBe("none");
position.mockReturnValue(null);
if (source === "native") view.contentDOM.dispatchEvent(new DragEvent("drop", { clientX: 30, clientY: 40, bubbles: true, cancelable: true }));
else window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 30, clientY: 40 }));
await nextTick();
expect(view.state.doc.toString()).toBe("SELECT id");
expect(props.modelValue).toBe("SELECT id");
expect(caret.style.display).toBe("none");
});
it("keeps readonly table drops inert and removes global drop listeners on unmount", async () => {
const { view, host, props, unmount } = await mountEditor({ modelValue: "SELECT old", readOnly: true });
const root = host.querySelector("[data-query-editor-root]") as HTMLElement;
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 200, 100));
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(10);
const payload: QueryEditorTableReferencePayload = { kind: "dbx-table-reference", connectionId: "drop-test", database: "demo", tableName: "users", referenceType: "column", columnName: "id" };
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 30, clientY: 40 }));
expect(position).not.toHaveBeenCalled();
expect(view.state.doc.toString()).toBe("SELECT old");
props.readOnly = false;
await nextTick();
unmount();
position.mockClear();
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 30, clientY: 40 }));
expect(position).not.toHaveBeenCalled();
});
});
@@ -2,22 +2,26 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* QueryEditor.vue 的 CTE 接线(hover 溯源、Ctrl+点击跳转、语义模型缓存)都写在
* `<script setup>` 内部,无法直接 import,因此沿用仓库既有做法
* (queryEditorExtendSelection.spec.ts 对 queryEditorSource 的结构断言)。
* CTE 接线(hover 溯源、Ctrl+点击跳转、语义模型缓存)分布在独立模块中。
* 此处保留结构断言,模块行为另由 useQueryEditorHover 和
* useQueryEditorObjectNavigation 的直接测试覆盖。
*
* 这里锁定回归成本最高的两类契约:分支先后顺序、关键调用参数。算法本身由
* cteNavigation.spec.ts / model.spec.ts / sqlNavigation.spec.ts 覆盖。
*/
const source = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const componentSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const source = [readFileSync(new URL("../useQueryEditorHover.ts", import.meta.url), "utf8"), readFileSync(new URL("../useQueryEditorObjectNavigation.ts", import.meta.url), "utf8")].join("\n");
/** 截取模块中的函数体,避免跨函数误匹配。 */
const metadataSource = readFileSync(new URL("../useQueryEditorCompletionMetadata.ts", import.meta.url), "utf8");
/** 截取 `<script setup>` 顶层函数体,避免跨函数误匹配。 */
function functionBody(name: string): string {
const start = source.indexOf(`function ${name}(`);
const functionSource = name === "getEditorSemanticModel" ? metadataSource : source;
const start = functionSource.indexOf(`function ${name}(`);
expect(start, `QueryEditor.vue 缺少函数 ${name}`).toBeGreaterThanOrEqual(0);
const end = source.indexOf("\n}", start);
const end = functionSource.indexOf("\n }", start);
expect(end, `${name} 函数体未闭合`).toBeGreaterThan(start);
return source.slice(start, end);
return functionSource.slice(start, end);
}
function positionOf(fragment: string): number {
@@ -43,6 +47,12 @@ function expectSourceToContain(fragment: string): void {
}
describe("QueryEditor hover 的语义模型缓存接线", () => {
it("连接独立的 hover 和对象导航入口及监听器生命周期", () => {
expect(componentSource).toContain("hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos))");
expect(componentSource).toContain("mousedown: onEditorMouseDown");
expect(componentSource).toContain("objectNavigation.attach();");
expect(componentSource).toContain("objectNavigation.dispose();");
});
it("hover 复用 getEditorSemanticModel,而不是每次重新解析", () => {
expect(source).toContain("semanticModel = getEditorSemanticModel(sql, pos, currentView.state);");
// 换回逐次解析会让 hover 在每次移动时重复建模型(原实现即为此形态)。
@@ -158,7 +168,7 @@ describe("jumpToCteRange 的跳转范围处理", () => {
const body = functionBody("jumpToCteRange");
it("缺少视图或 CodeMirror 模块时直接返回", () => {
expect(body).toContain("if (!currentView || !editorViewModule || !setResultSourceRangeEffect) return;");
expect(body).toContain("if (!currentView || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.setResultSourceRangeEffect) return;");
});
it("把目标与高亮范围都收敛到文档长度内", () => {
@@ -172,8 +182,8 @@ describe("jumpToCteRange 的跳转范围处理", () => {
it("空目标不动光标,命中时选中目标 token 并高亮整个定义块", () => {
expect(body).toContain("if (targetFrom >= targetTo) return;");
expect(normalizeCode(body)).toContain(normalizeCode("selection: { anchor: targetFrom, head: targetTo }"));
expectSourceToContain("setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo })");
expectSourceToContain('editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })');
expectSourceToContain("codeMirrorRuntime.setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo })");
expectSourceToContain('codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })');
expect(body).toContain("currentView.focus();");
});
});
@@ -1,11 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const editorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const editorSource = readFileSync(new URL("../useQueryEditorCompletionKeys.ts", import.meta.url), "utf8");
function functionSource(name: string, nextName: string) {
function functionSource(name: string) {
const start = editorSource.indexOf(`function ${name}(`);
const end = editorSource.indexOf(`function ${nextName}(`, start);
const end = editorSource.indexOf("\n }", start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return editorSource.slice(start, end);
@@ -18,7 +18,7 @@ function functionSource(name: string, nextName: string) {
// a composition starts instead of accepting over (or after) it.
describe("QueryEditor IME-safe completion acceptance", () => {
it("gates the Enter handler on IME composition before any acceptance", () => {
const source = functionSource("handleEnter", "clearPendingCompletionEnter");
const source = functionSource("handleEnter");
const composingGuard = source.indexOf("if (isEditorComposing(view)) return false;");
const acceptCall = source.indexOf("codeMirrorAcceptCompletion?.(view)");
@@ -27,7 +27,7 @@ describe("QueryEditor IME-safe completion acceptance", () => {
});
it("gates the Tab completion-accept handler on IME composition", () => {
const source = functionSource("acceptCompletionOrNextSnippetField", "clearPendingCompletionTab");
const source = functionSource("acceptCompletionOrNextSnippetField");
const composingGuard = source.indexOf("if (isEditorComposing(view)) return false;");
const selectionCheck = source.indexOf("view.state.selection.ranges.every");
@@ -36,7 +36,7 @@ describe("QueryEditor IME-safe completion acceptance", () => {
});
it("drops the pending Tab acceptance retry when an IME composition starts", () => {
const source = functionSource("waitForCompletionTab", "wordWrapExtension");
const source = functionSource("waitForCompletionTab");
const retry = source.indexOf("const retry = () => {");
const composingGuard = source.indexOf("if (isEditorComposing(view)) return;", retry);
@@ -45,7 +45,7 @@ describe("QueryEditor IME-safe completion acceptance", () => {
});
it("passes an IME guard to the Enter acceptance retry helper", () => {
const source = functionSource("handleEnter", "clearPendingCompletionEnter");
const source = functionSource("handleEnter");
expect(source.includes("isComposing: () => isEditorComposing(view)")).toBe(true);
});
});
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import componentSource from "../QueryEditor.vue?raw";
import componentSource from "../useQueryEditorCompletionKeys.ts?raw";
import ts from "typescript";
import { indentMore } from "@codemirror/commands";
import { autocompletion, nextSnippetField, closeCompletion, completionKeymap, completionStatus, hasNextSnippetField, snippet, startCompletion, type CompletionResult } from "@codemirror/autocomplete";
@@ -20,10 +20,11 @@ const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
// Exercise the component's highest-priority Tab handler with real selections,
// including typing into each field before advancing to the next one.
const componentAst = ts.createSourceFile("QueryEditor.ts", componentSource.match(/^<script setup[^>]*>([\s\S]*?)<\/script>/m)![1], ts.ScriptTarget.Latest, true);
const componentAst = ts.createSourceFile("useQueryEditorCompletionKeys.ts", componentSource, ts.ScriptTarget.Latest, true);
const tabFunctions = new Set(["handleTab", "tabKeyAcceptsCompletion", "handleTabWithoutAcceptingCompletion", "performNormalTab", "editorIndentUnit", "acceptCompletionOrNextSnippetField"]);
const tabSource = componentAst.statements
.filter((statement) => ts.isFunctionDeclaration(statement) && statement.name && tabFunctions.has(statement.name.text))
.find(ts.isFunctionDeclaration)!
.body!.statements.filter((statement) => ts.isFunctionDeclaration(statement) && statement.name && tabFunctions.has(statement.name.text))
.map((statement) => statement.getText(componentAst))
.join("\n");
const handleTab = new Function(
@@ -35,7 +36,7 @@ const handleTab = new Function(
"shortcutToCodeMirrorKey",
"isEditorComposing",
"isBatchColumnSelectionCompletionActive",
ts.transpileModule(tabSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + "\nreturn handleTab;",
ts.transpileModule("const codeMirrorRuntime = { codeMirrorCompletionStatus, codeMirrorNextSnippetField, codeMirrorIndentMore };\n" + tabSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + "\nreturn handleTab;",
)(
completionStatus,
nextSnippetField,
@@ -2,12 +2,12 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* SQL Server 例程批次的语法诊断接线(#9315)写在 `<script setup>` 内部,
* SQL Server 例程批次的语法诊断接线(#9315)写在诊断 composable 内部,
* 无法直接 import,因此沿用仓库既有做法(QueryEditor.cteNavigationWiring.spec.ts)
* 对源码做结构断言。规则本身由 sqlServerRoutineSyntaxDiagnostics.test.ts 覆盖,
* 批次/视口裁剪由 sqlSemanticDiagnostics.test.ts 覆盖,这里只锁定接线契约。
*/
const source = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const source = readFileSync(new URL("../useQueryEditorDiagnostics.ts", import.meta.url), "utf8");
function normalizeCode(text: string): string {
return text.replace(/\s+/g, "").replace(/,(?=[)\]}])/g, "");
@@ -0,0 +1,202 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import QueryEditorContextMenu, { type QueryEditorContextMenuActions, type QueryEditorContextMenuState } from "../QueryEditorContextMenu.vue";
import { DEFAULT_SHORTCUT_SETTINGS } from "@/lib/editor/shortcutRegistry";
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
});
function mountMenu() {
const state = reactive<QueryEditorContextMenuState>({ readOnly: false, hideExecutionControls: false, databaseType: "mysql", selectedSql: "", executableSql: "", previewContextSql: "", contextObjectTarget: null, shortcuts: { ...DEFAULT_SHORTCUT_SETTINGS }, expandSelectStar: undefined });
const actions = {
executeFromContextMenu: vi.fn(),
executeInNewResultTabFromContextMenu: vi.fn(),
requestPreviewChanges: vi.fn(),
exportQueryFromContextMenu: vi.fn(),
toggleCommentFromContextMenu: vi.fn(),
toggleBlockCommentFromContextMenu: vi.fn(),
formatCurrentSql: vi.fn(),
compressCurrentSql: vi.fn(),
copySelectedSqlFromContextMenu: vi.fn(),
copySelectedSqlAsRichTextFromContextMenu: vi.fn(),
cutSelectedSqlFromContextMenu: vi.fn(),
pasteClipboardSqlFromContextMenu: vi.fn(),
convertSelectedSqlCase: vi.fn(),
convertSelectedNamingStyle: vi.fn(),
openDelimitedListDialog: vi.fn(),
addNextSelectionOccurrenceFromContextMenu: vi.fn(),
selectAllSelectionOccurrencesFromContextMenu: vi.fn(),
openFindReplaceFromContextMenu: vi.fn(),
deleteEmptyLines: vi.fn(),
selectAllSqlFromContextMenu: vi.fn(),
emitContextObjectAction: vi.fn(),
openCodeSnapshot: vi.fn(),
sendSelectionToAi: vi.fn(),
} satisfies QueryEditorContextMenuActions;
const onClose = vi.fn();
let synchronize = () => {};
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () =>
h(
QueryEditorContextMenu,
{ getState: () => ({ ...state }), actions, onClose },
{
default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) =>
h("div", {
"data-editor-host": "",
onContextmenu: (event: MouseEvent) => {
synchronize();
onContextMenu(event);
},
}),
},
),
});
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
host.remove();
});
const open = async (update = () => {}) => {
synchronize = update;
host.querySelector("[data-editor-host]")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
await nextTick();
};
return { state, actions, onClose, open, host };
}
function button(label: string) {
const found = [...document.querySelectorAll<HTMLButtonElement>("[data-dbx-context-menu] button")].find((candidate) => candidate.textContent?.includes(label));
expect(found, label).toBeDefined();
return found!;
}
describe("QueryEditor extracted context menu", () => {
it("routes the upstream table structure peek action through the extracted menu", async () => {
const { state, actions, open } = mountMenu();
state.contextObjectTarget = { name: "users", database: "demo", schema: "public", type: "table" };
await open();
expect(button("contextMenu.peekStructure").disabled).toBe(false);
button("contextMenu.peekStructure").click();
expect(actions.emitContextObjectAction).toHaveBeenCalledExactlyOnceWith("peek-table-structure");
});
it("reads freshly synchronized state before Vue flushes child props", async () => {
const { state, open } = mountMenu();
await open();
expect(button("editor.contextMenu.executeCurrent").disabled).toBe(true);
expect(button("editor.contextMenu.copySelection").disabled).toBe(true);
await open(() => {
state.selectedSql = "SELECT 1";
state.executableSql = "SELECT 1";
});
expect(button("editor.contextMenu.executeSelection").disabled).toBe(false);
expect(button("editor.contextMenu.copySelection").disabled).toBe(false);
await open(() => {
state.selectedSql = "";
state.executableSql = "";
});
expect(button("editor.contextMenu.executeCurrent").disabled).toBe(true);
});
it("keeps whitespace copyable without treating it as executable SQL", async () => {
const { state, open } = mountMenu();
await open(() => {
state.selectedSql = " \n ";
state.executableSql = " \n ";
});
expect(button("editor.contextMenu.executeCurrent").disabled).toBe(true);
expect(button("editor.contextMenu.copySelection").disabled).toBe(false);
});
it("keeps read-only and hidden-execution controls independent", async () => {
const { state, open } = mountMenu();
await open(() => {
state.readOnly = true;
state.hideExecutionControls = true;
state.selectedSql = "SELECT 1";
});
expect(document.querySelector("[data-dbx-context-menu]")!.textContent).not.toContain("editor.contextMenu.executeSelection");
for (const name of ["cutSelection", "pasteFromClipboard", "commentSelection", "blockCommentSelection", "formatSelectionSql", "compressSelectionSql", "delimitedList", "deleteEmptyLines"]) {
expect(button(`editor.contextMenu.${name}`).disabled, name).toBe(true);
}
expect(button("editor.contextMenu.copySelection").disabled).toBe(false);
});
it.each(["redis", "mongodb"] as const)("preserves unsupported SQL controls for %s", async (databaseType) => {
const { state, open } = mountMenu();
await open(() => {
state.databaseType = databaseType;
state.selectedSql = "command";
});
expect(button("editor.contextMenu.blockCommentSelection").disabled).toBe(true);
expect(button("editor.contextMenu.formatSelectionSql").disabled).toBe(databaseType === "redis");
});
it("retains the resolved star expansion after the menu close callback clears it", async () => {
const { state, open, onClose } = mountMenu();
const expand = vi.fn();
onClose.mockImplementation(() => {
state.expandSelectStar = undefined;
});
await open(() => {
state.expandSelectStar = expand;
});
button("editor.contextMenu.expandSelectStar").click();
await nextTick();
expect(onClose).toHaveBeenCalledTimes(1);
expect(expand).toHaveBeenCalledTimes(1);
});
it("reads the current preview SQL when invoking the action", async () => {
const { state, actions, open } = mountMenu();
state.previewContextSql = "UPDATE users SET name = 'before'";
await open();
state.previewContextSql = "UPDATE users SET name = 'after'";
button("editor.previewChanges").click();
expect(actions.requestPreviewChanges).toHaveBeenCalledExactlyOnceWith(state.previewContextSql);
});
it.each([
["editor.contextMenu.executeCurrent", "executeFromContextMenu"],
["settings.shortcutExecuteSqlInNewResultTab", "executeInNewResultTabFromContextMenu"],
["editor.contextMenu.screenshotSelection", "openCodeSnapshot"],
["editor.contextMenu.delimitedList", "openDelimitedListDialog"],
["editor.contextMenu.sendToAi", "sendSelectionToAi"],
["editor.contextMenu.findReplace", "openFindReplaceFromContextMenu"],
] as const)("forwards %s exactly once", async (label, action) => {
const { state, actions, open } = mountMenu();
state.selectedSql = "SELECT 1";
state.executableSql = "SELECT 1";
if (label.endsWith("executeCurrent")) state.selectedSql = "";
await open();
button(label).click();
expect(actions[action]).toHaveBeenCalledTimes(1);
});
it.each([
["table", "contextMenu.editStructure", "edit-table-structure"],
["view", "contextMenu.editView", "edit-view"],
["materialized_view", "contextMenu.viewSource", "view-source"],
] as const)("keeps object routing for %s targets", async (type, label, action) => {
const { state, actions, open } = mountMenu();
await open(() => {
state.contextObjectTarget = { type, name: "users", schema: "public", database: "db" };
});
button(label).click();
expect(actions.emitContextObjectAction).toHaveBeenCalledExactlyOnceWith(action);
await open(() => {
state.contextObjectTarget = null;
});
expect(button("contextMenu.viewData").disabled).toBe(true);
});
});
@@ -8,10 +8,19 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
const source = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const menuSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditorContextMenu.vue"), "utf8");
const documentSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
const batchSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorBatchSelection.ts"), "utf8");
const completionSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorCompletion.ts"), "utf8");
const completionKeysSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/useQueryEditorCompletionKeys.ts"), "utf8");
const extensionsSource = readFileSync(path.resolve(process.cwd(), "apps/desktop/src/components/editor/queryEditorSqlExtensions.ts"), "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 updateEnd = source.indexOf("initializedRuntime.fontThemeComp.of(", updateStart);
const updateSource = source.slice(updateStart, updateEnd);
expect(updateStart).toBeGreaterThanOrEqual(0);
@@ -23,13 +32,13 @@ describe("QueryEditor context menu lifecycle", () => {
it("does not suppress legitimate external model updates", () => {
expect(source).not.toContain("lastEmittedModelValue");
expect(source).toContain("if (val !== currentEditorDocText(view.value)) {");
expect(documentSource).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);
const activateStart = documentSource.indexOf("function activateTabDocument");
const activateEnd = documentSource.indexOf("\n watch([() => props.tabId", activateStart);
const activateSource = documentSource.slice(activateStart, activateEnd);
expect(activateStart).toBeGreaterThanOrEqual(0);
expect(activateEnd).toBeGreaterThan(activateStart);
@@ -49,9 +58,9 @@ describe("QueryEditor context menu lifecycle", () => {
const syncSource = source.slice(syncStart, syncEnd);
const syncIndex = source.indexOf("syncContextMenuStateAtEvent(view, e);");
const openIndex = source.indexOf("onContextMenu(e);", syncIndex);
const getterStart = source.indexOf("function currentContextMenuItems()");
const getterEnd = source.indexOf("\n}", getterStart);
const getterSource = source.slice(getterStart, getterEnd);
const getterStart = menuSource.indexOf("function currentContextMenuItems()");
const getterEnd = menuSource.indexOf("\n}", getterStart);
const getterSource = menuSource.slice(getterStart, getterEnd);
expect(syncStart).toBeGreaterThanOrEqual(0);
expect(syncEnd).toBeGreaterThan(syncStart);
@@ -64,8 +73,9 @@ describe("QueryEditor context menu lifecycle", () => {
expect(getterSource).toContain("return contextMenuItems.value;");
expect(getterSource).not.toContain("nextTick");
expect(getterSource).not.toContain("setTimeout");
expect(source).toContain(':items="currentContextMenuItems"');
expect(source).not.toContain('<CustomContextMenu :items="contextMenuItems"');
expect(menuSource).toContain(':items="currentContextMenuItems"');
expect(source).toContain(':get-state="getContextMenuState"');
expect(menuSource).not.toContain('<CustomContextMenu :items="contextMenuItems"');
});
it("uses the target synchronized in the current context-menu event", async () => {
@@ -135,67 +145,67 @@ describe("QueryEditor context menu lifecycle", () => {
describe("QueryEditor batch column selection", () => {
it("keeps the confirmation action visible during pinyin filtering and supports keyboard toggling", () => {
const bypassFilterStart = source.indexOf("function completionItemsForBypassedFilter");
const bypassFilterEnd = source.indexOf("\n}\n\nfunction localCompletionDatabaseNames", bypassFilterStart);
const bypassFilterSource = source.slice(bypassFilterStart, bypassFilterEnd);
const bypassFilterStart = completionSource.indexOf("function completionItemsForBypassedFilter");
const bypassFilterEnd = completionSource.indexOf("\n }\n\n function localCompletionDatabaseNames", bypassFilterStart);
const bypassFilterSource = completionSource.slice(bypassFilterStart, bypassFilterEnd);
expect(bypassFilterStart).toBeGreaterThanOrEqual(0);
expect(bypassFilterEnd).toBeGreaterThan(bypassFilterStart);
expect(bypassFilterSource).toContain("if (isBatchColumnSelectionAction(item)) return true;");
expect(source).toContain('key: "Space"');
expect(source).toContain("run: toggleSelectedBatchColumnSelection");
expect(source).toContain("codeMirrorSelectedCompletion?.(view.state)");
expect(completionKeysSource).toContain("codeMirrorSelectedCompletion?.(view.state)");
});
it("applies checked columns directly through Enter and the completion Tab shortcut", () => {
const handleEnterStart = source.indexOf("function handleEnter");
const handleEnterEnd = source.indexOf("\n}\n\nfunction clearPendingCompletionEnter", handleEnterStart);
const tabStart = source.indexOf("function acceptCompletionOrNextSnippetField");
const tabEnd = source.indexOf("\n}\n\nfunction clearPendingCompletionTab", tabStart);
const handleEnterStart = completionKeysSource.indexOf("function handleEnter");
const handleEnterEnd = completionKeysSource.indexOf("\n }", handleEnterStart);
const tabStart = completionKeysSource.indexOf("function acceptCompletionOrNextSnippetField");
const tabEnd = completionKeysSource.indexOf("\n }", tabStart);
expect(source).toContain("function applySelectedBatchColumnSelection");
expect(source.slice(handleEnterStart, handleEnterEnd)).toContain("if (isBatchColumnSelectionCompletionActive(codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true;");
expect(source.slice(handleEnterStart, handleEnterEnd)).toContain("if (codeMirrorAcceptCompletion?.(view)) return true;");
expect(source.slice(tabStart, tabEnd)).toContain("if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;");
expect(source).toContain("defaultKeymap: false");
expect(batchSource).toContain("function applySelectedBatchColumnSelection");
expect(completionKeysSource.slice(handleEnterStart, handleEnterEnd)).toContain("if (isBatchColumnSelectionCompletionActive(codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true;");
expect(completionKeysSource.slice(handleEnterStart, handleEnterEnd)).toContain("if (codeMirrorRuntime.codeMirrorAcceptCompletion?.(view)) return true;");
expect(completionKeysSource.slice(tabStart, tabEnd)).toContain("if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;");
expect(extensionsSource).toContain("defaultKeymap: false");
expect(source).toContain('{ key: "ArrowDown", run: (view) => moveCompletion(view, true) }');
expect(source).toContain('{ key: "ArrowUp", run: (view) => moveCompletion(view, false) }');
});
it("marks the insertion action so the completion menu can keep it sticky", () => {
expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }");
expect(source).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")');
expect(completionSource).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }");
expect(extensionsSource).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")');
});
it("updates the insertion count while a mouse selection is still in progress", () => {
const dragUpdateStart = source.indexOf("function updateBatchColumnSelectionAtPoint");
const dragUpdateEnd = source.indexOf("\n}\n\nconst BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX", dragUpdateStart);
const dragUpdateStart = batchSource.indexOf("function updateBatchColumnSelectionAtPoint");
const dragUpdateEnd = batchSource.indexOf("\n }\n\n const BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX", dragUpdateStart);
expect(source).toContain("function updateBatchColumnSelectionActionLabel");
expect(source.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);");
expect(source).toContain("const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();");
expect(source).toContain("batchColumnSelectionTooltipParents.set(view.value, tooltipParent);");
expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();");
expect(source).toContain("!markerState || markerState.sessionKey !== sessionKey");
expect(source).toContain("renderBatchColumnSelectionActionMarker");
expect(batchSource).toContain("function updateBatchColumnSelectionActionLabel");
expect(batchSource.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);");
expect(batchSource).toContain("const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();");
expect(batchSource).toContain("batchColumnSelectionTooltipParents.set(currentView, tooltipParent);");
expect(batchSource).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();");
expect(batchSource).toContain("!markerState || markerState.sessionKey !== sessionKey");
expect(batchSource).toContain("renderBatchColumnSelectionActionMarker");
});
it("cancels stale scroll restoration before a new drag can begin", () => {
const refreshStart = source.indexOf("function scheduleBatchColumnSelectionRefresh");
const refreshEnd = source.indexOf("\n}\n\nfunction finishBatchColumnSelectionDrag", refreshStart);
const dragStart = source.indexOf("function startBatchColumnSelectionDrag");
const dragEnd = source.indexOf("\n}\n\nfunction renderBatchColumnSelectionCheckbox", dragStart);
const refreshStart = batchSource.indexOf("function scheduleBatchColumnSelectionRefresh");
const refreshEnd = batchSource.indexOf("\n }\n\n function finishBatchColumnSelectionDrag", refreshStart);
const dragStart = batchSource.indexOf("function startBatchColumnSelectionDrag");
const dragEnd = batchSource.indexOf("\n }\n\n function renderBatchColumnSelectionCheckbox", dragStart);
expect(source).toContain("let batchColumnSelectionRefreshCleanup: (() => void) | null = null;");
expect(source.slice(refreshStart, refreshEnd)).toContain("cancelBatchColumnSelectionRefresh();");
expect(source.slice(dragStart, dragEnd)).toContain("cancelBatchColumnSelectionRefresh();");
expect(source).toContain("if (restoreStartTimer) window.clearTimeout(restoreStartTimer);");
expect(batchSource).toContain("let batchColumnSelectionRefreshCleanup: (() => void) | null = null;");
expect(batchSource.slice(refreshStart, refreshEnd)).toContain("cancelBatchColumnSelectionRefresh();");
expect(batchSource.slice(dragStart, dragEnd)).toContain("cancelBatchColumnSelectionRefresh();");
expect(batchSource).toContain("if (restoreStartTimer) window.clearTimeout(restoreStartTimer);");
});
it("only expands rendering for batch field selection", () => {
expect(source).toContain("function setBatchColumnSelectionExpandedRendering");
expect(source).toContain("setBatchColumnSelectionExpandedRendering(true);");
expect(source).toContain("setBatchColumnSelectionExpandedRendering(false);");
expect(source).toContain("maxRenderedOptions: batchColumnSelectionExpandedRendering ? Number.MAX_SAFE_INTEGER : 100,");
expect(batchSource).toContain("function setBatchColumnSelectionExpandedRendering");
expect(batchSource).toContain("setBatchColumnSelectionExpandedRendering(true);");
expect(batchSource).toContain("setBatchColumnSelectionExpandedRendering(false);");
expect(extensionsSource).toContain("maxRenderedOptions: batchSelection.expandedRendering ? Number.MAX_SAFE_INTEGER : 100,");
});
});
@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
import { ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createQueryEditorHoverContent } from "../queryEditorHoverContent";
const { copy, highlight, createHighlighter, mountLayout, destroyLayout } = vi.hoisted(() => ({ copy: vi.fn(), highlight: vi.fn(), createHighlighter: vi.fn(), mountLayout: vi.fn(), destroyLayout: vi.fn() }));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: copy }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: createHighlighter }));
vi.mock("@/lib/editor/sqlHoverLayout", () => ({ constrainSqlHoverLayout: () => ({ mount: mountLayout, destroy: destroyLayout }) }));
const cleanups: Array<() => void> = [];
beforeEach(() => {
copy.mockResolvedValue(undefined);
highlight.mockImplementation((sql: string) => `<pre>${sql}</pre>`);
createHighlighter.mockResolvedValue(highlight);
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
document.getSelection()?.removeAllRanges();
vi.clearAllMocks();
});
function createHarness() {
const isDark = ref(false);
const toast = vi.fn();
const t = vi.fn((key: string) => key);
const content = createQueryEditorHoverContent({ isDark, toast, t: t as Parameters<typeof createQueryEditorHoverContent>[0]["t"] });
function render(sql?: string, rows?: string[]) {
const tooltip = content.createHoverDom("users", "TABLE", sql, rows);
document.body.append(tooltip.dom);
tooltip.mount?.();
cleanups.push(() => {
tooltip.destroy?.();
tooltip.dom.remove();
});
return tooltip;
}
return { content, isDark, toast, t, render };
}
describe("QueryEditor hover content", () => {
it("renders plain metadata safely without creating DDL controls", () => {
const { render } = createHarness();
const tooltip = render(undefined, ["<img src=x>"]);
expect(tooltip.dom.textContent).toContain("<img src=x>");
expect(tooltip.dom.querySelector("img")).toBeNull();
expect(tooltip.dom.querySelector("button")).toBeNull();
expect(mountLayout).not.toHaveBeenCalled();
});
it("copies normalized DDL without changing its displayed alignment", async () => {
const { render, toast } = createHarness();
const sql = "CREATE TABLE users (id INTEGER);";
const tooltip = render(sql);
const button = tooltip.dom.querySelector<HTMLButtonElement>('button[aria-label="grid.copyDdl"]')!;
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
button.dispatchEvent(pointer);
expect(pointer.defaultPrevented).toBe(true);
button.click();
await vi.waitFor(() => expect(copy).toHaveBeenCalledWith("CREATE TABLE users (id INTEGER);"));
expect(toast).toHaveBeenCalledWith("contextMenu.ddlCopied", 2000);
expect(tooltip.dom.textContent).toContain(sql);
});
it("reports copy failures without dismissing the content", async () => {
copy.mockRejectedValue(new Error("clipboard unavailable"));
const { render, toast, t } = createHarness();
const tooltip = render("SELECT 1");
tooltip.dom.querySelector<HTMLButtonElement>('button[aria-label="grid.copyDdl"]')!.click();
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith("grid.copyFailed", 5000));
expect(t).toHaveBeenCalledWith("grid.copyFailed", { message: "clipboard unavailable" });
expect(tooltip.dom.isConnected).toBe(true);
});
it("removes its document copy listener and layout controller on destroy", () => {
const { render } = createHarness();
const tooltip = render("SELECT 1");
const sqlContainer = tooltip.dom.querySelector(".whitespace-pre")!;
const range = document.createRange();
range.selectNodeContents(sqlContainer);
document.getSelection()?.addRange(range);
const clipboardData = { setData: vi.fn() };
const copyEvent = () => {
const event = new Event("copy", { cancelable: true });
Object.defineProperty(event, "clipboardData", { value: clipboardData });
document.dispatchEvent(event);
return event;
};
expect(copyEvent().defaultPrevented).toBe(true);
expect(clipboardData.setData).toHaveBeenCalledWith("text/plain", "SELECT 1");
tooltip.destroy?.();
clipboardData.setData.mockClear();
expect(copyEvent().defaultPrevented).toBe(false);
expect(clipboardData.setData).not.toHaveBeenCalled();
expect(mountLayout).toHaveBeenCalledOnce();
expect(destroyLayout).toHaveBeenCalledOnce();
});
it("uses late-loaded highlighting and reads the current appearance", async () => {
const { content, isDark, render } = createHarness();
expect(render("SELECT 1").dom.querySelector("pre")).toBeNull();
content.initializeHighlighter();
await vi.waitFor(() => expect(createHighlighter).toHaveBeenCalled());
render("SELECT 2");
expect(highlight).toHaveBeenLastCalledWith("SELECT 2", "light");
isDark.value = true;
render("SELECT 3");
expect(highlight).toHaveBeenLastCalledWith("SELECT 3", "dark");
});
it("keeps a plain-text fallback when the highlighter fails", async () => {
createHighlighter.mockRejectedValue(new Error("unavailable"));
const { content, render } = createHarness();
content.initializeHighlighter();
await vi.waitFor(() => expect(createHighlighter).toHaveBeenCalled());
const tooltip = render("SELECT '<script>'");
expect(tooltip.dom.textContent).toContain("SELECT '<script>'");
expect(tooltip.dom.querySelector("script")).toBeNull();
expect(highlight).not.toHaveBeenCalled();
});
it("keeps loaded highlighters local to each editor instance", async () => {
const first = createHarness();
const second = createHarness();
first.content.initializeHighlighter();
await vi.waitFor(() => expect(createHighlighter).toHaveBeenCalled());
expect(first.render("SELECT 1").dom.querySelector("pre")).not.toBeNull();
expect(second.render("SELECT 2").dom.querySelector("pre")).toBeNull();
});
});
@@ -0,0 +1,195 @@
// @vitest-environment happy-dom
import { shallowRef } from "vue";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { snippetCompletion } from "@codemirror/autocomplete";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorBatchSelection, type QueryCompletionOption } from "../useQueryEditorBatchSelection";
import type { SqlCompletionItem } from "@/lib/sql/sqlCompletion";
const { toast } = vi.hoisted(() => ({ toast: vi.fn() }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast }) }));
type Options = Parameters<typeof useQueryEditorBatchSelection>[0];
const cleanups: Array<() => void> = [];
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(EditorView.prototype, "focus").mockImplementation(() => {});
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
toast.mockReset();
});
function createHarness(doc = "SELECT FROM users", mode: "select" | "insert" = "select") {
const completionComp = new Compartment();
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc, extensions: [completionComp.of([])] }) });
const view = shallowRef<EditorView | null>(currentView);
const runtime: Options["runtime"] = {
completionComp,
buildSqlCompletionExtension: vi.fn(() => []),
codeMirrorCompletionStatus: vi.fn(() => "active"),
codeMirrorStartCompletion: vi.fn(() => true),
codeMirrorCurrentCompletions: vi.fn(() => []),
codeMirrorSetSelectedCompletion: null,
codeMirrorSelectedCompletion: vi.fn(() => null),
codeMirrorSnippetCompletion: snippetCompletion,
};
const accepted = vi.fn();
const batch = useQueryEditorBatchSelection({ view, runtime, settingsStore: { editorSettings: { sqlFormatter: { keywordCase: "upper" } } } as Options["settingsStore"], markCompletionAccepted: accepted });
const items: SqlCompletionItem[] = ["id", "name", "email"].map((label) => ({ label, apply: label, type: "column", batchSelectionMode: mode }));
const from = mode === "select" ? 7 : doc.length;
const session = batch.prepareBatchColumnSelectionSession(items, doc, from, from)!;
batch.attach(currentView, parent);
cleanups.push(() => {
batch.dispose();
currentView.destroy();
parent.remove();
});
function completion(index: number): QueryCompletionOption {
return { label: items[index].label, dbxBatchColumnSelection: batch.batchColumnSelectionMarkerForItem(items[index]) };
}
function row(index: number) {
const element = document.createElement("li");
element.setAttribute("role", "option");
const checkbox = batch.renderBatchColumnSelectionCheckbox(completion(index), currentView.state, currentView) as HTMLInputElement;
const label = document.createElement("span");
label.textContent = items[index].label;
element.append(checkbox, label);
parent.append(element);
return { element, checkbox, label };
}
return { batch, currentView, view, runtime, accepted, items, session, completion, row };
}
describe("QueryEditor batch selection ownership", () => {
it("keeps checked fields and cached options across refreshes, pruning removed fields", () => {
const { batch, session, items, completion, currentView } = createHarness();
batch.toggleAllBatchColumnSelection(currentView, session.key);
const option = completion(0);
expect(batch.cacheBatchColumnSelectionOption(option.dbxBatchColumnSelection, option)).toBe(option);
expect(batch.cacheBatchColumnSelectionOption(option.dbxBatchColumnSelection, completion(0))).toBe(option);
const refreshed = batch.prepareBatchColumnSelectionSession(items.slice(0, 2), session.document, session.from, session.to)!;
expect(refreshed).toBe(session);
expect(refreshed.selectedKeys).toEqual(new Set(refreshed.candidates.map((candidate) => candidate.key)));
expect(refreshed.completionOptions.size).toBe(1);
});
it("starts a fresh session when the document changes and ignores old select-all actions", () => {
const { batch, session, items, currentView } = createHarness();
batch.toggleAllBatchColumnSelection(currentView, session.key);
const next = batch.prepareBatchColumnSelectionSession(items, session.document + " ", session.from, session.to)!;
batch.toggleAllBatchColumnSelection(currentView, session.key);
expect(next.selectedKeys.size).toBe(0);
expect(next.completionOptions.size).toBe(0);
batch.prepareBatchColumnSelectionSession([], next.document, next.from, next.to);
expect(batch.expandedRendering).toBe(false);
});
it("guards label clicks and applies checked fields together, never an individual row", () => {
const { batch, session, currentView, row, accepted } = createHarness();
const { label } = row(1);
vi.spyOn(document, "elementFromPoint").mockReturnValue(label);
const mouseDown = new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true });
label.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
expect(session.selectedKeys).toEqual(new Set([session.candidates[1].key]));
expect(currentView.state.doc.toString()).toBe(session.document);
expect(batch.applySelectedBatchColumnSelection(currentView)).toBe(true);
expect(currentView.state.doc.toString()).toBe("SELECT name FROM users");
expect(accepted).toHaveBeenCalledOnce();
expect(batch.expandedRendering).toBe(false);
});
it("uses the live selected completion and inserts fields in candidate order", () => {
const { batch, session, runtime, currentView, completion } = createHarness();
runtime.codeMirrorSelectedCompletion = () => completion(2);
expect(batch.toggleSelectedBatchColumnSelection(currentView)).toBe(true);
runtime.codeMirrorSelectedCompletion = () => completion(0);
batch.toggleSelectedBatchColumnSelection(currentView);
expect(session.selectedKeys.size).toBe(2);
expect(batch.applySelectedBatchColumnSelection(currentView)).toBe(true);
expect(currentView.state.doc.toString()).toBe("SELECT id, email FROM users");
});
it("rejects stale documents and pending completion without recording acceptance", () => {
const { batch, session, runtime, currentView, accepted } = createHarness();
batch.toggleAllBatchColumnSelection(currentView, session.key);
runtime.codeMirrorCompletionStatus = () => "pending";
expect(batch.applySelectedBatchColumnSelection(currentView)).toBe(false);
runtime.codeMirrorCompletionStatus = () => "active";
currentView.dispatch({ changes: { from: 0, insert: " " } });
expect(batch.applySelectedBatchColumnSelection(currentView)).toBe(false);
expect(accepted).not.toHaveBeenCalled();
});
it("preserves the empty-selection warning and INSERT snippet application", () => {
const { batch, session, currentView, accepted } = createHarness("INSERT INTO users (", "insert");
const action = { label: "insert", type: "text" as const, detail: "", boost: 0, batchColumnSelectionAction: true as const, sessionKey: session.key };
batch.applyBatchColumnSelection(currentView, action, session.from, session.to);
expect(toast).toHaveBeenCalledWith("editor.completion.selectColumnsBeforeInsert", 3000);
expect(accepted).not.toHaveBeenCalled();
batch.toggleAllBatchColumnSelection(currentView, session.key);
batch.applySelectedBatchColumnSelection(currentView);
expect(currentView.state.doc.toString()).toMatch(/^INSERT INTO users \(id, name, email\) VALUES \(/);
expect(currentView.state.doc.toString()).not.toContain("${");
expect(accepted).toHaveBeenCalledOnce();
});
it("restores selection styles and cancels refresh work when disposed during a drag", () => {
const { batch, row, currentView, runtime } = createHarness();
const { checkbox } = row(0);
document.body.style.userSelect = "text";
checkbox.dispatchEvent(new PointerEvent("pointerdown", { button: 0, buttons: 1, isPrimary: true, pointerId: 9, bubbles: true }));
expect(document.body.style.userSelect).toBe("none");
const remove = vi.spyOn(window, "removeEventListener");
batch.dispose();
expect(document.body.style.userSelect).toBe("text");
expect(remove.mock.calls.map(([event]) => event)).toEqual(expect.arrayContaining(["pointermove", "pointerup", "pointercancel", "blur"]));
const dispatch = vi.spyOn(currentView, "dispatch");
vi.advanceTimersByTime(2000);
expect(dispatch).not.toHaveBeenCalled();
expect(runtime.codeMirrorStartCompletion).not.toHaveBeenCalled();
expect(batch.applySelectedBatchColumnSelection(currentView)).toBe(false);
document.body.style.userSelect = "";
});
it("cancels pending refresh timers and document row guards on disposal", () => {
const { batch, session, currentView, runtime, row } = createHarness();
const { label } = row(0);
batch.toggleAllBatchColumnSelection(currentView, session.key);
batch.dispose();
vi.advanceTimersByTime(2000);
expect(runtime.codeMirrorStartCompletion).not.toHaveBeenCalled();
const mouseDown = new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true });
label.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(false);
});
it("does not reconfigure a replaced editor through an old render timer", () => {
const { view, currentView } = createHarness();
const dispatch = vi.spyOn(currentView, "dispatch");
view.value = null;
vi.advanceTimersByTime(1);
expect(dispatch).not.toHaveBeenCalled();
});
it("does not consume an INSERT completion before the lazy snippet API is ready", () => {
const { batch, session, currentView, accepted, runtime } = createHarness("INSERT INTO users (", "insert");
Object.assign(runtime, { codeMirrorSnippetCompletion: undefined });
batch.toggleAllBatchColumnSelection(currentView, session.key);
batch.applySelectedBatchColumnSelection(currentView);
expect(accepted).not.toHaveBeenCalled();
expect(currentView.state.doc.toString()).toBe(session.document);
expect(batch.expandedRendering).toBe(false);
});
});
@@ -0,0 +1,149 @@
// @vitest-environment happy-dom
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { shallowRef } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryEditorCodeMirrorRuntime } from "../queryEditorCodeMirrorRuntime";
import { useQueryEditorCodeMirror } from "../useQueryEditorCodeMirror";
type Options = Parameters<typeof useQueryEditorCodeMirror>[0];
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.restoreAllMocks();
});
function deferred<Value>() {
let resolve!: (value: Value) => void;
const promise = new Promise<Value>((complete) => {
resolve = complete;
});
return { promise, resolve };
}
function createHarness() {
const parent = document.createElement("div");
document.body.append(parent);
const view = shallowRef<EditorView | null>(null);
const runtime = createQueryEditorCodeMirrorRuntime();
const editorRef = shallowRef<HTMLElement | null>(parent);
const events: string[] = [];
const onReady = vi.fn(() => {
expect(view.value?.state.doc.toString()).toBe("SELECT 1;");
events.push("ready");
});
const configuration = { parent, state: EditorState.create({ doc: "SELECT 1;" }), onReady };
const beforeLoad = vi.fn(() => {
events.push("beforeLoad");
});
const prepare = vi.fn<Options["prepare"]>(async () => {
events.push("prepare");
return configuration;
});
const lifecycle = useQueryEditorCodeMirror({ editorRef, view, runtime, beforeLoad, prepare });
cleanups.push(() => {
lifecycle.dispose();
parent.remove();
});
return { lifecycle, editorRef, view, runtime, beforeLoad, prepare, configuration, onReady, events, parent };
}
describe("QueryEditor lazy CodeMirror runtime", () => {
it("keeps unloaded handles empty and publishes initialized handles on the same object", async () => {
const runtime = createQueryEditorCodeMirrorRuntime();
expect(runtime.editorViewModule).toBeNull();
expect(runtime.codeMirrorStartCompletion).toBeNull();
expect(runtime.sqlSignatureComp).toBeNull();
const modules = await runtime.load();
expect(modules.runtime).toBe(runtime);
expect(runtime.editorViewModule?.EditorView).toBe(EditorView);
expect(runtime.codeMirrorStartCompletion).toBe(modules.startCompletion);
expect(runtime.codeMirrorToggleBlockComment).toBe(modules.toggleBlockComment);
expect(runtime.sqlSignatureComp).toBeInstanceOf(Compartment);
expect(runtime.buildSqlLanguageExtension).toBeNull();
});
it("isolates per-editor compartments and effects while sharing imported functions", async () => {
const first = createQueryEditorCodeMirrorRuntime();
const second = createQueryEditorCodeMirrorRuntime();
await Promise.all([first.load(), second.load()]);
expect(first.codeMirrorStartCompletion).toBe(second.codeMirrorStartCompletion);
expect(first.completionComp).not.toBe(second.completionComp);
expect(first.sqlLanguageComp).not.toBe(second.sqlLanguageComp);
expect(first.sqlSignatureComp).not.toBe(second.sqlSignatureComp);
expect(first.setSqlDiagnosticsEffect).not.toBe(second.setSqlDiagnosticsEffect);
expect(first.statementBoundariesRefreshEffect).not.toBe(second.statementBoundariesRefreshEffect);
});
});
describe("QueryEditor initialization lifecycle", () => {
it("loads, prepares, creates and notifies in order", async () => {
const harness = createHarness();
const load = harness.runtime.load;
vi.spyOn(harness.runtime, "load").mockImplementation(async () => {
harness.events.push("load");
return load();
});
await harness.lifecycle.initialize();
expect(harness.events).toEqual(["beforeLoad", "load", "prepare", "ready"]);
expect(harness.parent.querySelector(".cm-editor")).toBe(harness.view.value?.dom);
const destroy = vi.spyOn(harness.view.value!, "destroy");
harness.lifecycle.dispose();
expect(destroy).toHaveBeenCalledOnce();
expect(harness.parent.querySelector(".cm-editor")).toBeNull();
});
it.each(["missing host", "disposed"])("does not begin initialization when %s", async (reason) => {
const harness = createHarness();
const load = vi.spyOn(harness.runtime, "load");
if (reason === "missing host") harness.editorRef.value = null;
else harness.lifecycle.dispose();
await harness.lifecycle.initialize();
expect(harness.beforeLoad).not.toHaveBeenCalled();
expect(load).not.toHaveBeenCalled();
expect(harness.prepare).not.toHaveBeenCalled();
expect(harness.view.value).toBeNull();
});
it("does not prepare or mount after disposal during lazy loading", async () => {
const harness = createHarness();
const modules = await harness.runtime.load();
const pending = deferred<typeof modules>();
vi.spyOn(harness.runtime, "load").mockReturnValue(pending.promise);
const initializing = harness.lifecycle.initialize();
harness.lifecycle.dispose();
pending.resolve(modules);
await initializing;
expect(harness.prepare).not.toHaveBeenCalled();
expect(harness.view.value).toBeNull();
expect(harness.onReady).not.toHaveBeenCalled();
});
it("does not create a view after disposal during asynchronous preparation", async () => {
const harness = createHarness();
const preparing = deferred<void>();
const pending = deferred<typeof harness.configuration>();
harness.prepare.mockImplementation(() => {
preparing.resolve();
return pending.promise;
});
const initializing = harness.lifecycle.initialize();
await preparing.promise;
harness.lifecycle.dispose();
pending.resolve(harness.configuration);
await initializing;
expect(harness.view.value).toBeNull();
expect(harness.onReady).not.toHaveBeenCalled();
expect(harness.parent.querySelector(".cm-editor")).toBeNull();
});
it("respects a cancelled configuration", async () => {
const harness = createHarness();
harness.prepare.mockResolvedValue(undefined);
await harness.lifecycle.initialize();
expect(harness.view.value).toBeNull();
expect(harness.onReady).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,229 @@
// @vitest-environment happy-dom
import { CompletionContext, insertCompletionText, snippetCompletion } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { computed, reactive, shallowRef } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorCompletion } from "../useQueryEditorCompletion";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
vi.mock("@/lib/backend/api", () => ({}));
type Options = Parameters<typeof useQueryEditorCompletion>[0];
const cleanups: Array<() => void> = [];
const commands: RedisCommandDocumentation[] = [
{ name: "GET", arity: 2, keySpecs: [{ beginSearch: { type: "index", index: 1 }, findKeys: { type: "range", lastKey: 0, keyStep: 1, limit: 0 } }] },
{ name: "CUSTOM.SERVER.COMMAND", keySpecs: [] },
];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const props = reactive<QueryEditorProps>({ modelValue: "SEL", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides });
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: props.modelValue, selection: { anchor: props.modelValue.length } }) });
const view = shallowRef<EditorView | null>(currentView);
const store = {
getConfig: vi.fn(() => undefined),
lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []),
lookupLocalCompletionColumns: vi.fn(() => []),
lookupLocalCompletionColumnsByPrefix: vi.fn(() => []),
lookupLocalCompletionObjects: vi.fn(() => []),
lookupLocalCompletionDatabases: vi.fn(() => []),
lookupLocalCompletionSchemas: vi.fn(() => []),
lookupLocalCompletionForeignKeys: vi.fn(() => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
listCompletionObjects: vi.fn(async () => []),
listCompletionColumns: vi.fn(async () => []),
listCompletionSchemas: vi.fn(async () => []),
listCompletionDatabases: vi.fn(async () => []),
refreshCompletionTables: vi.fn(async () => []),
refreshCompletionColumns: vi.fn(async () => []),
refreshCompletionSchemas: vi.fn(async () => []),
refreshCompletionDatabases: vi.fn(async () => []),
listRedisCompletionCommandDocs: vi.fn(async () => commands),
listRedisCompletionKeys: vi.fn(async () => ["user:1"]),
listMongoCompletionCollections: vi.fn(async () => ["users"]),
listMongoCompletionFields: vi.fn(async () => []),
listElasticsearchCompletionIndices: vi.fn(async () => ["users"]),
listElasticsearchCompletionFields: vi.fn(async () => []),
};
const connectionStore = store as unknown as Options["connectionStore"];
const settings = reactive({ editorSettings: { completionTriggerMode: "positional", snippets: [], sqlFormatter: { keywordCase: "upper", functionCase: "upper" }, autoAliasTables: false, generateSqlQuoteIdentifiers: false } });
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false });
const startCompletion = vi.fn(() => true);
const runtime: Options["runtime"] = { codeMirrorStartCompletion: startCompletion, codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false };
const batchSelection = {
clearBatchColumnSelectionSession: vi.fn(),
prepareBatchColumnSelectionSession: vi.fn(() => null),
isBatchColumnSelectionAction: () => false,
batchColumnSelectionMarkerForItem: () => undefined,
cacheBatchColumnSelectionOption: (_marker: unknown, option: unknown) => option,
} as unknown as Options["batchSelection"];
const completion = useQueryEditorCompletion({
props,
view,
connectionStore,
settingsStore: settings as Options["settingsStore"],
sqlDriverProfile: computed(() => undefined),
t: ((key: string) => key) as Options["t"],
metadata,
batchSelection,
runtime,
isEditorComposing: () => runtime.imeCompositionActive,
debounceDelayMs: 30,
triggerDeferDelayMs: 25,
maxCompletionTables: 100,
onDemandTableLimit: 100,
semanticCompletionEnabled: false,
});
cleanups.push(() => {
completion.clearDeferredCompletionTrigger();
completion.invalidateRequests();
currentView.destroy();
parent.remove();
});
const provide = (explicit = true) => completion.provideSqlCompletions(new CompletionContext(currentView.state, currentView.state.selection.main.head, explicit));
return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion };
}
describe("QueryEditor completion provider ownership", () => {
it("keeps manual completion available while automatic completion is disabled", async () => {
const { settings, provide, completion, currentView, startCompletion } = createHarness({ database: undefined });
settings.editorSettings.completionTriggerMode = "manual";
expect(await provide(false)).toBeNull();
completion.activeOrigin = null;
const manual = await provide(true);
expect(manual?.options.map((option) => option.label)).toContain("SELECT");
expect(completion.triggerSqlCompletion(currentView)).toBe(true);
expect(startCompletion).toHaveBeenCalledWith(currentView);
});
it("does not request metadata or start completion during IME composition", async () => {
const { runtime, provide, completion, currentView, store, startCompletion } = createHarness({ databaseType: "redis" });
runtime.imeCompositionActive = true;
expect(await provide()).toBeNull();
expect(completion.triggerSqlCompletion(currentView)).toBe(false);
completion.scheduleSqlCompletionStart(currentView);
await vi.advanceTimersByTimeAsync(1);
expect(startCompletion).not.toHaveBeenCalled();
expect(store.listRedisCompletionCommandDocs).not.toHaveBeenCalled();
});
it.each(["cursor", "document", "view", "composition", "cancel"])("rejects deferred triggers after a %s change", async (change) => {
const { currentView, view, runtime, completion, startCompletion } = createHarness();
completion.scheduleDeferredCompletionTrigger(currentView, "L", "");
if (change === "cursor") currentView.dispatch({ selection: { anchor: 0 } });
if (change === "document") currentView.dispatch({ changes: { from: 0, insert: " " } });
if (change === "view") view.value = null;
if (change === "composition") runtime.imeCompositionActive = true;
if (change === "cancel") completion.clearDeferredCompletionTrigger();
await vi.advanceTimersByTimeAsync(50);
expect(startCompletion).not.toHaveBeenCalled();
});
it("coalesces valid deferred input and reads late runtime initialization", async () => {
const { currentView, runtime, completion, startCompletion } = createHarness();
runtime.codeMirrorStartCompletion = null;
expect(completion.triggerSqlCompletion(currentView)).toBe(false);
completion.scheduleDeferredCompletionTrigger(currentView, "E", "");
completion.scheduleDeferredCompletionTrigger(currentView, "L", "");
runtime.codeMirrorStartCompletion = startCompletion;
await vi.advanceTimersByTimeAsync(50);
expect(startCompletion).toHaveBeenCalledOnce();
});
it("consumes completion suppression once and respects expiry", () => {
const { completion } = createHarness();
completion.suppressAutoStartUntil = Date.now() + 100;
expect(completion.consumeSqlCompletionAutoStartSuppression()).toBe(true);
expect(completion.consumeSqlCompletionAutoStartSuppression()).toBe(false);
completion.suppressAutoStartUntil = Date.now() - 1;
expect(completion.consumeSqlCompletionAutoStartSuppression()).toBe(false);
});
it("uses only server-provided Redis commands and never fabricates fallback metadata", async () => {
const { store, provide } = createHarness({ databaseType: "redis", database: undefined, modelValue: "" });
const result = await provide();
expect(result?.options.map((option) => option.label)).toEqual(expect.arrayContaining(["GET", "CUSTOM.SERVER.COMMAND"]));
expect(result?.options.map((option) => option.label)).not.toContain("SET");
expect(store.listRedisCompletionCommandDocs).toHaveBeenCalledWith("connection", "0");
expect(store.listRedisCompletionKeys).not.toHaveBeenCalled();
store.listRedisCompletionCommandDocs.mockRejectedValue(new Error("unavailable"));
expect(await provide()).toBeNull();
});
it("loads Redis keys only for a key argument with a resolved database", async () => {
const { props, store, provide } = createHarness({ databaseType: "redis", database: "", modelValue: "GET " });
await provide();
expect(store.listRedisCompletionKeys).not.toHaveBeenCalled();
props.database = "3";
const result = await provide();
expect(store.listRedisCompletionKeys).toHaveBeenCalledWith("connection", "3");
expect(result?.options.map((option) => option.label)).toContain("user:1");
});
it("rejects stale Mongo collection loads after request invalidation", async () => {
const { store, provide, completion } = createHarness({ databaseType: "mongodb", modelValue: "db.us" });
let resolve!: (collections: string[]) => void;
store.listMongoCompletionCollections.mockReturnValue(
new Promise((complete) => {
resolve = complete;
}),
);
const pending = provide();
expect(store.listMongoCompletionCollections).toHaveBeenCalledWith("connection", "demo");
completion.invalidateRequests();
resolve(["users"]);
expect(await pending).toBeNull();
store.listMongoCompletionCollections.mockResolvedValue(["users"]);
expect((await provide())?.options.map((option) => option.label)).toContain("users");
});
it.each(["mongodb", "elasticsearch", "easysearch"] as const)("preserves %s completion and tolerates metadata failures", async (databaseType) => {
const { store, provide } = createHarness({ databaseType, modelValue: databaseType === "mongodb" ? "db.us" : "GET /us" });
expect((await provide())?.options.map((option) => option.label)).toContain("users");
store.listMongoCompletionCollections.mockRejectedValue(new Error("offline"));
store.listElasticsearchCompletionIndices.mockRejectedValue(new Error("offline"));
const fallback = await provide();
expect(fallback?.options.some((option) => option.label === "users") ?? false).toBe(false);
});
it("rejects pending SQL metadata after the editor invalidates the request", async () => {
const { store, completion, provide } = createHarness({ modelValue: "SELECT * FROM us" });
let resolve!: (tables: SqlCompletionTable[]) => void;
store.listCompletionTables.mockReturnValue(
new Promise((complete) => {
resolve = complete;
}),
);
const pending = provide();
await vi.advanceTimersByTimeAsync(31);
expect(store.listCompletionTables).toHaveBeenCalled();
completion.invalidateRequests();
resolve([{ name: "users" }]);
expect(await pending).toBeNull();
});
it.each(["redis", "mongodb", "mysql"] as const)("does not query %s metadata without a connection", async (databaseType) => {
const { store, provide } = createHarness({ databaseType, connectionId: undefined });
expect(await provide()).toBeNull();
expect(store.listCompletionTables).not.toHaveBeenCalled();
expect(store.listMongoCompletionCollections).not.toHaveBeenCalled();
expect(store.listRedisCompletionCommandDocs).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { reactive } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createQueryEditorCodeMirrorRuntime } from "../queryEditorCodeMirrorRuntime";
import { useQueryEditorCompletionKeys } from "../useQueryEditorCompletionKeys";
import type { QueryEditorProps } from "../queryEditorTypes";
type Options = Parameters<typeof useQueryEditorCompletionKeys>[0];
const cleanups: Array<() => void> = [];
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const props = reactive<QueryEditorProps>({ modelValue: "abc", databaseType: "postgres", ...overrides });
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({ doc: props.modelValue, selection: { anchor: props.modelValue.length }, extensions: [EditorState.allowMultipleSelections.of(true)] }) });
const runtime = createQueryEditorCodeMirrorRuntime();
const status = vi.fn<NonNullable<typeof runtime.codeMirrorCompletionStatus>>(() => null);
const accept = vi.fn(() => false);
const snippet = vi.fn(() => false);
const indent = vi.fn(() => true);
const composing = vi.fn(() => false);
const batch = vi.fn(() => false);
const newline = vi.fn((currentView: EditorView) => {
currentView.dispatch(currentView.state.replaceSelection("\n"));
return true;
});
Object.assign(runtime, {
codeMirrorCompletionStatus: status,
codeMirrorAcceptCompletion: accept,
codeMirrorNextSnippetField: snippet,
codeMirrorIndentMore: indent,
codeMirrorCloseCompletion: vi.fn(() => false),
codeMirrorSelectedCompletionIndex: vi.fn(() => null),
codeMirrorSelectFirstCompletion: vi.fn(() => false),
codeMirrorInsertNewlineKeepIndent: newline,
});
const settings = reactive({ editorSettings: { sqlFormatter: { useTabs: false, tabWidth: 2 }, shortcuts: { acceptCompletion: "Tab" }, selectFirstCompletionOnOpen: false, sqlServerSpaceConfirmsCompletion: true } });
const completion = { suppressAutoStartUntil: 0 };
const keys = useQueryEditorCompletionKeys({ props, settingsStore: settings as Options["settingsStore"], runtime, completion, batchSelection: { applySelectedBatchColumnSelection: batch }, isEditorComposing: composing, retryDelayMs: 5, tabMaxWaitMs: 40, enterMaxWaitMs: 20 });
cleanups.push(() => {
keys.clearPendingCompletionEnter();
keys.clearPendingCompletionTab();
view.destroy();
host.remove();
});
return { props, view, runtime, status, accept, snippet, indent, composing, batch, newline, settings, completion, keys };
}
describe("QueryEditor completion-key ownership", () => {
it.each(["handleTab", "handleEnter", "acceptCompletionOrNextSnippetField"] as const)("leaves %s to an active IME", (method) => {
const { keys, view, composing, accept, snippet, indent } = createHarness();
composing.mockReturnValue(true);
expect(keys[method](view)).toBe(false);
expect(accept).not.toHaveBeenCalled();
expect(snippet).not.toHaveBeenCalled();
expect(indent).not.toHaveBeenCalled();
expect(view.state.doc.toString()).toBe("abc");
});
it("preserves ordinary Tab indentation when completion acceptance is remapped", () => {
const { keys, view, settings, status, accept } = createHarness();
settings.editorSettings.shortcuts.acceptCompletion = "Enter";
status.mockReturnValue("active");
expect(keys.handleTab(view)).toBe(true);
expect(view.state.doc.toString()).toBe("abc ");
expect(accept).not.toHaveBeenCalled();
settings.editorSettings.sqlFormatter.useTabs = true;
expect(keys.editorIndentUnit()).toBe("\t");
});
it("prioritizes a selected snippet field over ordinary indentation", () => {
const { keys, view, snippet, indent, accept } = createHarness();
view.dispatch({ selection: { anchor: 0, head: 3 } });
snippet.mockReturnValueOnce(true);
expect(keys.handleTab(view)).toBe(true);
expect(indent).not.toHaveBeenCalled();
expect(keys.handleTab(view)).toBe(true);
expect(indent).toHaveBeenCalledOnce();
expect(accept).not.toHaveBeenCalled();
});
it.each(["handleEnter", "acceptCompletionOrNextSnippetField"] as const)("gives checked-column insertion priority in %s", (method) => {
const { keys, view, status, batch, accept } = createHarness();
status.mockReturnValue("active");
batch.mockReturnValue(true);
expect(keys[method](view)).toBe(true);
expect(batch).toHaveBeenCalledWith(view);
expect(accept).not.toHaveBeenCalled();
});
it("accepts a pending Tab only after completion becomes available", async () => {
const { keys, view, status, runtime, accept, snippet } = createHarness();
status.mockReturnValue("pending");
expect(keys.handleTab(view)).toBe(true);
expect(accept).not.toHaveBeenCalled();
status.mockReturnValue("active");
runtime.codeMirrorSelectedCompletionIndex = () => 0;
accept.mockReturnValue(true);
await vi.advanceTimersByTimeAsync(5);
expect(accept).toHaveBeenCalledOnce();
expect(snippet).not.toHaveBeenCalled();
expect(view.state.doc.toString()).toBe("abc");
});
it("falls back to normal Tab once the pending completion budget expires", async () => {
const { keys, view, status, snippet } = createHarness();
status.mockReturnValue("pending");
keys.handleTab(view);
await vi.advanceTimersByTimeAsync(45);
expect(snippet).toHaveBeenCalledOnce();
expect(view.state.doc.toString()).toBe("abc ");
});
it.each(["document", "secondary-selection", "composition", "cancel"])("invalidates queued Tab after %s changes", async (change) => {
const { keys, view, status, composing, accept, snippet } = createHarness();
status.mockReturnValue("pending");
keys.handleTab(view);
if (change === "document") view.dispatch({ changes: { from: 0, insert: "x" } });
if (change === "secondary-selection") view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(3)], 1) });
if (change === "composition") composing.mockReturnValue(true);
if (change === "cancel") keys.clearPendingCompletionTab();
const document = view.state.doc.toString();
await vi.advanceTimersByTimeAsync(50);
expect(view.state.doc.toString()).toBe(document);
expect(accept).not.toHaveBeenCalled();
expect(snippet).not.toHaveBeenCalled();
});
it("inserts one newline and suppresses automatic completion after an Enter retry expires", async () => {
const { keys, view, status, settings, newline, completion } = createHarness();
status.mockReturnValue("active");
settings.editorSettings.selectFirstCompletionOnOpen = true;
expect(keys.handleEnter(view)).toBe(true);
expect(newline).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(25);
expect(newline).toHaveBeenCalledOnce();
expect(view.state.doc.toString()).toBe("abc\n");
expect(completion.suppressAutoStartUntil).toBeGreaterThan(Date.now());
});
it("cancels Enter retries without cancelling another editor's pending Tab", async () => {
const first = createHarness();
const second = createHarness();
first.settings.editorSettings.selectFirstCompletionOnOpen = true;
first.status.mockReturnValue("active");
second.status.mockReturnValue("pending");
first.keys.handleEnter(first.view);
second.keys.handleTab(second.view);
first.keys.clearPendingCompletionEnter();
await vi.advanceTimersByTimeAsync(50);
expect(first.view.state.doc.toString()).toBe("abc");
expect(first.newline).not.toHaveBeenCalled();
expect(second.view.state.doc.toString()).toBe("abc ");
});
it.each(["users", "users "])("preserves SQL Server Space acceptance and existing whitespace in %s", (sql) => {
const { keys, view, runtime, status, accept } = createHarness({ modelValue: sql, databaseType: "sqlserver" });
view.dispatch({ selection: { anchor: 5 } });
status.mockReturnValue("active");
runtime.codeMirrorSelectedCompletion = () => ({ label: "users", type: "table" });
accept.mockReturnValue(true);
expect(keys.acceptSqlServerCompletionOnSpace(view)).toBe(true);
expect(view.state.doc.toString()).toBe("users ");
expect(view.state.selection.main.head).toBe(6);
});
});
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { reactive, shallowRef } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
vi.mock("@/lib/backend/api", () => ({}));
type Options = Parameters<typeof useQueryEditorCompletionMetadata>[0];
const columns: SqlCompletionColumn[] = [{ name: "id", table: "users", dataType: "int" }];
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT id FROM users", databaseType: "postgres", dialect: "postgres", connectionId: "connection", database: "demo", schema: "public", ...overrides });
const store = {
getConfig: vi.fn(() => undefined),
lookupLocalCompletionColumns: vi.fn((): SqlCompletionColumn[] => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
};
const metadata = useQueryEditorCompletionMetadata({
props,
view: shallowRef<EditorView | null>(null),
connectionStore: store as unknown as Options["connectionStore"],
sqlBehaviorDialect: () => props.dialect,
remoteLatencyBudgetMs: 40,
maxCompletionTables: 100,
onDemandMinPrefix: 2,
semanticCompletionEnabled: true,
});
return { metadata, store, props };
}
describe("QueryEditor completion metadata ownership", () => {
it.each(["getEditorSqlCompletionContext", "getEditorSemanticModel"] as const)("keys %s by document, editor state, position and dialect", (method) => {
const { metadata, props } = createHarness();
const state = EditorState.create({ doc: props.modelValue });
const first = metadata[method](props.modelValue, 8, state);
expect(first).not.toBeNull();
expect(metadata[method](props.modelValue, 8, state)).toBe(first);
const moved = metadata[method](props.modelValue, 9, state);
expect(moved).not.toBe(first);
const selected = state.update({ selection: { anchor: 3 } }).state;
expect(selected.doc).toBe(state.doc);
expect(metadata[method](props.modelValue, 9, selected)).not.toBe(moved);
props.dialect = "mysql";
const changedDialect = metadata[method](props.modelValue, 9, selected);
expect(metadata[method](props.modelValue, 9, selected)).toBe(changedDialect);
props.databaseType = "mysql";
expect(metadata[method](props.modelValue, 9, selected)).not.toBe(changedDialect);
const edited = state.update({ changes: { from: 0, insert: " " } }).state;
expect(metadata[method](edited.doc.toString(), 9, edited)).not.toBe(first);
});
it("prefers local columns and shares successful loads with subsequent callers", async () => {
const { metadata, store } = createHarness();
store.lookupLocalCompletionColumns.mockReturnValue(columns);
expect(await metadata.ensureColumnsForTable({ name: "users" })).toBe(true);
expect(metadata.cachedColumnsByTable.get("public.users")).toBe(columns);
expect(metadata.loadedColumnsByTable.has("public.users")).toBe(true);
expect(metadata.getInsertValueHintTableColumns("users")).toEqual(["id"]);
await metadata.ensureColumnsForTable({ name: "users" });
expect(store.lookupLocalCompletionColumns).toHaveBeenCalledOnce();
expect(store.listCompletionColumns).not.toHaveBeenCalled();
});
it("retries empty remote responses rather than marking them as loaded", async () => {
const { metadata, store } = createHarness();
store.listCompletionColumns.mockResolvedValueOnce([]).mockResolvedValueOnce(columns);
await metadata.ensureColumnsForTable({ name: "users" });
expect(metadata.cachedColumnsByTable.has("public.users")).toBe(false);
expect(metadata.loadedColumnsByTable.size).toBe(0);
await metadata.ensureColumnsForTable({ name: "users" });
expect(store.listCompletionColumns).toHaveBeenCalledTimes(2);
expect(metadata.cachedColumnsByTable.get("public.users")).toBe(columns);
});
it("isolates quoted names and prefix caches by table and schema", () => {
const { metadata } = createHarness();
const table = { name: "users", schema: "public" };
expect(metadata.completionCacheKey({ ...table, nameQuoted: true })).not.toBe(metadata.completionCacheKey(table));
metadata.cachedPrefixColumnsByTable.set(metadata.completionPrefixCacheKey(table, undefined, "us"), [
{ name: "user_id", table: "users" },
{ name: "username", table: "users" },
]);
expect(metadata.lookupCachedPrefixColumns(table, undefined, " USER_ ")).toEqual([{ name: "user_id", table: "users" }]);
expect(metadata.lookupCachedPrefixColumns({ ...table, schema: "archive" }, undefined, "user_")).toBeUndefined();
});
it("returns local tables within the latency budget but still merges scoped remote metadata", async () => {
vi.useFakeTimers();
const { metadata, store } = createHarness({ catalog: "catalog" });
store.lookupLocalCompletionTables.mockReturnValue([{ name: "local", schema: "public" }]);
let resolve!: (tables: SqlCompletionTable[]) => void;
store.listCompletionTables.mockReturnValue(
new Promise((complete) => {
resolve = complete;
}),
);
const loading = metadata.listCompletionTablesWithLatencyBudget("connection", "demo", "", 100, "public");
await vi.advanceTimersByTimeAsync(40);
expect(await loading).toEqual([{ name: "local", schema: "public", database: "demo", catalog: "catalog" }]);
resolve([{ name: "remote", schema: "public" }]);
await Promise.resolve();
expect(metadata.cachedTables).toEqual([{ name: "remote", schema: "public", database: "demo", catalog: "catalog" }]);
});
it("clears shared maps in place and exposes replacement table arrays live", async () => {
const { metadata, store } = createHarness();
const cachedColumns = metadata.cachedColumnsByTable;
store.lookupLocalCompletionColumns.mockReturnValue(columns);
await metadata.ensureColumnsForTable({ name: "users" });
metadata.cachedTables = [{ name: "users" }];
metadata.cachedPrefixColumnsByTable.set("prefix", columns);
metadata.cachedForeignKeysByTable.set("table", []);
metadata.cachedCompletionObjectsByScope.set("scope", []);
expect(metadata.cachedTables).toEqual([{ name: "users" }]);
metadata.refreshCompletionCache();
expect(metadata.cachedColumnsByTable).toBe(cachedColumns);
expect(cachedColumns.size).toBe(0);
expect(metadata.cachedPrefixColumnsByTable.size).toBe(0);
expect(metadata.cachedForeignKeysByTable.size).toBe(0);
expect(metadata.cachedCompletionObjectsByScope.size).toBe(0);
expect(metadata.loadedColumnsByTable.size).toBe(0);
expect(metadata.cachedTables).toEqual([]);
});
});
@@ -0,0 +1,218 @@
// @vitest-environment happy-dom
import { computed, createApp, h, reactive, shallowRef } from "vue";
import { EditorState, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorDiagnostics } from "../useQueryEditorDiagnostics";
import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { SqlReferenceAnalysis } from "@/types/database";
const { analyze, buildDiagnostics } = vi.hoisted(() => ({ analyze: vi.fn(), buildDiagnostics: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ analyzeSqlReferences: analyze }));
vi.mock("@/lib/sql/semantic/diagnostics", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sql/semantic/diagnostics")>()),
buildSqlSemanticDiagnostics: buildDiagnostics,
}));
type Options = Parameters<typeof useQueryEditorDiagnostics>[0];
const cleanups: Array<() => void> = [];
const diagnostic: SqlSemanticDiagnostic = { message: "missing field", severity: "error", span: { start_line: 1, start_column: 8, end_line: 1, end_column: 15 } };
beforeEach(() => {
vi.useFakeTimers();
analyze.mockResolvedValue({ tables: [], columns: [] });
buildDiagnostics.mockReturnValue([diagnostic]);
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT missing;", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides });
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: props.modelValue }) });
const view = shallowRef<EditorView | null>(currentView);
const runtime: Options["runtime"] = { editorIsActive: true, executableStatementRangeCache: null, setSqlDiagnosticsEffect: StateEffect.define<SqlSemanticDiagnostic[]>(), diagnosticComp: null, buildSqlDiagnosticExtension: null, codeMirrorCompletionStatus: () => null };
const settingsStore = { editorSettings: { sqlSemanticDiagnosticsEnabled: true } } as Options["settingsStore"];
let diagnostics!: ReturnType<typeof useQueryEditorDiagnostics>;
const host = document.createElement("div");
const app = createApp({
setup() {
diagnostics = useQueryEditorDiagnostics({
props,
view,
runtime,
settingsStore,
connectionStore: { connectionIdentifierQuote: () => "`" } as Options["connectionStore"],
sqlDriverProfile: computed(() => undefined),
sqlStatementParameterOptions: () => ({}),
sqlBehaviorDialect: () => props.dialect,
semanticCompletionEnabled: false,
maxCompletionTables: 100,
metadata: {
cachedTables: [],
cachedColumnsByTable: new Map(),
loadedColumnsByTable: new Set(),
usesOracleSessionCompletionColumns: () => false,
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
completionCacheKey: (table) => table.name,
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
isMissingTableMetadataError: () => false,
},
});
return () => h("div");
},
});
app.mount(host);
let mounted = true;
const unmount = () => {
if (!mounted) return;
mounted = false;
app.unmount();
};
cleanups.push(() => {
unmount();
currentView.destroy();
parent.remove();
});
return { diagnostics, props, runtime, settingsStore, currentView, unmount };
}
describe("QueryEditor diagnostic scheduling ownership", () => {
it("coalesces timers and shares the latest executable statement cache", async () => {
const { diagnostics, currentView, runtime } = createHarness();
diagnostics.scheduleSemanticDiagnostics(50);
diagnostics.scheduleSemanticDiagnostics(100);
await vi.advanceTimersByTimeAsync(99);
expect(analyze).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(analyze).toHaveBeenCalledOnce();
expect(diagnostics.diagnostics).toEqual([diagnostic]);
expect(runtime.executableStatementRangeCache).not.toBeNull();
const cache = runtime.executableStatementRangeCache;
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(runtime.executableStatementRangeCache).toBe(cache);
expect(currentView.state.doc.toString()).toBe("SELECT missing;");
});
it("clears old decorations and pending work when SQL diagnostics are disabled", async () => {
const { diagnostics, settingsStore } = createHarness();
diagnostics.setSemanticDiagnostics([diagnostic]);
diagnostics.scheduleSemanticDiagnostics(10);
settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled = false;
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(20);
expect(diagnostics.diagnostics).toEqual([]);
expect(analyze).not.toHaveBeenCalled();
});
it("does not schedule work while the editor is inactive", async () => {
const { diagnostics, runtime } = createHarness();
runtime.editorIsActive = false;
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(2000);
expect(analyze).not.toHaveBeenCalled();
});
it.each([{ connectionId: undefined }, { database: undefined }, { modelValue: " " }, { databaseType: "elasticsearch" as const }])("clears diagnostics without SQL analysis for unsupported context %j", async (overrides) => {
const { diagnostics } = createHarness(overrides);
diagnostics.setSemanticDiagnostics([diagnostic]);
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(diagnostics.diagnostics).toEqual([]);
expect(analyze).not.toHaveBeenCalled();
});
it("rejects an old analysis after document invalidation", async () => {
let resolve!: (analysis: SqlReferenceAnalysis) => void;
analyze.mockImplementationOnce(
() =>
new Promise<SqlReferenceAnalysis>((done) => {
resolve = done;
}),
);
const { diagnostics, currentView } = createHarness();
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(analyze).toHaveBeenCalledOnce();
currentView.dispatch({ changes: { from: 0, to: currentView.state.doc.length, insert: "SELECT 1;" } });
diagnostics.invalidateSemanticDiagnosticsForDocumentChange();
resolve({ tables: [], columns: [] });
await vi.advanceTimersByTimeAsync(0);
expect(buildDiagnostics).not.toHaveBeenCalled();
expect(diagnostics.diagnostics).toEqual([]);
});
it("invalidates in-flight analysis on unmount and cancels scheduled work", async () => {
let resolve!: (analysis: SqlReferenceAnalysis) => void;
analyze.mockImplementationOnce(
() =>
new Promise<SqlReferenceAnalysis>((done) => {
resolve = done;
}),
);
const { diagnostics, unmount, currentView } = createHarness();
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
diagnostics.scheduleSemanticDiagnostics(500);
unmount();
const dispatch = vi.spyOn(currentView, "dispatch");
resolve({ tables: [], columns: [] });
await vi.advanceTimersByTimeAsync(1000);
expect(analyze).toHaveBeenCalledOnce();
expect(dispatch).not.toHaveBeenCalled();
expect(buildDiagnostics).not.toHaveBeenCalled();
});
it.each([
{ databaseType: "redis" as const, modelValue: "GET" },
{ databaseType: "mongodb" as const, modelValue: "db.users.unknownMethod()" },
])("preserves native diagnostics with SQL diagnostics disabled for $databaseType", async (overrides) => {
const { diagnostics, settingsStore } = createHarness(overrides);
settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled = false;
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(diagnostics.diagnostics.length).toBeGreaterThan(0);
expect(analyze).not.toHaveBeenCalled();
});
it("keeps unfinished MongoDB expressions under the cursor free of premature errors", async () => {
const { diagnostics } = createHarness({ databaseType: "mongodb", modelValue: "db.users.find({" });
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(diagnostics.diagnostics).toEqual([]);
expect(analyze).not.toHaveBeenCalled();
});
it("keeps completed MongoDB warnings while the cursor is in a later unfinished command", async () => {
const sql = "db.a.deleteMany({});\ndb.b.find({";
const { diagnostics, currentView } = createHarness({ databaseType: "mongodb", modelValue: sql });
currentView.dispatch({ selection: { anchor: sql.length } });
diagnostics.scheduleSemanticDiagnostics(0);
await vi.advanceTimersByTimeAsync(0);
expect(diagnostics.diagnostics).toHaveLength(1);
expect(diagnostics.diagnostics[0].severity).toBe("warning");
expect(diagnostics.diagnostics[0].message).toContain("every document in a");
expect(analyze).not.toHaveBeenCalled();
});
it("does not dispatch unchanged diagnostics or rewrite editor selection", () => {
const { diagnostics, currentView } = createHarness();
currentView.dispatch({ selection: { anchor: 3 } });
const dispatch = vi.spyOn(currentView, "dispatch");
diagnostics.setSemanticDiagnostics([diagnostic]);
diagnostics.setSemanticDiagnostics([structuredClone(diagnostic)]);
expect(dispatch).toHaveBeenCalledOnce();
expect(dispatch.mock.calls[0][0]).not.toHaveProperty("selection");
expect(currentView.state.selection.main.head).toBe(3);
});
});
@@ -0,0 +1,196 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, onBeforeUnmount, reactive, ref, shallowRef } from "vue";
import { Compartment, EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo } from "@codemirror/commands";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorDocumentState } from "../useQueryEditorDocumentState";
import type { QueryEditorProps } from "../queryEditorTypes";
const cleanups: Array<() => void> = [];
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(EditorView.prototype, "focus").mockImplementation(() => {});
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT 1;", tabId: "tab-a", autoFocus: false, ...overrides });
const historyResetComp = new Compartment();
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: props.modelValue, extensions: [historyResetComp.of(history())] }) });
const view = shallowRef<EditorView | null>(currentView);
const runtime = { historyResetComp, codeMirrorHistory: history, codeMirrorEditorSelection: EditorSelection, editorIsActive: true };
const emit = vi.fn();
const callbacks = {
scheduleSemanticDiagnostics: vi.fn(),
clearScheduledPreviewContextRefresh: vi.fn(),
syncContextMenuState: vi.fn(),
applyEditorAppearance: vi.fn().mockResolvedValue(undefined),
applyEditorShortcutKeymaps: vi.fn(),
applyEditorIndentExtension: vi.fn(),
applyEditorCompletionExtension: vi.fn(),
invalidateSemanticDiagnosticsForDocumentChange: vi.fn(),
currentEditorDocText: (editor: EditorView) => editor.state.doc.toString(),
scheduleDocumentSearchUpdate: vi.fn(),
isEditorComposing: vi.fn(() => false),
};
let state!: ReturnType<typeof useQueryEditorDocumentState>;
const app = createApp({
setup() {
state = useQueryEditorDocumentState({ props, view, runtime, emit, previewContextSql: ref(""), ...callbacks });
state.attach();
onBeforeUnmount(() => {
state.flushBeforeDeactivation();
state.dispose();
});
return () => h("div");
},
});
const host = document.createElement("div");
app.mount(host);
let mounted = true;
const unmount = () => {
if (!mounted) return;
mounted = false;
app.unmount();
};
cleanups.push(() => {
unmount();
currentView.destroy();
parent.remove();
});
async function activate(tabId: string, modelValue: string, saved: Partial<QueryEditorProps> = {}) {
Object.assign(props, { tabId, modelValue, initialSelection: undefined, initialViewport: undefined }, saved);
await nextTick();
}
async function append(text: string) {
currentView.dispatch({ changes: { from: currentView.state.doc.length, insert: text } });
props.modelValue = currentView.state.doc.toString();
await nextTick();
}
return { state, props, runtime, emit, callbacks, currentView, activate, append, unmount };
}
describe("QueryEditor document and viewport ownership", () => {
it("isolates undo on new tabs and restores each cached tab's own history", async () => {
const { currentView, activate, append, callbacks } = createHarness();
await append(" -- a");
await activate("tab-b", "SELECT 2;");
expect(undo(currentView)).toBe(false);
await append(" -- b");
await activate("tab-a", "SELECT 1; -- a");
expect(undo(currentView)).toBe(true);
expect(currentView.state.doc.toString()).toBe("SELECT 1;");
await activate("tab-b", "SELECT 2; -- b");
expect(undo(currentView)).toBe(true);
expect(currentView.state.doc.toString()).toBe("SELECT 2;");
expect(callbacks.applyEditorAppearance).toHaveBeenCalledTimes(2);
expect(callbacks.applyEditorShortcutKeymaps).toHaveBeenCalledTimes(2);
expect(callbacks.applyEditorIndentExtension).toHaveBeenCalledTimes(2);
expect(callbacks.applyEditorCompletionExtension).toHaveBeenCalledTimes(2);
expect(callbacks.scheduleDocumentSearchUpdate).toHaveBeenCalledTimes(2);
});
it("bounds cached histories and treats an evicted tab as a fresh document", async () => {
const { currentView, activate, append } = createHarness();
await append(" -- a");
for (let index = 0; index < 17; index++) await activate(`tab-${index}`, `SELECT ${index};`);
await activate("tab-a", "SELECT 1; -- a");
expect(currentView.state.doc.toString()).toBe("SELECT 1; -- a");
expect(undo(currentView)).toBe(false);
});
it("keeps external edits to a cached inactive document undoable", async () => {
const { currentView, activate } = createHarness();
await activate("tab-b", "SELECT 2;");
await activate("tab-a", "SELECT 100;");
expect(undo(currentView)).toBe(true);
expect(currentView.state.doc.toString()).toBe("SELECT 1;");
});
it("restores saved selection and viewport, without leaking them into a brand-new tab", async () => {
const { currentView, activate, callbacks } = createHarness();
await activate("tab-b", "SELECT 200;", { initialSelection: { anchor: 7, head: 10 }, initialViewport: { scrollTop: 300, scrollLeft: 40 } });
expect(currentView.state.selection.main.anchor).toBe(7);
expect(currentView.state.selection.main.head).toBe(10);
expect(currentView.scrollDOM.scrollTop).toBe(300);
expect(currentView.scrollDOM.scrollLeft).toBe(40);
expect(callbacks.syncContextMenuState).toHaveBeenCalledWith(currentView);
await activate("tab-c", "SELECT 3;");
expect(currentView.state.selection.main.head).toBe(0);
expect(currentView.scrollDOM.scrollTop).toBe(0);
expect(currentView.scrollDOM.scrollLeft).toBe(0);
});
it("flushes a deferred viewport with its outgoing tab owner and deduplicates emissions", async () => {
const { state, currentView, activate, emit } = createHarness();
currentView.scrollDOM.scrollTop = 240;
state.scheduleEditorViewportEmit();
await activate("tab-b", "SELECT 2;");
expect(emit.mock.calls.filter(([event]) => event === "viewportChange")).toEqual([["viewportChange", { scrollTop: 240, scrollLeft: 0 }, "tab-a"]]);
await vi.advanceTimersByTimeAsync(200);
expect(emit.mock.calls.filter(([event]) => event === "viewportChange")).toHaveLength(1);
currentView.scrollDOM.scrollTop = 100;
state.scheduleEditorViewportEmit();
await vi.advanceTimersByTimeAsync(200);
expect(emit).toHaveBeenCalledWith("viewportChange", { scrollTop: 100, scrollLeft: 0 }, "tab-b");
});
it("captures state before tab deactivation and never flushes a reset inactive viewport", () => {
const { state, currentView, runtime, emit } = createHarness();
currentView.scrollDOM.scrollTop = 220;
currentView.dispatch({ selection: { anchor: 4 } });
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { fromTabId: "another-tab" } }));
expect(emit).not.toHaveBeenCalled();
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { fromTabId: "tab-a" } }));
expect(emit).toHaveBeenCalledWith("viewportChange", { scrollTop: 220, scrollLeft: 0 }, "tab-a");
expect(emit).toHaveBeenCalledWith("selectionStateChange", { anchor: 4, head: 4 });
emit.mockClear();
currentView.scrollDOM.scrollTop = 0;
state.flushBeforeDeactivation();
runtime.editorIsActive = false;
state.flushBeforeDeactivation();
expect(emit).not.toHaveBeenCalled();
});
it("hydrates saved state after mount and respects IME composition for external content updates", async () => {
const { props, currentView, callbacks } = createHarness();
props.initialViewport = { scrollTop: 280, scrollLeft: 30 };
props.initialSelection = { anchor: 3, head: 3 };
await nextTick();
expect(currentView.scrollDOM.scrollTop).toBe(280);
expect(currentView.state.selection.main.head).toBe(3);
callbacks.isEditorComposing.mockReturnValue(true);
props.modelValue = "SELECT blocked;";
await nextTick();
expect(currentView.state.doc.toString()).toBe("SELECT 1;");
callbacks.isEditorComposing.mockReturnValue(false);
props.modelValue = "SELECT allowed;";
await nextTick();
expect(currentView.state.doc.toString()).toBe("SELECT allowed;");
});
it("cancels pending viewport work and removes listeners when unmounted", async () => {
const { state, currentView, runtime, emit, unmount } = createHarness();
currentView.scrollDOM.scrollTop = 500;
state.scheduleEditorViewportEmit();
state.restoreEditorViewport({ scrollTop: 500, scrollLeft: 0 });
await nextTick();
runtime.editorIsActive = false;
unmount();
emit.mockClear();
window.dispatchEvent(new CustomEvent("dbx:before-tab-switch", { detail: { fromTabId: "tab-a" } }));
await vi.advanceTimersByTimeAsync(2000);
expect(emit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { reactive, ref, shallowRef } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorHover } from "../useQueryEditorHover";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
const { loadDdl, getColumns, listIndexes, getTableComment } = vi.hoisted(() => ({ loadDdl: vi.fn(), getColumns: vi.fn(), listIndexes: vi.fn(), getTableComment: vi.fn() }));
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
vi.mock("@/lib/backend/api", () => ({ getColumns, listIndexes, getTableComment }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({ loadObjectDdl: loadDdl }));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: async (_request: unknown, _facet: string, load: () => Promise<unknown>) => ({ value: await load(), cacheStatus: "remote" }) }));
type Options = Parameters<typeof useQueryEditorHover>[0];
const cleanups: Array<() => void> = [];
beforeEach(() => {
loadDdl.mockResolvedValue({ ddl: 'CREATE TABLE "users" ("id" INTEGER);' });
getColumns.mockResolvedValue([{ name: "id", data_type: "INTEGER", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]);
listIndexes.mockResolvedValue([]);
getTableComment.mockResolvedValue(null);
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}, semanticCompletionEnabled = false) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT * FROM users", connectionId: "connection", database: "demo", schema: "public", databaseType: "postgres", dialect: "postgres", ...overrides });
const currentView = new EditorView({ state: EditorState.create({ doc: props.modelValue }) });
cleanups.push(() => currentView.destroy());
const store = {
getConfig: vi.fn(() => undefined),
lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []),
listCompletionTables: vi.fn<Options["connectionStore"]["listCompletionTables"]>(async () => []),
lookupLocalCompletionColumns: vi.fn((): SqlCompletionColumn[] => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
};
const connectionStore = store as unknown as Options["connectionStore"];
const metadata = useQueryEditorCompletionMetadata({ props, view: shallowRef<EditorView | null>(currentView), connectionStore, sqlBehaviorDialect: () => props.dialect, maxCompletionTables: 200, remoteLatencyBudgetMs: 40, onDemandMinPrefix: 2, semanticCompletionEnabled });
const settings = reactive({ editorSettings: { tableHoverLookupMode: "fallback", showTableDdlHoverPreview: true, generateSqlQuoteIdentifiers: true, generateSqlIncludeDatabaseName: false, excludeDdlStorage: false, sqlFormatter: {} } });
const contextMenuOpen = ref(false);
const createHoverDom = vi.fn((_title: string, _detail: string, _sql?: string, _rows?: string[]) => ({ dom: document.createElement("div") }));
const hover = useQueryEditorHover({ props, contextMenuOpen, settingsStore: settings as Options["settingsStore"], connectionStore, metadata, createHoverDom, maxCompletionTables: 200, semanticCompletionEnabled });
const resolve = (position = props.modelValue.lastIndexOf("users") + 2) => hover.resolveSqlHoverTooltip(currentView, position);
return { props, currentView, store, metadata, settings, contextMenuOpen, createHoverDom, resolve };
}
describe("QueryEditor hover resolution", () => {
it.each(["connection", "database", "menu"])("does no metadata work without an available %s context", async (missing) => {
const harness = createHarness();
if (missing === "connection") harness.props.connectionId = undefined;
if (missing === "database") harness.props.database = undefined;
if (missing === "menu") harness.contextMenuOpen.value = true;
expect(await harness.resolve()).toBeNull();
expect(harness.store.lookupLocalCompletionTables).not.toHaveBeenCalled();
expect(loadDdl).not.toHaveBeenCalled();
});
it("prefers local tables and preserves catalog scope for persisted DDL", async () => {
const { store, metadata, resolve, createHoverDom } = createHarness({ catalog: "catalog" });
store.lookupLocalCompletionTables.mockReturnValue([{ name: "users", schema: "public", database: "demo", catalog: "catalog" }]);
const tooltip = await resolve();
expect(tooltip).not.toBeNull();
tooltip!.create();
expect(store.listCompletionTables).not.toHaveBeenCalled();
expect(loadDdl).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "connection", database: "demo", schema: "public", tableName: "users", catalog: "catalog" }));
expect(metadata.cachedTables).toHaveLength(1);
expect(createHoverDom).toHaveBeenCalledWith("users", expect.any(String), expect.stringContaining("create table"), undefined);
expect(getColumns).not.toHaveBeenCalled();
});
it("falls back from scoped to global metadata only in fallback mode", async () => {
const { store, resolve } = createHarness();
store.listCompletionTables.mockResolvedValueOnce([]).mockResolvedValueOnce([{ name: "users", schema: "archive" }]);
expect(await resolve()).not.toBeNull();
expect(store.listCompletionTables.mock.calls.map((call) => [call[4], call[5]])).toEqual([
["public", false],
[undefined, true],
]);
expect(loadDdl).toHaveBeenCalledWith(expect.objectContaining({ schema: "archive" }));
});
it("falls back to column metadata when DDL loading fails", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
loadDdl.mockRejectedValue(new Error("DDL unavailable"));
listIndexes.mockRejectedValue(new Error("indexes unavailable"));
const { store, resolve, createHoverDom } = createHarness();
store.lookupLocalCompletionTables.mockReturnValue([{ name: "users", schema: "public" }]);
const tooltip = await resolve();
tooltip!.create();
expect(getColumns).toHaveBeenCalledWith("connection", "demo", "public", "users", undefined);
expect(createHoverDom.mock.calls[0][2]).toContain("create table");
expect(createHoverDom.mock.calls[0][2]).toContain("id");
expect(createHoverDom.mock.calls[0][3]).toBeUndefined();
});
it("shows an explicit structure failure instead of inventing an empty DDL", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
loadDdl.mockRejectedValue(new Error("offline"));
getColumns.mockRejectedValue(new Error("offline"));
const { store, resolve, createHoverDom } = createHarness();
store.lookupLocalCompletionTables.mockReturnValue([{ name: "users", schema: "public" }]);
(await resolve())!.create();
expect(createHoverDom).toHaveBeenCalledWith("users", expect.any(String), undefined, ["[DBX] Failed to load table structure — check connection"]);
});
it("suppresses a late DDL tooltip when a context menu opens during its request", async () => {
let finish!: (result: { ddl: string }) => void;
loadDdl.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const { store, resolve, contextMenuOpen } = createHarness();
store.lookupLocalCompletionTables.mockReturnValue([{ name: "users", schema: "public" }]);
const pending = resolve();
await vi.waitFor(() => expect(loadDdl).toHaveBeenCalled());
contextMenuOpen.value = true;
finish({ ddl: "CREATE TABLE users (id INTEGER);" });
expect(await pending).toBeNull();
});
it("keeps column hover when table DDL preview is disabled", async () => {
const { store, settings, resolve, createHoverDom } = createHarness({ modelValue: "SELECT u.name FROM users u" });
settings.editorSettings.showTableDdlHoverPreview = false;
store.lookupLocalCompletionColumns.mockReturnValue([{ name: "name", table: "users", schema: "public", dataType: "varchar", comment: "Display name" }]);
(await resolve(10))!.create();
expect(createHoverDom).toHaveBeenCalledWith("name", "varchar", undefined, ["public.users", "Display name"]);
expect(loadDdl).not.toHaveBeenCalled();
});
it("resolves CTE column origins through the shared cache before physical-table fallback", async () => {
const sql = "WITH named_users AS (SELECT name FROM users) SELECT name FROM named_users";
const { store, resolve, createHoverDom, metadata } = createHarness({ modelValue: sql }, true);
store.lookupLocalCompletionColumns.mockReturnValue([{ name: "name", table: "users", schema: "public", dataType: "varchar" }]);
(await resolve(sql.lastIndexOf("SELECT name") + 8))!.create();
expect(createHoverDom).toHaveBeenCalledWith("name", "varchar", undefined, ["public.users"]);
expect(metadata.cachedColumnsByTable.get("public.users")).toHaveLength(1);
expect(store.lookupLocalCompletionTables).not.toHaveBeenCalled();
expect(loadDdl).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive, shallowRef } from "vue";
import { createI18n } from "vue-i18n";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorIntentions } from "../useQueryEditorIntentions";
import QueryEditorIntentionPopup from "../QueryEditorIntentionPopup.vue";
import type { QueryEditorProps } from "../queryEditorTypes";
const metadata = vi.hoisted(() => ({ loadTableMetadata: vi.fn() }));
vi.mock("@/lib/metadata/tableMetadataCache", () => metadata);
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.restoreAllMocks();
metadata.loadTableMetadata.mockReset();
});
function mountIntentions(sql = "SELECT user_id FROM sys_user AS su", cursor = 14) {
const host = document.createElement("div");
const editorHost = document.createElement("div");
document.body.append(host, editorHost);
const view = new EditorView({ doc: sql, parent: editorHost });
view.dispatch({ selection: { anchor: cursor } });
const coords = vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 10, right: 20, top: 30, bottom: 40 });
const props = reactive<QueryEditorProps>({ modelValue: sql, databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "db" });
let controller!: ReturnType<typeof useQueryEditorIntentions>;
const app = createApp({
setup() {
controller = useQueryEditorIntentions({ props, view: shallowRef(view), sqlBehaviorDialect: () => props.dialect, focusEditor: () => view.focus() });
return () =>
h(QueryEditorIntentionPopup, {
state: controller.intentionPopup.value,
onClose: controller.closeIntentionPopup,
onConfirm: controller.executeIntentionAction,
onSelect: (index) => {
if (controller.intentionPopup.value) controller.intentionPopup.value.selectedIndex = index;
},
});
},
});
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
let mounted = true;
const unmount = () => {
if (!mounted) return;
mounted = false;
app.unmount();
view.destroy();
host.remove();
editorHost.remove();
};
cleanups.push(unmount);
return { controller, view, props, coords, unmount };
}
function press(key: string) {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
document.dispatchEvent(event);
return event;
}
describe("QueryEditor intention popup extraction", () => {
it("renders the real intention at the cursor and confirms it by keyboard", async () => {
const { controller, view } = mountIntentions();
expect(controller.handleSqlIntentionActions(view)).toBe(true);
await nextTick();
const popup = document.querySelector<HTMLElement>(".intention-popup")!;
expect(popup.style.left).toBe("28px");
expect(popup.style.top).toBe("44px");
expect(popup.textContent).toContain("intentionQualifyIdentifier");
expect(press("Enter").defaultPrevented).toBe(true);
await nextTick();
expect(view.state.doc.toString()).toBe("SELECT `su`.`user_id` FROM sys_user AS su");
expect(document.querySelector(".intention-popup")).toBeNull();
expect(view.hasFocus).toBe(true);
expect(press("ArrowDown").defaultPrevented).toBe(false);
});
it("keeps keyboard selection bounded and forwards mouse hover and click", async () => {
const { controller, view } = mountIntentions();
controller.handleSqlIntentionActions(view);
const popup = controller.intentionPopup.value!;
popup.actions.push({ ...popup.actions[0], label: "Alternative", replacement: "`other`.`user_id`" });
press("ArrowDown");
press("ArrowDown");
expect(popup.selectedIndex).toBe(1);
press("ArrowUp");
press("ArrowUp");
expect(popup.selectedIndex).toBe(0);
await nextTick();
const alternative = document.querySelectorAll<HTMLElement>(".intention-popup-item")[1];
expect(alternative.textContent).toContain("Alternative");
alternative.dispatchEvent(new MouseEvent("mouseenter"));
expect(popup.selectedIndex).toBe(1);
alternative.click();
expect(view.state.doc.toString()).toBe("SELECT `other`.`user_id` FROM sys_user AS su");
});
it.each(["Escape", "outside"])("cancels with %s and releases its keyboard handler", async (method) => {
const { controller, view } = mountIntentions();
const sql = view.state.doc.toString();
controller.handleSqlIntentionActions(view);
await nextTick();
if (method === "Escape") expect(press("Escape").defaultPrevented).toBe(true);
else document.querySelector<HTMLElement>(".intention-popup-overlay")!.click();
expect(controller.intentionPopup.value).toBeNull();
expect(view.state.doc.toString()).toBe(sql);
expect(press("Enter").defaultPrevented).toBe(false);
});
it("does not open for read-only editors, missing cursor coordinates or non-actionable SQL", () => {
const { controller, view, props, coords } = mountIntentions();
props.readOnly = true;
expect(controller.handleSqlIntentionActions(view)).toBe(false);
props.readOnly = false;
coords.mockReturnValue(null);
expect(controller.handleSqlIntentionActions(view)).toBe(false);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "SELECT 1" }, selection: { anchor: 0 } });
expect(controller.handleSqlIntentionActions(view)).toBe(false);
expect(controller.intentionPopup.value).toBeNull();
});
it("applies batch replacements from right to left without offset drift", () => {
const { controller, view } = mountIntentions("SELECT id, name FROM users", 8);
controller.handleSqlIntentionActions(view);
controller.executeIntentionAction({
kind: "batch_qualify_identifiers",
span: { start: 7, end: 15 },
replacement: "",
replacements: [
{ span: { start: 7, end: 9 }, replacement: "users.id" },
{ span: { start: 11, end: 15 }, replacement: "users.name" },
],
});
expect(view.state.doc.toString()).toBe("SELECT users.id, users.name FROM users");
});
it.each([false, true])("keeps asynchronous wildcard expansion guarded against a changed document (%s)", async (changeDocument) => {
let resolveMetadata!: (value: unknown) => void;
metadata.loadTableMetadata.mockReturnValue(
new Promise((resolve) => {
resolveMetadata = resolve;
}),
);
const { controller, view } = mountIntentions("SELECT * FROM users", 8);
expect(controller.handleSqlIntentionActions(view)).toBe(true);
const action = controller.intentionPopup.value!.actions.find((candidate) => candidate.kind === "expand_wildcard")!;
controller.executeIntentionAction(action);
await vi.waitFor(() => expect(metadata.loadTableMetadata).toHaveBeenCalledTimes(1));
if (changeDocument) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "SELECT 2" } });
resolveMetadata({ metadata: { columns: [{ name: "id" }, { name: "name" }] } });
await new Promise((resolve) => setTimeout(resolve, 0));
await vi.waitFor(() => {
if (changeDocument) expect(view.state.doc.toString()).toBe("SELECT 2");
else expect(view.state.doc.toString()).toMatch(/SELECT .*id.*name.* FROM users/);
});
await nextTick();
if (changeDocument) expect(view.state.doc.toString()).toBe("SELECT 2");
});
it("leaves the document unchanged when metadata loading fails", async () => {
metadata.loadTableMetadata.mockRejectedValue(new Error("metadata unavailable"));
const { controller, view } = mountIntentions("SELECT * FROM users", 8);
controller.handleSqlIntentionActions(view);
controller.executeIntentionAction(controller.intentionPopup.value!.actions[0]);
await vi.waitFor(() => expect(metadata.loadTableMetadata).toHaveBeenCalledTimes(1));
await nextTick();
expect(view.state.doc.toString()).toBe("SELECT * FROM users");
});
it("removes the teleported popup and keyboard handler on unmount", async () => {
const { controller, view, unmount } = mountIntentions();
const remove = vi.spyOn(document, "removeEventListener");
controller.handleSqlIntentionActions(view);
await nextTick();
unmount();
expect(remove.mock.calls.some(([event]) => event === "keydown")).toBe(true);
expect(document.querySelector(".intention-popup-overlay")).toBeNull();
expect(press("Enter").defaultPrevented).toBe(false);
});
});
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
import { createApp, h, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorLayout } from "../useQueryEditorLayout";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
import { uiTuning } from "@/lib/app/uiTuning";
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
endPanelResize();
vi.restoreAllMocks();
});
function mountLayout(width = 800, height = 600) {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 0;
const request = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
});
const cancel = vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frame) => {
frames.delete(frame);
});
const host = document.createElement("div");
Object.defineProperties(host, { clientWidth: { get: () => width }, clientHeight: { get: () => height } });
document.body.append(host);
const root = ref<HTMLDivElement>();
const app = createApp({
setup() {
useQueryEditorLayout(root);
return () => h("div", { ref: root });
},
});
app.mount(host);
let mounted = true;
const unmount = () => {
if (mounted) {
app.unmount();
host.remove();
mounted = false;
}
};
cleanups.push(unmount);
const step = () => {
const [frame, callback] = frames.entries().next().value!;
frames.delete(frame);
callback(0);
};
return {
root: root.value!,
frames,
request,
cancel,
step,
unmount,
resize: (nextWidth: number) => {
width = nextWidth;
},
};
}
describe("QueryEditor resize layout extraction", () => {
it("pins synchronously, throttles frame updates and releases on pointer-up", () => {
const { root, frames, step, resize } = mountLayout();
beginPanelResize();
expect(root.style.width).toBe("800px");
expect(root.style.height).toBe("600px");
resize(900);
for (let frame = 1; frame < uiTuning.value.panelResizeTrackEveryFrames; frame++) step();
expect(root.style.width).toBe("800px");
step();
expect(root.style.width).toBe("900px");
endPanelResize();
expect(root.style.width).toBe("");
expect(root.style.height).toBe("");
expect(root.style.maxWidth).toBe("");
expect(root.style.minHeight).toBe("");
expect(frames.size).toBe(0);
});
it("does not schedule frames for an invisible editor", () => {
const { root, request } = mountLayout(0, 0);
beginPanelResize();
expect(request).not.toHaveBeenCalled();
expect(root.style.width).toBe("");
});
it("cancels the active frame and watcher when unmounted mid-drag", () => {
const { unmount, frames, cancel, request } = mountLayout();
beginPanelResize();
unmount();
expect(cancel).toHaveBeenCalledTimes(1);
expect(frames.size).toBe(0);
endPanelResize();
beginPanelResize();
expect(request).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,179 @@
// @vitest-environment happy-dom
import { EditorState, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { reactive, shallowRef } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createQueryEditorCodeMirrorRuntime } from "../queryEditorCodeMirrorRuntime";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import { useQueryEditorObjectNavigation } from "../useQueryEditorObjectNavigation";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { SqlCompletionColumn, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
vi.mock("@/lib/backend/api", () => ({}));
type Options = Parameters<typeof useQueryEditorObjectNavigation>[0];
const cleanups: Array<() => void> = [];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
async function createHarness(overrides: Partial<QueryEditorProps> = {}, semanticCompletionEnabled = false) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT * FROM users", databaseType: "postgres", dialect: "postgres", connectionId: "connection", database: "demo", schema: "public", ...overrides });
const host = document.createElement("div");
document.body.append(host);
const currentView = new EditorView({ parent: host, state: EditorState.create({ doc: props.modelValue }) });
const position = vi.spyOn(currentView, "posAtCoords").mockReturnValue(props.modelValue.lastIndexOf("users") + 2);
vi.spyOn(currentView, "focus").mockImplementation(() => {});
const view = shallowRef<EditorView | null>(currentView);
const store = {
getConfig: vi.fn(() => undefined),
lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []),
lookupLocalCompletionObjects: vi.fn((): SqlCompletionObject[] => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
listCompletionObjects: vi.fn(async (): Promise<SqlCompletionObject[]> => []),
lookupLocalCompletionColumns: vi.fn((): SqlCompletionColumn[] => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
};
const connectionStore = store as unknown as Options["connectionStore"];
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, maxCompletionTables: 200, remoteLatencyBudgetMs: 40, onDemandMinPrefix: 2, semanticCompletionEnabled });
const runtime = createQueryEditorCodeMirrorRuntime();
await runtime.load();
runtime.setResultSourceRangeEffect = StateEffect.define<{ from: number; to: number } | null>();
const settings = { editorSettings: { tableHoverLookupMode: "current" } } as Options["settingsStore"];
const emit = vi.fn();
const dismissHoverTooltip = vi.fn();
const startEditorSelectionDrag = vi.fn(() => false);
const navigation = useQueryEditorObjectNavigation({ props, view, editorRef: shallowRef(host), settingsStore: settings, connectionStore, metadata, runtime, dismissHoverTooltip, startEditorSelectionDrag, emit, semanticCompletionEnabled, maxCompletionTables: 200 });
navigation.attach();
cleanups.push(() => {
navigation.dispose();
currentView.destroy();
host.remove();
});
const event = (overrides: MouseEventInit = {}) => new MouseEvent("mousedown", { ctrlKey: true, button: 0, clientX: 20, clientY: 10, cancelable: true, ...overrides });
const click = async (overrides: MouseEventInit = {}) => {
const pointer = event(overrides);
const handled = navigation.onEditorMouseDown(pointer);
await vi.advanceTimersByTimeAsync(0);
return { handled, pointer };
};
return { props, view, currentView, position, host, store, metadata, runtime, emit, dismissHoverTooltip, startEditorSelectionDrag, navigation, event, click };
}
describe("QueryEditor object navigation ownership", () => {
it("gives existing selection dragging priority over object navigation", async () => {
const harness = await createHarness();
harness.startEditorSelectionDrag.mockReturnValue(true);
expect((await harness.click()).handled).toBe(true);
expect(harness.dismissHoverTooltip).toHaveBeenCalledOnce();
expect(harness.store.lookupLocalCompletionTables).not.toHaveBeenCalled();
expect(harness.emit).not.toHaveBeenCalled();
});
it.each([{ ctrlKey: true, altKey: true }, { ctrlKey: true, button: 2 }, { ctrlKey: false }])("preserves non-navigation mouse gestures %j", async (modifiers) => {
const { click, store, emit } = await createHarness();
expect((await click(modifiers)).handled).toBe(false);
expect(store.lookupLocalCompletionTables).not.toHaveBeenCalled();
if (!modifiers.ctrlKey) expect(emit).toHaveBeenCalledWith("closeColumnPanel");
else expect(emit).not.toHaveBeenCalled();
});
it("ignores missing connection contexts and non-identifier positions", async () => {
const { props, position, click, store } = await createHarness();
position.mockReturnValue(2);
expect((await click()).handled).toBe(false);
position.mockReturnValue(null);
expect((await click()).handled).toBe(false);
props.connectionId = undefined;
expect((await click()).handled).toBe(false);
expect(store.lookupLocalCompletionTables).not.toHaveBeenCalled();
});
it.each(["control", "command"])("opens a cached table with the %s modifier before any routine or remote lookup", async (modifier) => {
const { store, emit, click } = await createHarness();
store.lookupLocalCompletionTables.mockReturnValue([{ name: "users", schema: "public", type: "table" }]);
const result = await click(modifier === "command" ? { ctrlKey: false, metaKey: true } : {});
expect(result.handled).toBe(true);
expect(result.pointer.defaultPrevented).toBe(true);
expect(emit).toHaveBeenCalledWith("clickTable", expect.objectContaining({ name: "users", schema: "public" }));
expect(store.lookupLocalCompletionObjects).not.toHaveBeenCalled();
expect(store.listCompletionTables).not.toHaveBeenCalled();
});
it("resolves relation column lists as tables rather than routine calls", async () => {
const { store, emit, click } = await createHarness({ modelValue: "INSERT INTO users(id) VALUES (1)" });
store.listCompletionTables.mockResolvedValue([{ name: "users", schema: "public" }]);
await click();
expect(store.listCompletionTables).toHaveBeenCalled();
expect(store.listCompletionObjects).not.toHaveBeenCalled();
expect(store.lookupLocalCompletionObjects).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith("clickTable", expect.objectContaining({ name: "users" }));
});
it("opens cached routine metadata without changing its stored identifier case", async () => {
const { store, position, emit, click } = await createHarness({ modelValue: 'CALL "Cleanup"()', databaseType: "oracle", schema: "APP" });
position.mockReturnValue(8);
store.lookupLocalCompletionObjects.mockReturnValue([{ name: "Cleanup", schema: "APP", type: "procedure" }]);
await click();
expect(emit).toHaveBeenCalledWith("openObjectSource", expect.objectContaining({ name: "Cleanup", type: "procedure", nameQuoted: true }), false);
expect(store.listCompletionObjects).not.toHaveBeenCalled();
expect(store.listCompletionTables).not.toHaveBeenCalled();
});
it("jumps to CTE definitions without querying physical table metadata", async () => {
const sql = "WITH picked AS (SELECT id FROM users) SELECT id FROM picked";
const { position, currentView, runtime, emit, click, store } = await createHarness({ modelValue: sql }, true);
position.mockReturnValue(sql.lastIndexOf("picked") + 2);
const dispatch = vi.spyOn(currentView, "dispatch");
await click();
expect(currentView.state.sliceDoc(currentView.state.selection.main.from, currentView.state.selection.main.to)).toBe("picked");
expect(currentView.state.selection.main.from).toBe(sql.indexOf("picked"));
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ effects: expect.arrayContaining([expect.objectContaining({ type: runtime.setResultSourceRangeEffect })]) }));
expect(store.lookupLocalCompletionTables).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it("uses the shared column cache for qualified column navigation", async () => {
const { position, metadata, store, emit, click } = await createHarness({ modelValue: "SELECT u.name FROM users u" });
position.mockReturnValue(10);
metadata.cachedColumnsByTable.set("public.users", [{ name: "name", table: "users", schema: "public" }]);
await click();
expect(emit).toHaveBeenCalledWith("clickColumn", [{ name: "name", table: "users", schema: "public" }]);
expect(store.listCompletionColumns).not.toHaveBeenCalled();
});
it("does not emit a false column match when its metadata lookup fails", async () => {
const { position, store, emit, click } = await createHarness({ modelValue: "SELECT u.name FROM users u" });
position.mockReturnValue(10);
store.listCompletionColumns.mockRejectedValue(new Error("offline"));
await click();
expect(store.listCompletionColumns).toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it("keeps modifier-hover lookup bounded and cleans up global listeners", async () => {
const { navigation, currentView, position, host, event } = await createHarness({ modelValue: " ".repeat(10000) + "users" });
position.mockReturnValue(10002);
const stringify = vi.spyOn(currentView.state.doc, "toString");
expect(navigation.updateTableNavigationHover(currentView, event())).toBe(true);
expect(host.classList.contains("query-editor--table-navigation-hover")).toBe(true);
expect(stringify).not.toHaveBeenCalled();
window.dispatchEvent(new KeyboardEvent("keyup"));
expect(host.classList.contains("query-editor--table-navigation-hover")).toBe(false);
navigation.updateTableNavigationHover(currentView, event());
window.dispatchEvent(new Event("blur"));
expect(host.classList.contains("query-editor--table-navigation-hover")).toBe(false);
navigation.dispose();
navigation.updateTableNavigationHover(currentView, event());
window.dispatchEvent(new Event("blur"));
expect(host.classList.contains("query-editor--table-navigation-hover")).toBe(true);
});
});
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorPointer } from "../useQueryEditorPointer";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: vi.fn(() => false) }));
vi.mock("@/lib/backend/platform", () => ({ isMacOS: vi.fn(() => false) }));
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.restoreAllMocks();
});
function createHarness(readOnly = false) {
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({ doc: "abc def ghi", selection: { anchor: 4, head: 7 } }) });
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(5);
vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: 50, right: 51, top: 20, bottom: 38 });
vi.spyOn(view, "focus").mockImplementation(() => {});
const emit = vi.fn();
const clearTableNavigationHover = vi.fn();
const pointer = useQueryEditorPointer({ props: { modelValue: "abc def ghi", readOnly }, clearTableNavigationHover, emit });
const start = (init: MouseEventInit = {}) => {
const event = new MouseEvent("mousedown", { button: 0, detail: 1, clientX: 10, clientY: 10, cancelable: true, ...init });
Object.defineProperty(event, "target", { value: view.contentDOM });
return { handled: pointer.startEditorSelectionDrag(view, event), event };
};
const move = (init: MouseEventInit = {}) => document.dispatchEvent(new MouseEvent("mousemove", { clientX: 30, clientY: 10, cancelable: true, ...init }));
const release = (init: MouseEventInit = {}) => document.dispatchEvent(new MouseEvent("mouseup", { clientX: 30, clientY: 10, cancelable: true, ...init }));
cleanups.push(() => {
pointer.dispose();
view.destroy();
host.remove();
});
return { host, view, position, emit, clearTableNavigationHover, pointer, start, move, release };
}
describe("QueryEditor pointer ownership", () => {
it.each(["read-only", "non-primary", "shift", "double-click", "empty-selection", "outside-selection"])("does not capture %s gestures", (kind) => {
const { view, position, start, emit } = createHarness(kind === "read-only");
if (kind === "empty-selection") view.dispatch({ selection: { anchor: 4 } });
if (kind === "outside-selection") position.mockReturnValue(0);
const { handled, event } = start({ button: kind === "non-primary" ? 2 : 0, shiftKey: kind === "shift", detail: kind === "double-click" ? 2 : 1 });
expect(handled).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(emit).not.toHaveBeenCalled();
});
it("keeps clicks below the drag threshold as caret moves", () => {
const { view, position, start, move, release, emit } = createHarness();
expect(start().handled).toBe(true);
move({ clientX: 12 });
position.mockReturnValue(6);
release({ clientX: 12 });
expect(view.state.doc.toString()).toBe("abc def ghi");
expect(view.state.selection.main.anchor).toBe(6);
expect(view.state.selection.main.empty).toBe(true);
expect(emit).toHaveBeenCalledWith("closeColumnPanel");
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
});
it.each(["move", "control-copy", "command-copy"])("preserves %s text and selection mapping", (kind) => {
const { view, position, start, move, release, emit } = createHarness();
const modifiers = { ctrlKey: kind === "control-copy", metaKey: kind === "command-copy" };
start(modifiers);
position.mockReturnValue(11);
move(modifiers);
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).not.toBeNull();
expect(view.contentDOM.style.cursor).toBe(kind === "move" ? "move" : "copy");
release(modifiers);
expect(view.state.doc.toString()).toBe(kind === "move" ? "abc ghidef" : "abc def ghidef");
expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe("def");
expect(view.contentDOM.style.cursor).toBe("");
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
if (kind !== "move") expect(emit).not.toHaveBeenCalled();
});
it.each(["escape", "dispose"])("cleans document listeners and overlays on %s", (action) => {
const { view, position, pointer, start, move, release } = createHarness();
start();
position.mockReturnValue(11);
move();
const remove = vi.spyOn(document, "removeEventListener");
if (action === "escape") document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
else pointer.dispose();
expect(document.querySelector(".dbx-editor-selection-drop-cursor")).toBeNull();
expect(view.contentDOM.style.cursor).toBe("");
for (const event of ["mousemove", "mouseup", "keydown"]) expect(remove).toHaveBeenCalledWith(event, expect.any(Function), true);
release();
expect(view.state.doc.toString()).toBe("abc def ghi");
});
it("replaces scrollbar capture listeners and removes them on disposal", () => {
const { view, host, pointer, clearTableNavigationHover } = createHarness();
Object.defineProperties(view.scrollDOM, { scrollHeight: { configurable: true, value: 300 }, clientHeight: { configurable: true, value: 100 }, offsetWidth: { configurable: true, value: 100 }, clientWidth: { configurable: true, value: 90 } });
vi.spyOn(view.scrollDOM, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 100, 100));
const bubble = vi.fn();
host.addEventListener("mousedown", bubble);
pointer.registerEditorScrollbarPointerGuard(view);
pointer.registerEditorScrollbarPointerGuard(view);
view.scrollDOM.dispatchEvent(new MouseEvent("mousedown", { clientX: 95, clientY: 20, bubbles: true }));
expect(clearTableNavigationHover).toHaveBeenCalledOnce();
expect(bubble).not.toHaveBeenCalled();
pointer.dispose();
view.scrollDOM.dispatchEvent(new MouseEvent("mousedown", { clientX: 95, clientY: 20, bubbles: true }));
expect(bubble).toHaveBeenCalledOnce();
});
it("prevents native macOS Tauri scrollbar focus changes outside editor content", () => {
const { view, pointer } = createHarness();
vi.mocked(isTauriRuntime).mockReturnValue(true);
vi.mocked(isMacOS).mockReturnValue(true);
Object.defineProperties(view.scrollDOM, { scrollHeight: { configurable: true, value: 300 }, clientHeight: { configurable: true, value: 100 } });
vi.spyOn(view.scrollDOM, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 100, 100));
pointer.registerEditorScrollbarPointerGuard(view);
const event = new MouseEvent("mousedown", { clientX: 95, clientY: 20, bubbles: true, cancelable: true });
view.scrollDOM.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { EditorState, StateEffect } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { shallowRef } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createQueryEditorCodeMirrorRuntime } from "../queryEditorCodeMirrorRuntime";
import { useQueryEditorStatementBoundaries } from "../useQueryEditorStatementBoundaries";
import { statementGutterStartIndexHasStartAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
const cleanups: Array<() => void> = [];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createHarness() {
const view = shallowRef<EditorView | null>(null);
const cache = { value: null as ExecutableStatementRangeCache | null };
const runtime = createQueryEditorCodeMirrorRuntime();
runtime.statementBoundariesRefreshEffect = StateEffect.define<null>();
const boundaries = useQueryEditorStatementBoundaries({
props: { modelValue: "", databaseType: "mysql" },
view,
cache,
runtime,
sqlStatementParameterOptions: () => ({}),
});
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: "SELECT 1;\nSELECT 2;", extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) });
view.value = currentView;
cleanups.push(() => {
currentView.destroy();
parent.remove();
});
return { view, currentView, cache, runtime, boundaries, parent };
}
describe("QueryEditor shared statement boundaries", () => {
it("does not parse or schedule refreshes before any consumer requests boundaries", async () => {
const { currentView, cache } = createHarness();
currentView.dispatch({ changes: { from: 7, insert: "0" } });
await vi.advanceTimersByTimeAsync(200);
expect(cache.value).toBeNull();
});
it("maps positions during typing and refreshes the shared cache only after the debounce", async () => {
const { currentView, cache, boundaries } = createHarness();
const initial = boundaries.statementBoundariesForState(currentView.state);
const exactCache = cache.value;
initial.frameRange = { from: 10, to: 18 };
expect(boundaries.statementBoundariesForState(currentView.state)).toBe(initial);
currentView.dispatch({ changes: { from: 0, insert: " " } });
const mapped = boundaries.statementBoundariesForState(currentView.state);
expect(mapped.fresh).toBe(false);
expect(mapped.frameRange).toEqual({ from: 11, to: 19 });
expect(statementGutterStartIndexHasStartAt(mapped.startsIndex, 11)).toBe(true);
expect(cache.value).toBe(exactCache);
await vi.advanceTimersByTimeAsync(100);
currentView.dispatch({ changes: { from: 0, insert: " " } });
await vi.advanceTimersByTimeAsync(149);
expect(cache.value).toBe(exactCache);
await vi.advanceTimersByTimeAsync(1);
const refreshed = boundaries.statementBoundariesForState(currentView.state);
expect(refreshed.fresh).toBe(true);
expect(refreshed.doc).toBe(currentView.state.doc);
expect(cache.value).not.toBe(exactCache);
expect(refreshed.frameRange).toBeNull();
});
it("rebuilds when the dialect generation or tab state changes", () => {
const { currentView, boundaries } = createHarness();
const initial = boundaries.statementBoundariesForState(currentView.state);
boundaries.invalidate();
const invalidated = boundaries.statementBoundariesForState(currentView.state);
expect(invalidated).not.toBe(initial);
expect(invalidated.generation).toBe(initial.generation + 1);
const anotherTab = EditorState.create({ doc: "SELECT 3;" });
expect(boundaries.statementBoundariesForState(anotherTab).doc).toBe(anotherTab.doc);
expect(boundaries.statementBoundariesForState(currentView.state).doc).toBe(currentView.state.doc);
});
it.each(["destroyed", "replaced", "detached"])("does not refresh an editor that was %s", async (reason) => {
const { currentView, view, parent, boundaries, cache } = createHarness();
boundaries.statementBoundariesForState(currentView.state);
const previousCache = cache.value;
currentView.dispatch({ changes: { from: 0, insert: " " } });
if (reason === "destroyed") currentView.destroy();
if (reason === "replaced") view.value = null;
if (reason === "detached") parent.remove();
const dispatch = vi.spyOn(currentView, "dispatch");
await vi.advanceTimersByTimeAsync(200);
expect(dispatch).not.toHaveBeenCalled();
expect(cache.value).toBe(previousCache);
});
});
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { reactive, shallowRef } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorTableDrop } from "../useQueryEditorTableDrop";
import {
activeTableReferencePayloadValue,
createTableReferenceDragEndEvent,
createTableReferenceDropEvent,
createTableReferenceHoverEvent,
DBX_TABLE_REFERENCE_MIME,
serializeTableReferencePayload,
setActiveTableReferencePayload,
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import type { QueryEditorProps } from "../queryEditorTypes";
type Options = Parameters<typeof useQueryEditorTableDrop>[0];
const cleanups: Array<() => void> = [];
const payload: QueryEditorTableReferencePayload = { kind: "dbx-table-reference", connectionId: "connection", database: "demo", tableName: "users", referenceType: "column", columnName: "id" };
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
setActiveTableReferencePayload(null);
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}, left = 0) {
const props = reactive<QueryEditorProps>({ modelValue: "SELECT old", databaseType: "postgres", ...overrides });
const host = document.createElement("div");
document.body.append(host);
const view = new EditorView({ parent: host, state: EditorState.create({ doc: props.modelValue, selection: { anchor: 7, head: 10 } }) });
vi.spyOn(view, "focus").mockImplementation(() => {});
const position = vi.spyOn(view, "posAtCoords").mockReturnValue(10);
const coordinates = vi.spyOn(view, "coordsAtPos").mockReturnValue({ left: left + 25, right: left + 26, top: 30, bottom: 48 });
vi.spyOn(host, "getBoundingClientRect").mockReturnValue(new DOMRect(left, 20, 200, 100));
const settings = reactive({ editorSettings: { sidebarCopyTableNameSeparator: "comma", sidebarCopyTableNameIncludeSchema: false } });
const drop = useQueryEditorTableDrop({ props, view: shallowRef(view), editorRef: shallowRef(host), settingsStore: settings as Options["settingsStore"] });
cleanups.push(() => {
drop.unregisterTableReferenceDropListener();
view.destroy();
host.remove();
});
const nativeEvent = (serialized = serializeTableReferencePayload(payload)) => {
const dataTransfer = new DataTransfer();
dataTransfer.setData(DBX_TABLE_REFERENCE_MIME, serialized);
const event = new DragEvent("drop", { clientX: left + 30, clientY: 40, cancelable: true, bubbles: true });
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
return event;
};
return { props, host, view, position, coordinates, settings, drop, nativeEvent };
}
describe("QueryEditor table-reference drop ownership", () => {
it("inserts native references at the pointer and clears the hover caret", () => {
const { view, drop, nativeEvent } = createHarness();
drop.showQueryEditorDropCaretAt(30, 40);
const event = nativeEvent();
expect(drop.hasDroppedTableReference(event)).toBe(true);
expect(drop.insertDroppedTableReference(view, event)).toBe(true);
expect(view.state.doc.toString()).toBe("SELECT oldid");
expect(view.state.selection.main.anchor).toBe(12);
expect(event.defaultPrevented).toBe(true);
expect(drop.queryEditorDropCaret.value).toBeNull();
});
it("uses the active pointer payload before native transfer data", () => {
const { view, drop, nativeEvent } = createHarness();
setActiveTableReferencePayload({ ...payload, columnName: "name" });
expect(drop.insertDroppedTableReference(view, nativeEvent())).toBe(true);
expect(view.state.doc.toString()).toBe("SELECT oldname");
expect(activeTableReferencePayloadValue()).toBeNull();
});
it("falls back to replacing the selection when no pointer position is available", () => {
const { view, position, drop, nativeEvent } = createHarness();
position.mockReturnValue(null);
expect(drop.insertDroppedTableReference(view, nativeEvent())).toBe(true);
expect(view.state.doc.toString()).toBe("SELECT id");
expect(view.state.selection.main.head).toBe(9);
});
it("ignores malformed payloads without consuming unrelated drops", () => {
const { view, drop, nativeEvent } = createHarness();
const event = nativeEvent("not-json");
expect(drop.insertDroppedTableReference(view, event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(view.state.doc.toString()).toBe("SELECT old");
});
it("leaves read-only documents and active payloads unchanged", () => {
const { view, drop, nativeEvent } = createHarness({ readOnly: true });
setActiveTableReferencePayload(payload);
expect(drop.insertDroppedTableReference(view, nativeEvent())).toBe(false);
expect(view.state.doc.toString()).toBe("SELECT old");
expect(activeTableReferencePayloadValue()).toBe(payload);
drop.showQueryEditorDropCaretAt(30, 40);
expect(drop.queryEditorDropCaret.value).toBeNull();
});
it("converts viewport coordinates to the local caret and clears failed positions", () => {
const { drop, position, coordinates } = createHarness({}, 100);
drop.showQueryEditorDropCaretAt(130, 40);
expect(drop.queryEditorDropCaretStyle.value).toEqual({ left: "25px", top: "10px", height: "18px" });
coordinates.mockReturnValue(null);
drop.showQueryEditorDropCaretAt(130, 40);
expect(drop.queryEditorDropCaret.value).toBeNull();
position.mockImplementation(() => {
throw new Error("view unavailable");
});
expect(() => drop.showQueryEditorDropCaretAt(130, 40)).not.toThrow();
expect(drop.queryEditorDropCaretStyle.value).toEqual({});
});
it("registers global listeners once and supports unregistering and reactivation", () => {
const { view, drop } = createHarness();
drop.registerTableReferenceDropListener();
drop.registerTableReferenceDropListener();
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 30, clientY: 40 }));
expect(view.state.doc.toString()).toBe("SELECT oldid");
drop.unregisterTableReferenceDropListener();
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 30, clientY: 40 }));
expect(view.state.doc.toString()).toBe("SELECT oldid");
drop.registerTableReferenceDropListener();
window.dispatchEvent(createTableReferenceHoverEvent({ clientX: 30, clientY: 40 }));
expect(drop.queryEditorDropCaret.value).not.toBeNull();
window.dispatchEvent(createTableReferenceDragEndEvent());
expect(drop.queryEditorDropCaret.value).toBeNull();
});
it("keeps drops and hover carets isolated between editors", () => {
const first = createHarness();
const second = createHarness({}, 300);
first.drop.registerTableReferenceDropListener();
second.drop.registerTableReferenceDropListener();
window.dispatchEvent(createTableReferenceHoverEvent({ clientX: 330, clientY: 40 }));
expect(first.drop.queryEditorDropCaret.value).toBeNull();
expect(second.drop.queryEditorDropCaret.value).not.toBeNull();
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX: 330, clientY: 40 }));
expect(first.view.state.doc.toString()).toBe("SELECT old");
expect(second.view.state.doc.toString()).toBe("SELECT oldid");
});
});
@@ -0,0 +1,184 @@
// @vitest-environment happy-dom
import { computed, createApp, h, reactive, shallowRef } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo, toggleLineComment, toggleBlockComment } from "@codemirror/commands";
import { sql } from "@codemirror/lang-sql";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useQueryEditorTextActions } from "../useQueryEditorTextActions";
import { useSettingsStore } from "@/stores/settingsStore";
import type { QueryEditorProps } from "../queryEditorTypes";
const clipboard = vi.hoisted(() => ({ copyToClipboard: vi.fn(), readTextFromClipboard: vi.fn(), copySqlAsRichText: vi.fn() }));
vi.mock("@/lib/common/clipboard", () => clipboard);
vi.mock("@/lib/sql/sqlRichText", () => ({ copySqlAsRichText: clipboard.copySqlAsRichText }));
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
vi.resetAllMocks();
});
function mountActions(text = "alice\nbob") {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
const host = document.createElement("div");
const editorHost = document.createElement("div");
document.body.append(host, editorHost);
const props = reactive<QueryEditorProps>({ modelValue: text, databaseType: "mysql", dialect: "mysql" });
const state = shallowRef(EditorState.create({ doc: text, selection: { anchor: 0, head: text.length }, extensions: [sql(), history(), EditorState.allowMultipleSelections.of(true)] }));
const editorView = new EditorView({
state: state.value,
parent: editorHost,
dispatchTransactions: (transactions, currentView) => {
currentView.update(transactions);
state.value = currentView.state;
},
});
vi.spyOn(editorView, "focus").mockImplementation(() => {});
const view = shallowRef<EditorView | null>(editorView);
const selectedSql = computed(() => state.value.sliceDoc(state.value.selection.main.from, state.value.selection.main.to));
let actions!: ReturnType<typeof useQueryEditorTextActions>;
const app = createApp({
setup() {
actions = useQueryEditorTextActions({ props, view, selectedSql, settingsStore, sqlBehaviorDialect: () => props.dialect, focusEditor: () => view.value?.focus(), getEditorSelection: () => EditorSelection, toggleLineComment, toggleBlockComment });
return () => h("div");
},
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => {
app.unmount();
editorView.destroy();
host.remove();
editorHost.remove();
});
return { actions, view, editorView, props };
}
describe("QueryEditor extracted text actions", () => {
it("captures the delimited-list input and replaces the selection with one undoable edit", () => {
const { actions, editorView } = mountActions();
actions.openDelimitedListDialog();
expect(actions.delimitedListOpen.value).toBe(true);
expect(actions.delimitedListSelectedText.value).toBe("alice\nbob");
actions.applyDelimitedListResult("'alice','bob'");
expect(editorView.state.doc.toString()).toBe("'alice','bob'");
expect(undo(editorView)).toBe(true);
expect(editorView.state.doc.toString()).toBe("alice\nbob");
});
it("guards delimited-list opening and confirmation with the current read-only state", () => {
const { actions, editorView, props } = mountActions();
props.readOnly = true;
actions.openDelimitedListDialog();
expect(actions.delimitedListOpen.value).toBe(false);
props.readOnly = false;
actions.openDelimitedListDialog();
props.readOnly = true;
actions.applyDelimitedListResult("changed");
expect(editorView.state.doc.toString()).toBe("alice\nbob");
});
it("does not open selection dialogs for whitespace-only input", () => {
const { actions } = mountActions(" \n ");
actions.openDelimitedListDialog();
actions.openCodeSnapshot();
expect(actions.delimitedListOpen.value).toBe(false);
expect(actions.codeSnapshotOpen.value).toBe(false);
});
it("captures the code snapshot without aliasing later selections", () => {
const { actions, editorView } = mountActions("SELECT 1");
actions.openCodeSnapshot();
editorView.dispatch({ selection: { anchor: 0 } });
expect(actions.codeSnapshotOpen.value).toBe(true);
expect(actions.codeSnapshotSource.value).toEqual({ code: "SELECT 1", lang: "sql" });
});
it("preserves plain and rich-text copying and undoable cutting", async () => {
const { actions, editorView } = mountActions("SELECT 1");
await actions.copySelectedSqlFromContextMenu();
await actions.copySelectedSqlAsRichTextFromContextMenu();
expect(clipboard.copyToClipboard).toHaveBeenCalledWith("SELECT 1");
expect(clipboard.copySqlAsRichText).toHaveBeenCalledWith("SELECT 1");
await actions.cutSelectedSqlFromContextMenu();
expect(editorView.state.doc.toString()).toBe("");
expect(undo(editorView)).toBe(true);
expect(editorView.state.doc.toString()).toBe("SELECT 1");
});
it("does not cut the selection when clipboard writing fails", async () => {
clipboard.copyToClipboard.mockRejectedValue(new Error("clipboard unavailable"));
const { actions, editorView } = mountActions();
await actions.cutSelectedSqlFromContextMenu();
expect(editorView.state.doc.toString()).toBe("alice\nbob");
});
it("keeps clipboard normalization, caret placement, undo and read-only guards", async () => {
clipboard.readTextFromClipboard.mockResolvedValue("one\r\ntwo");
const { actions, editorView, props } = mountActions();
await actions.pasteClipboardSqlFromContextMenu();
expect(editorView.state.doc.toString()).toBe("one\ntwo");
expect(editorView.state.selection.main.head).toBe(7);
expect(undo(editorView)).toBe(true);
expect(editorView.state.doc.toString()).toBe("alice\nbob");
props.readOnly = true;
await actions.pasteClipboardSqlFromContextMenu();
expect(clipboard.readTextFromClipboard).toHaveBeenCalledTimes(1);
});
it("preserves multi-range case conversion and selects each replacement", () => {
const { actions, editorView } = mountActions("select id, name from users");
editorView.dispatch({ selection: EditorSelection.create([EditorSelection.range(0, 6), EditorSelection.range(16, 20)]) });
expect(actions.convertSelectedSqlCase("upper")).toBe(true);
expect(editorView.state.doc.toString()).toBe("SELECT id, name FROM users");
expect(editorView.state.selection.ranges.map((range) => editorView.state.sliceDoc(range.from, range.to))).toEqual(["SELECT", "FROM"]);
expect(undo(editorView)).toBe(true);
expect(editorView.state.doc.toString()).toBe("select id, name from users");
});
it("preserves naming conversion and an undoable empty-line deletion", () => {
const { actions, editorView } = mountActions("user_name");
expect(actions.convertSelectedNamingStyle()).toBe(true);
expect(editorView.state.doc.toString()).toBe("USER_NAME");
editorView.setState(EditorState.create({ doc: "SELECT 1;\n\nSELECT 2;", extensions: [history()] }));
actions.deleteEmptyLines();
expect(editorView.state.doc.toString()).toBe("SELECT 1;\nSELECT 2;");
expect(undo(editorView)).toBe(true);
expect(editorView.state.doc.toString()).toBe("SELECT 1;\n\nSELECT 2;");
});
it("does not apply a pending IN-list paste after the view or read-only state changes", async () => {
let resolveClipboard!: (text: string) => void;
clipboard.readTextFromClipboard.mockReturnValue(
new Promise<string>((resolve) => {
resolveClipboard = resolve;
}),
);
const { actions, editorView, props } = mountActions("");
const pending = actions.pasteClipboardAsSqlInCondition();
props.readOnly = true;
resolveClipboard("alice\nbob");
expect(await pending).toBe(false);
expect(editorView.state.doc.toString()).toBe("");
});
it("tolerates missing views and empty selections without dispatching", async () => {
const { actions, view } = mountActions("");
view.value = null;
expect(actions.convertSelectedSqlCase("upper")).toBe(false);
expect(actions.convertSelectedNamingStyle()).toBe(false);
expect(await actions.pasteClipboardAsSqlInCondition()).toBe(false);
expect(() => {
actions.applyDelimitedListResult("text");
actions.deleteEmptyLines();
actions.selectAllSqlFromContextMenu();
}).not.toThrow();
});
});
@@ -0,0 +1,432 @@
import { StateEffect } from "@codemirror/state";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { type SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
interface CodeMirrorBindings {
editorViewModule: typeof import("@codemirror/view") | null;
codeMirrorLineNumbers: typeof import("@codemirror/view").lineNumbers | null;
codeMirrorPrec: typeof import("@codemirror/state").Prec | null;
codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null;
hoverCloseEffect: StateEffect<unknown> | null;
fontThemeComp: import("@codemirror/state").Compartment | null;
codeMirrorTheme: import("@codemirror/state").Compartment | null;
wordWrapComp: import("@codemirror/state").Compartment | null;
showWhitespaceComp: import("@codemirror/state").Compartment | null;
lineNumbersComp: import("@codemirror/state").Compartment | null;
vimModeComp: import("@codemirror/state").Compartment | null;
closeBracketsComp: import("@codemirror/state").Compartment | null;
sqlLanguageComp: import("@codemirror/state").Compartment | null;
sqlSemanticHighlightComp: import("@codemirror/state").Compartment | null;
sqlSignatureComp: import("@codemirror/state").Compartment | null;
codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrackets | null;
codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null;
readOnlyComp: import("@codemirror/state").Compartment | null;
runGutterComp: import("@codemirror/state").Compartment | null;
runKeymapComp: import("@codemirror/state").Compartment | null;
historyResetComp: import("@codemirror/state").Compartment | null;
codeMirrorHistory: typeof import("@codemirror/commands").history | null;
defaultKeymapComp: import("@codemirror/state").Compartment | null;
completionComp: import("@codemirror/state").Compartment | null;
diagnosticComp: import("@codemirror/state").Compartment | null;
codeMirrorVim: typeof import("@replit/codemirror-vim").vim | null;
codeMirrorVimApi: typeof import("@replit/codemirror-vim").Vim | null;
codeMirrorGetVimCm: typeof import("@replit/codemirror-vim").getCM | null;
codeMirrorVimImportPromise: Promise<typeof import("@replit/codemirror-vim")> | null;
dbxVimCommandsConfigured: boolean;
buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null;
buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null;
buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null;
buildSqlLanguageExtension: (() => import("@codemirror/state").Extension) | null;
buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Extension) | null;
codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null;
codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null;
codeMirrorCurrentCompletions: typeof import("@codemirror/autocomplete").currentCompletions | null;
codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null;
codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | null;
codeMirrorSetSelectedCompletion: typeof import("@codemirror/autocomplete").setSelectedCompletion | null;
codeMirrorMoveCompletionSelection: typeof import("@codemirror/autocomplete").moveCompletionSelection | null;
codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null;
codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null;
codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null;
codeMirrorInsertCompletionText: typeof import("@codemirror/autocomplete").insertCompletionText | null;
codeMirrorNextSnippetField: typeof import("@codemirror/autocomplete").nextSnippetField | null;
codeMirrorIndentMore: typeof import("@codemirror/commands").indentMore | null;
codeMirrorIndentLess: typeof import("@codemirror/commands").indentLess | null;
codeMirrorCopyLineDown: typeof import("@codemirror/commands").copyLineDown | null;
codeMirrorCopyLineUp: typeof import("@codemirror/commands").copyLineUp | null;
codeMirrorDeleteLine: typeof import("@codemirror/commands").deleteLine | null;
codeMirrorMoveLineUp: typeof import("@codemirror/commands").moveLineUp | null;
codeMirrorMoveLineDown: typeof import("@codemirror/commands").moveLineDown | null;
codeMirrorUndo: typeof import("@codemirror/commands").undo | null;
codeMirrorRedo: typeof import("@codemirror/commands").redo | null;
codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null;
codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null;
codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null;
codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBlockComment | null;
codeMirrorDefaultKeymap: readonly import("@codemirror/view").KeyBinding[] | null;
codeMirrorToggleFold: typeof import("@codemirror/language").toggleFold | null;
setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null;
setPreviewRangeEffect:
| import("@codemirror/state").StateEffectType<{
from: number;
to: number;
} | null>
| null;
setResultSourceRangeEffect:
| import("@codemirror/state").StateEffectType<{
from: number;
to: number;
} | null>
| null;
setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null;
previewRangeComp: import("@codemirror/state").Compartment | null;
buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null;
buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null;
buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null;
indentComp: import("@codemirror/state").Compartment | null;
codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null;
statementBoundariesRefreshEffect: import("@codemirror/state").StateEffectType<null> | null;
}
type ReadyCodeMirrorBindings = CodeMirrorBindings & {
[Key in
| "editorViewModule"
| "hoverCloseEffect"
| "codeMirrorLineNumbers"
| "codeMirrorPrec"
| "codeMirrorEditorSelection"
| "codeMirrorSnippetCompletion"
| "fontThemeComp"
| "codeMirrorTheme"
| "wordWrapComp"
| "showWhitespaceComp"
| "lineNumbersComp"
| "vimModeComp"
| "closeBracketsComp"
| "sqlLanguageComp"
| "sqlSemanticHighlightComp"
| "sqlSignatureComp"
| "codeMirrorCloseBrackets"
| "codeMirrorCloseBracketsKeymap"
| "readOnlyComp"
| "runGutterComp"
| "runKeymapComp"
| "historyResetComp"
| "defaultKeymapComp"
| "completionComp"
| "diagnosticComp"
| "previewRangeComp"
| "indentComp"
| "setSqlDiagnosticsEffect"
| "statementBoundariesRefreshEffect"
| "codeMirrorCompletionStatus"
| "codeMirrorAcceptCompletion"
| "codeMirrorCurrentCompletions"
| "codeMirrorSelectedCompletion"
| "codeMirrorSelectedCompletionIndex"
| "codeMirrorSetSelectedCompletion"
| "codeMirrorMoveCompletionSelection"
| "codeMirrorSelectFirstCompletion"
| "codeMirrorCloseCompletion"
| "codeMirrorStartCompletion"
| "codeMirrorInsertCompletionText"
| "codeMirrorNextSnippetField"
| "codeMirrorIndentMore"
| "codeMirrorIndentLess"
| "codeMirrorCopyLineDown"
| "codeMirrorCopyLineUp"
| "codeMirrorDeleteLine"
| "codeMirrorMoveLineUp"
| "codeMirrorMoveLineDown"
| "codeMirrorUndo"
| "codeMirrorRedo"
| "codeMirrorHistory"
| "codeMirrorSelectAll"
| "codeMirrorInsertNewlineKeepIndent"
| "codeMirrorToggleLineComment"
| "codeMirrorToggleBlockComment"
| "codeMirrorDefaultKeymap"
| "codeMirrorToggleFold"
| "codeMirrorIndentUnit"]: NonNullable<CodeMirrorBindings[Key]>;
};
export function createQueryEditorCodeMirrorRuntime() {
const runtime: CodeMirrorBindings = {
editorViewModule: null,
codeMirrorLineNumbers: null,
codeMirrorPrec: null,
codeMirrorEditorSelection: null,
hoverCloseEffect: null,
fontThemeComp: null,
codeMirrorTheme: null,
wordWrapComp: null,
showWhitespaceComp: null,
lineNumbersComp: null,
vimModeComp: null,
closeBracketsComp: null,
sqlLanguageComp: null,
sqlSemanticHighlightComp: null,
sqlSignatureComp: null,
codeMirrorCloseBrackets: null,
codeMirrorCloseBracketsKeymap: null,
readOnlyComp: null,
runGutterComp: null,
runKeymapComp: null,
historyResetComp: null,
codeMirrorHistory: null,
defaultKeymapComp: null,
completionComp: null,
diagnosticComp: null,
codeMirrorVim: null,
codeMirrorVimApi: null,
codeMirrorGetVimCm: null,
codeMirrorVimImportPromise: null,
dbxVimCommandsConfigured: false,
buildSqlDiagnosticExtension: null,
buildSqlSignatureExtension: null,
buildSqlCompletionExtension: null,
buildSqlLanguageExtension: null,
buildSqlSemanticHighlightExtension: null,
codeMirrorSnippetCompletion: undefined!,
codeMirrorCompletionStatus: null,
codeMirrorAcceptCompletion: null,
codeMirrorCurrentCompletions: null,
codeMirrorSelectedCompletionIndex: null,
codeMirrorSelectedCompletion: null,
codeMirrorSetSelectedCompletion: null,
codeMirrorMoveCompletionSelection: null,
codeMirrorSelectFirstCompletion: null,
codeMirrorStartCompletion: null,
codeMirrorCloseCompletion: null,
codeMirrorInsertCompletionText: null,
codeMirrorNextSnippetField: null,
codeMirrorIndentMore: null,
codeMirrorIndentLess: null,
codeMirrorCopyLineDown: null,
codeMirrorCopyLineUp: null,
codeMirrorDeleteLine: null,
codeMirrorMoveLineUp: null,
codeMirrorMoveLineDown: null,
codeMirrorUndo: null,
codeMirrorRedo: null,
codeMirrorSelectAll: null,
codeMirrorInsertNewlineKeepIndent: null,
codeMirrorToggleLineComment: null,
codeMirrorToggleBlockComment: null,
codeMirrorDefaultKeymap: null,
codeMirrorToggleFold: null,
setSqlDiagnosticsEffect: null,
setPreviewRangeEffect: null,
setResultSourceRangeEffect: null,
setStatementExecutionMarkersEffect: null,
previewRangeComp: null,
buildPreviewRangeExtension: null,
buildResultSourceRangeExtension: null,
buildRunStatementGutterExtension: null,
indentComp: null,
codeMirrorIndentUnit: null,
statementBoundariesRefreshEffect: null,
};
return Object.assign(runtime, {
async load() {
const [
{
EditorView,
keymap,
rectangularSelection,
hoverTooltip,
showTooltip,
closeHoverTooltips,
Decoration,
tooltips,
gutter,
GutterMarker,
lineNumberMarkers,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
highlightWhitespace,
WidgetType,
drawSelection,
dropCursor,
crosshairCursor,
scrollPastEnd,
ViewPlugin,
layer,
RectangleMarker,
},
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
langSql,
{
autocompletion,
startCompletion,
acceptCompletion,
closeBrackets,
closeBracketsKeymap,
snippetCompletion,
completionStatus,
completionKeymap,
insertCompletionText,
nextSnippetField,
closeCompletion,
moveCompletionSelection,
selectedCompletion,
selectedCompletionIndex,
currentCompletions,
setSelectedCompletion,
},
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree },
{ searchKeymap },
] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]);
runtime.editorViewModule = {
EditorView,
keymap,
rectangularSelection,
highlightWhitespace,
WidgetType,
Decoration,
ViewPlugin,
} as typeof import("@codemirror/view");
runtime.hoverCloseEffect = closeHoverTooltips;
runtime.codeMirrorLineNumbers = lineNumbers;
runtime.codeMirrorPrec = Prec;
runtime.codeMirrorEditorSelection = EditorSelection;
runtime.codeMirrorSnippetCompletion = snippetCompletion;
runtime.fontThemeComp = new Compartment();
runtime.codeMirrorTheme = new Compartment();
runtime.wordWrapComp = new Compartment();
runtime.showWhitespaceComp = new Compartment();
runtime.lineNumbersComp = new Compartment();
runtime.vimModeComp = new Compartment();
runtime.closeBracketsComp = new Compartment();
runtime.sqlLanguageComp = new Compartment();
runtime.sqlSemanticHighlightComp = new Compartment();
runtime.sqlSignatureComp = new Compartment();
runtime.codeMirrorCloseBrackets = closeBrackets;
runtime.codeMirrorCloseBracketsKeymap = closeBracketsKeymap;
runtime.readOnlyComp = new Compartment();
runtime.runGutterComp = new Compartment();
runtime.runKeymapComp = new Compartment();
runtime.historyResetComp = new Compartment();
runtime.defaultKeymapComp = new Compartment();
runtime.completionComp = new Compartment();
runtime.diagnosticComp = new Compartment();
runtime.previewRangeComp = new Compartment();
runtime.indentComp = new Compartment();
runtime.setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
runtime.statementBoundariesRefreshEffect = StateEffect.define<null>();
runtime.codeMirrorCompletionStatus = completionStatus;
runtime.codeMirrorAcceptCompletion = acceptCompletion;
runtime.codeMirrorCurrentCompletions = currentCompletions;
runtime.codeMirrorSelectedCompletion = selectedCompletion;
runtime.codeMirrorSelectedCompletionIndex = selectedCompletionIndex;
runtime.codeMirrorSetSelectedCompletion = setSelectedCompletion;
runtime.codeMirrorMoveCompletionSelection = moveCompletionSelection;
runtime.codeMirrorSelectFirstCompletion = moveCompletionSelection(true);
runtime.codeMirrorCloseCompletion = closeCompletion;
runtime.codeMirrorStartCompletion = startCompletion;
runtime.codeMirrorInsertCompletionText = insertCompletionText;
runtime.codeMirrorNextSnippetField = nextSnippetField;
runtime.codeMirrorIndentMore = indentMore;
runtime.codeMirrorIndentLess = indentLess;
runtime.codeMirrorCopyLineDown = copyLineDown;
runtime.codeMirrorCopyLineUp = copyLineUp;
runtime.codeMirrorDeleteLine = deleteLine;
runtime.codeMirrorMoveLineUp = moveLineUp;
runtime.codeMirrorMoveLineDown = moveLineDown;
runtime.codeMirrorUndo = undo;
runtime.codeMirrorRedo = redo;
runtime.codeMirrorHistory = history;
runtime.codeMirrorSelectAll = selectAll;
runtime.codeMirrorInsertNewlineKeepIndent = insertNewlineKeepIndent;
runtime.codeMirrorToggleLineComment = toggleLineComment;
runtime.codeMirrorToggleBlockComment = toggleBlockComment;
runtime.codeMirrorDefaultKeymap = defaultKeymap;
runtime.codeMirrorToggleFold = toggleFold;
runtime.codeMirrorIndentUnit = indentUnit;
return {
runtime: runtime as ReadyCodeMirrorBindings,
EditorView,
keymap,
rectangularSelection,
hoverTooltip,
showTooltip,
closeHoverTooltips,
Decoration,
tooltips,
gutter,
GutterMarker,
lineNumberMarkers,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
highlightWhitespace,
WidgetType,
drawSelection,
dropCursor,
crosshairCursor,
scrollPastEnd,
ViewPlugin,
layer,
RectangleMarker,
EditorState,
EditorSelection,
Compartment,
Prec,
RangeSet,
StateEffect,
StateField,
langSql,
autocompletion,
startCompletion,
acceptCompletion,
closeBrackets,
closeBracketsKeymap,
snippetCompletion,
completionStatus,
completionKeymap,
insertCompletionText,
nextSnippetField,
closeCompletion,
moveCompletionSelection,
selectedCompletion,
selectedCompletionIndex,
currentCompletions,
setSelectedCompletion,
copyLineDown,
copyLineUp,
deleteLine,
indentLess,
indentMore,
insertNewlineKeepIndent,
moveLineDown,
moveLineUp,
redo,
selectAll,
undo,
toggleLineComment,
toggleBlockComment,
history,
defaultKeymap,
historyKeymap,
bracketMatching,
foldGutter,
indentOnInput,
indentUnit,
syntaxHighlighting,
defaultHighlightStyle,
foldKeymap,
toggleFold,
ensureSyntaxTree,
highlightingFor,
syntaxTree,
searchKeymap,
};
},
});
}
export type QueryEditorCodeMirrorRuntime = ReturnType<typeof createQueryEditorCodeMirrorRuntime>;
export type QueryEditorCodeMirrorModules = Awaited<ReturnType<QueryEditorCodeMirrorRuntime["load"]>>;
@@ -0,0 +1,157 @@
import { copyToClipboard } from "@/lib/common/clipboard";
import { normalizeAlignedSqlWhitespace } from "@/lib/editor/hoverTableSql";
import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout";
import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import type { Ref } from "vue";
import type { Composer } from "vue-i18n";
import type { useToast } from "@/composables/useToast";
interface QueryEditorHoverContentOptions {
isDark: Readonly<Ref<boolean>>;
t: Composer["t"];
toast: ReturnType<typeof useToast>["toast"];
}
export function createQueryEditorHoverContent(options: QueryEditorHoverContentOptions) {
const { isDark, t, toast } = options;
let hoverSqlHighlighter: SqlHighlighter | null = null;
function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []): { dom: HTMLElement; mount?: () => void; destroy?: () => void } {
const dom = document.createElement("div");
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
const heading = document.createElement("div");
heading.className = "font-medium";
heading.textContent = title;
dom.appendChild(heading);
const detailNode = document.createElement("div");
detailNode.className = "mt-1 text-muted-foreground";
detailNode.textContent = detail;
dom.appendChild(detailNode);
let layoutController: ReturnType<typeof constrainSqlHoverLayout> | null = null;
let handleCopy: ((event: ClipboardEvent) => void) | null = null;
let searchController: HoverSearchController | null = null;
if (sqlContent) {
heading.className = "flex items-center justify-between gap-3 font-medium";
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "rounded border border-border/60 px-1.5 py-0.5 text-[11px] leading-none text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
copyButton.textContent = t("grid.copyDdl");
copyButton.title = t("grid.copyDdl");
copyButton.setAttribute("aria-label", t("grid.copyDdl"));
copyButton.addEventListener("pointerdown", (event) => {
// Keep CodeMirror's editor gestures from dismissing the tooltip before
// the click can reach the copy action.
event.preventDefault();
event.stopPropagation();
});
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
try {
await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent));
toast(t("contextMenu.ddlCopied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);
}
})();
});
heading.appendChild(copyButton);
const separator = document.createElement("div");
separator.className = "mt-2 border-t border-border/60";
dom.appendChild(separator);
const sqlContainer = document.createElement("div");
sqlContainer.className = "mt-1.5 text-[11px] leading-5 whitespace-pre font-mono";
if (hoverSqlHighlighter) {
sqlContainer.innerHTML = hoverSqlHighlighter(sqlContent, isDark.value ? "dark" : "light");
} else {
sqlContainer.className += " text-muted-foreground";
sqlContainer.textContent = sqlContent;
}
// 纯前端搜索:在已生成的 DDL 内容上做大小写不敏感匹配并高亮,不重新请求元数据/DDL。
// originalHtml 必须在挂到文档前、内容渲染后捕获,作为每次搜索的还原基线。
searchController = createHoverSearch({
target: sqlContainer,
originalHtml: sqlContainer.innerHTML,
placeholder: t("grid.hoverSearchPlaceholder"),
noResultLabel: t("grid.hoverSearchNoResult"),
});
dom.appendChild(searchController.element);
dom.appendChild(sqlContainer);
dom.appendChild(searchController.status);
// 返回 mount/destroy 给 CodeMirror TooltipView 生命周期钩子,
// 避免 MutationObserver 监听 body 全子树来兜底清理。
layoutController = constrainSqlHoverLayout(dom, sqlContainer);
// The tooltip pads column names/types with literal spaces so they line up
// visually (see alignColumnRows). Selecting that text and copying it via
// the native OS/browser copy carries those spaces verbatim, which shows
// up as long literal space runs when pasted into a plain-text editor.
// Normalize just the clipboard payload so the on-screen alignment is
// untouched but paste targets get clean single-spaced SQL.
handleCopy = (event: ClipboardEvent) => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed) return;
if (!dom.contains(selection.anchorNode) && !dom.contains(selection.focusNode)) return;
const text = selection.toString();
if (text !== sqlContent) return;
const normalized = normalizeAlignedSqlWhitespace(text);
if (normalized === text) return;
event.clipboardData?.setData("text/plain", normalized);
event.preventDefault();
};
}
for (const row of rows) {
const rowNode = document.createElement("div");
rowNode.className = "mt-1 font-mono text-muted-foreground";
rowNode.textContent = row;
dom.appendChild(rowNode);
}
return {
dom,
mount:
layoutController || handleCopy || searchController
? () => {
layoutController?.mount();
if (handleCopy) document.addEventListener("copy", handleCopy);
}
: undefined,
destroy:
layoutController || handleCopy || searchController
? () => {
layoutController?.destroy();
searchController?.destroy();
if (handleCopy) document.removeEventListener("copy", handleCopy);
}
: undefined,
};
}
function initializeHighlighter() {
void (async () => {
try {
const { createShikiSqlHighlighter } = await import("@/lib/sql/sqlHighlighter");
hoverSqlHighlighter = await createShikiSqlHighlighter({
appearance: () => (isDark.value ? "dark" : "light"),
});
} catch {
// Highlighter unavailable; hover falls back to plain text
}
})();
}
return { createHoverDom, initializeHighlighter };
}
@@ -0,0 +1,589 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import { type QueryCompletionOption } from "./useQueryEditorBatchSelection";
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, statementGutterStartIndexHasStartAt } from "@/lib/sql/executableStatementRangeCache";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
import { getSqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion";
import { shellLineCommentTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom } from "@/lib/editor/codemirrorStatementGutter";
import { compareSqlCompletions } from "@/lib/editor/sqlCompletionPresentation";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlights";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight";
import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment";
import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
import type { DatabaseType } from "@/types/database";
import type { ComputedRef } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import type { Composer } from "vue-i18n";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { QueryEditorCodeMirrorRuntime, QueryEditorCodeMirrorModules } from "./queryEditorCodeMirrorRuntime";
import type { useQueryEditorDiagnostics } from "./useQueryEditorDiagnostics";
import type { useQueryEditorCompletion } from "./useQueryEditorCompletion";
import type { useQueryEditorBatchSelection } from "./useQueryEditorBatchSelection";
import type { useQueryEditorStatementBoundaries } from "./useQueryEditorStatementBoundaries";
import type { ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
interface QueryEditorSqlExtensionsOptions {
props: Readonly<QueryEditorProps>;
runtime: QueryEditorCodeMirrorRuntime;
modules: QueryEditorCodeMirrorModules;
settingsStore: ReturnType<typeof useSettingsStore>;
t: Composer["t"];
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
sqlDriverProfile: ComputedRef<string | undefined>;
sqlStatementParameterOptions: () => SqlParameterOptions;
queryEditorSelectionLanguage: () => "sql" | "text";
cache: { value: ExecutableStatementRangeCache | null };
completion: ReturnType<typeof useQueryEditorCompletion>;
batchSelection: ReturnType<typeof useQueryEditorBatchSelection>;
diagnostics: Pick<ReturnType<typeof useQueryEditorDiagnostics>, "sqlErrorDecorationRange" | "sqlSemanticDecorationRanges">;
statementBoundaries: ReturnType<typeof useQueryEditorStatementBoundaries>;
currentExecutableStatementRange: (view: EditorViewType) => SqlTextRange | null;
executeSqlStatementFromGutter: (view: EditorViewType, line: { from: number; to: number }, event: Event) => boolean;
signatureHelpWindowChars: number;
}
export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensionsOptions) {
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter } = 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;
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges } = options.diagnostics;
const { statementBoundariesForState } = options.statementBoundaries;
const SQL_SIGNATURE_HELP_WINDOW_CHARS = options.signatureHelpWindowChars;
const diagnosticTheme = EditorView.baseTheme({
".cm-sql-error": {
textDecoration: "underline wavy var(--dbx-editor-diagnostic-error, var(--destructive))",
textUnderlineOffset: "3px",
},
".cm-sql-semantic-warning": {
textDecoration: "underline wavy var(--dbx-editor-diagnostic-warning, var(--warning))",
textUnderlineOffset: "3px",
},
});
codeMirrorRuntime.buildSqlDiagnosticExtension = () => {
const diagnosticEffect = codeMirrorRuntime.setSqlDiagnosticsEffect;
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
Decoration.mark({
class: "cm-sql-error",
attributes: { title: range.message },
}).range(range.from, range.to),
);
const semanticDecorations = sqlSemanticDecorationRanges(state).map((range) =>
Decoration.mark({
class: range.severity === "error" ? "cm-sql-error" : "cm-sql-semantic-warning",
attributes: { title: range.message },
}).range(range.from, range.to),
);
return Decoration.set([...errorDecorations, ...semanticDecorations], true);
};
const field = StateField.define({
create: buildDecorations,
update(value, transaction) {
const diagnosticsChanged = !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
if (diagnosticsChanged) return buildDecorations(transaction.state);
if (transaction.docChanged) return Decoration.set([]);
return value;
},
provide: (field) => EditorView.decorations.from(field),
});
return [field, diagnosticTheme];
};
codeMirrorRuntime.setPreviewRangeEffect = StateEffect.define<{
from: number;
to: number;
} | null>();
codeMirrorRuntime.buildPreviewRangeExtension = () => {
const effectType = codeMirrorRuntime.setPreviewRangeEffect!;
const field = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
const range = effect.value;
if (!range) return Decoration.none;
return Decoration.set([Decoration.mark({ class: "cm-db-execution-preview" }).range(range.from, range.to)]);
}
}
if (transaction.docChanged || transaction.selection) return Decoration.none;
return decorations;
},
provide: (f) => EditorView.decorations.from(f),
});
return field;
};
class ResultSourceLineNumberMarker extends GutterMarker {
elementClass = "cm-db-result-source-line-number";
}
const resultSourceLineNumberMarker = new ResultSourceLineNumberMarker();
codeMirrorRuntime.setResultSourceRangeEffect = StateEffect.define<{
from: number;
to: number;
} | null>();
codeMirrorRuntime.buildResultSourceRangeExtension = () => {
const effectType = codeMirrorRuntime.setResultSourceRangeEffect!;
const markersForRange = (state: import("@codemirror/state").EditorState, range: { from: number; to: number }) => {
const from = Math.max(0, Math.min(range.from, state.doc.length));
const to = Math.max(from, Math.min(range.to, state.doc.length));
const startLine = state.doc.lineAt(from);
const endLine = state.doc.lineAt(Math.max(from, to - 1));
const markers = Array.from({ length: endLine.number - startLine.number + 1 }, (_, index) => resultSourceLineNumberMarker.range(state.doc.line(startLine.number + index).from));
return RangeSet.of(markers);
};
const field = StateField.define({
create() {
return RangeSet.empty;
},
update(markers, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
return effect.value ? markersForRange(transaction.state, effect.value) : RangeSet.empty;
}
}
if (transaction.docChanged || transaction.selection) return RangeSet.empty;
return markers;
},
provide: (field) => lineNumberMarkers.from(field),
});
const highlightField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) {
const range = effect.value;
if (!range) return Decoration.none;
const from = Math.max(0, Math.min(range.from, transaction.state.doc.length));
const to = Math.max(from, Math.min(range.to, transaction.state.doc.length));
return from === to ? Decoration.none : Decoration.set([Decoration.mark({ class: "cm-db-result-source-highlight" }).range(from, to)]);
}
}
if (transaction.docChanged || transaction.selection) return Decoration.none;
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
return [field, highlightField];
};
class StatementExecutionStateMarker extends GutterMarker {
constructor(readonly marker: StatementExecutionMarker) {
super();
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount && other.marker.runningCount === this.marker.runningCount;
}
}
class StatementGutterMarker extends GutterMarker {
constructor(
readonly canExecute: boolean,
readonly marker?: StatementExecutionMarker,
) {
super();
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return (
other instanceof StatementGutterMarker &&
other.canExecute === this.canExecute &&
other.marker?.status === this.marker?.status &&
other.marker?.successCount === this.marker?.successCount &&
other.marker?.errorCount === this.marker?.errorCount &&
other.marker?.runningCount === this.marker?.runningCount
);
}
toDOM() {
return createStatementGutterMarkerDom({
canExecute: this.canExecute,
executeLabel: t("editor.contextMenu.executeCurrent"),
status: this.marker?.status,
statusLabel: this.marker ? statementExecutionMarkerTitle(this.marker) : undefined,
});
}
}
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
const parts = [];
if ((marker.runningCount ?? 0) > 0) parts.push(t("editor.statementExecutionRunning", { count: marker.runningCount }));
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
return parts.join(", ");
}
codeMirrorRuntime.setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
codeMirrorRuntime.buildRunStatementGutterExtension = () => {
const effectType = codeMirrorRuntime.setStatementExecutionMarkersEffect!;
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
const ranges = markers.map((marker) => {
const from = Math.max(0, Math.min(marker.from, state.doc.length));
return new StatementExecutionStateMarker(marker).range(state.doc.lineAt(from).from);
});
return RangeSet.of(ranges, true);
};
const field = StateField.define({
create(state) {
return markersForState(state, props.statementExecutionMarkers ?? []);
},
update(markers, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) return markersForState(transaction.state, effect.value);
}
if (transaction.docChanged) return RangeSet.empty;
return markers;
},
provide: (field) =>
gutter({
class: "cm-run-statement-gutter",
markers: (currentView) => currentView.state.field(field),
lineMarker(currentView, line, markers) {
const executionMarker = markers.find((marker) => marker instanceof StatementExecutionStateMarker)?.marker;
// Membership check against the lenient index (mapped through the
// ChangeSet while typing) instead of a full-document re-parse per
// keystroke. The click handler still resolves the exact range on
// demand, so display-level approximation never affects execution.
const canExecute = showRunButtons && statementGutterStartIndexHasStartAt(statementBoundariesForState(currentView.state).startsIndex, line.from);
return canExecute || executionMarker ? new StatementGutterMarker(canExecute, executionMarker) : null;
},
lineMarkerChange(update) {
// Redraw once the debounced full rebuild has landed.
const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
domEventHandlers: showRunButtons
? {
mousedown: executeSqlStatementFromGutter,
}
: {},
}),
});
return field;
};
codeMirrorRuntime.buildSqlSignatureExtension = () =>
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
// keystroke — doc.toString() was O(document) per key on large scripts.
const windowFrom = Math.max(0, cursor - SQL_SIGNATURE_HELP_WINDOW_CHARS);
const signature = getSqlFunctionSignatureHelp(currentState.doc.sliceString(windowFrom, cursor), cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 });
if (!signature) return null;
return {
pos: cursor,
above: false,
clip: false,
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
};
});
codeMirrorRuntime.buildSqlCompletionExtension = () =>
autocompletion({
activateOnTyping: true,
defaultKeymap: false,
selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen,
compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically),
// Keep normal completion lists virtualized; batch-field selection needs every row in the DOM.
maxRenderedOptions: batchSelection.expandedRendering ? Number.MAX_SAFE_INTEGER : 100,
optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : ""),
addToOptions: [
{ position: 5, render: renderBatchColumnSelectionActionMarker },
{ position: 10, render: renderBatchColumnSelectionCheckbox },
],
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),
}),
// 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) }])),
Prec.highest(EditorState.languageData.of(() => queryEditorWordLanguageData(props.databaseType))),
// The SQL grammar does not tokenize `//`, so those comments are highlighted by hand.
queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [],
];
const MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32;
const MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH = 128_000;
const SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100;
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<{
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(
{ layer, RectangleMarker },
(view) => {
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
const cursorPos = view.state.selection.main.head;
const boundaries = statementBoundariesForState(view.state);
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
// Typing in progress: reuse the ChangeSet-shifted range instead of
// re-parsing the whole document. The debounced refresh rebuilds and
// repaints the exact frame once typing pauses.
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
}
let range = currentExecutableStatementRange(view);
if (!range) {
const cursorLine = view.state.doc.lineAt(cursorPos);
cache.value = executableStatementRangeCacheForDoc(cache.value, view.state.doc, props.databaseType, sqlStatementParameterOptions());
// Find ranges that overlap the cursor line, then expand to include
// adjacent ranges (handles parser fragments from edge cases like
// ultra-long comments splitting a statement).
let mergedFrom = cursorLine.from;
let mergedTo = cursorLine.from;
let changed = true;
while (changed) {
changed = false;
for (const cachedRange of cache.value.ranges) {
// Only merge ranges that overlap the cursor line or are adjacent
// to the current merged region
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
const newFrom = Math.min(mergedFrom, cachedRange.from);
const newTo = Math.max(mergedTo, cachedRange.to);
if (newFrom !== mergedFrom || newTo !== mergedTo) {
mergedFrom = newFrom;
mergedTo = newTo;
changed = true;
}
}
}
}
if (mergedTo > mergedFrom) {
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
}
}
if (boundaries) boundaries.frameRange = range ? { from: range.from, to: range.to } : null;
if (!range) return null;
return { from: range.from, to: currentStatementFrameTo(view, range) };
},
{
shouldRefresh(update) {
const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
},
);
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
return currentStatementFrameRangeTo(view.state.doc, range);
}
const activeLineHighlighter = ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
constructor(view: import("@codemirror/view").EditorView) {
this.decorations = this.getDeco(view);
}
update(update: import("@codemirror/view").ViewUpdate) {
if (update.docChanged || update.selectionSet) this.decorations = this.getDeco(update.view);
}
getDeco(view: import("@codemirror/view").EditorView) {
if (!view.state.selection.main.empty) return Decoration.none;
let lastLineStart = -1;
const deco: any[] = [];
for (const r of view.state.selection.ranges) {
if (!r.empty) continue;
const line = view.lineBlockAt(r.head);
if (line.from > lastLineStart) {
deco.push(Decoration.line({ class: "cm-activeLine" }).range(line.from));
lastLineStart = line.from;
}
}
return Decoration.set(deco);
}
},
{ decorations: (v) => v.decorations },
);
return { currentStatementFrameExtension, activeLineHighlighter };
}
return {
createViewDecorations,
buildPreviewRangeExtension: codeMirrorRuntime.buildPreviewRangeExtension,
buildResultSourceRangeExtension: codeMirrorRuntime.buildResultSourceRangeExtension,
buildSqlSignatureExtension: codeMirrorRuntime.buildSqlSignatureExtension,
buildSqlLanguageExtension: codeMirrorRuntime.buildSqlLanguageExtension,
buildSqlSemanticHighlightExtension: codeMirrorRuntime.buildSqlSemanticHighlightExtension,
buildSqlCompletionExtension: codeMirrorRuntime.buildSqlCompletionExtension,
buildSqlDiagnosticExtension: codeMirrorRuntime.buildSqlDiagnosticExtension,
};
}
@@ -0,0 +1,41 @@
import type { SqlCompletionScope } from "@/lib/sql/sqlCompletionLookupTarget";
import type { DatabaseType } from "@/types/database";
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
export interface QueryEditorProps {
modelValue: string;
/** Identity of the tab owning the document. Changing it swaps in that tab's cached editor state (fresh undo history on first visit). */
tabId?: string;
connectionId?: string;
catalog?: string;
database?: string;
schema?: string;
clientSessionId?: string;
completionContextVersion?: number;
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver";
syntaxDialect?: CodeMirrorSqlDialectName;
formatDialect?: SqlFormatDialect;
formatRequestId?: number;
compressRequestId?: number;
executionError?: string;
executionErrorSql?: string;
resultColumns?: string[];
resultSourceStatement?: string;
resultSourceFrom?: number;
resultSourceTo?: number;
readOnly?: boolean;
autoFocus?: boolean;
forceWordWrap?: boolean;
hideExecutionControls?: boolean;
enableExplainShortcut?: boolean;
canExplain?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
initialSelection?: { anchor: number; head: number };
revealRequest?: { id: number; line: number; column?: number };
statementExecutionMarkers?: StatementExecutionMarker[];
}
export type CompletionMetadataScope = Pick<SqlCompletionScope, "database" | "schema">;
@@ -0,0 +1,633 @@
import { type ShallowRef } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { Completion } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
import { type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive } from "@/lib/editor/batchColumnSelection";
import { type RedisCompletionItem } from "@/lib/redis/redisCompletion";
import type { SqlCompletionItem } from "@/lib/sql/sqlCompletion";
export type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem;
export interface BatchColumnSelectionActionItem {
label: string;
filterText?: string;
type: "text";
detail: string;
boost: number;
batchColumnSelectionAction: true;
/** Toggles every candidate checkbox instead of inserting the selection. */
batchColumnSelectionToggleAll?: true;
sessionKey: string;
}
export type QueryCompletionOption = Completion & {
dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string };
dbxBatchColumnSelectionAction?: { sessionKey: string; toggleAll?: true };
};
interface QueryEditorBatchSelectionRuntime {
completionComp: import("@codemirror/state").Compartment | null;
buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null;
codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null;
codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null;
codeMirrorCurrentCompletions: typeof import("@codemirror/autocomplete").currentCompletions | null;
codeMirrorSetSelectedCompletion: typeof import("@codemirror/autocomplete").setSelectedCompletion | null;
codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | null;
codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
}
interface QueryEditorBatchSelectionOptions {
view: ShallowRef<EditorViewType | null>;
settingsStore: ReturnType<typeof useSettingsStore>;
runtime: QueryEditorBatchSelectionRuntime;
markCompletionAccepted: (item: QueryCompletionItem | BatchColumnSelectionActionItem) => void;
}
export function useQueryEditorBatchSelection(options: QueryEditorBatchSelectionOptions) {
const { view, settingsStore, runtime, markCompletionAccepted } = options;
const { t } = useI18n();
const { toast } = useToast();
interface BatchColumnSelectionCandidate {
key: string;
apply: string;
}
interface BatchColumnSelectionSession {
key: string;
mode: "select" | "insert";
qualifier?: string;
document: string;
from: number;
to: number;
replaceClosingQuote?: SqlCompletionItem["replaceClosingQuote"];
replaceSelectWildcard?: true;
candidates: BatchColumnSelectionCandidate[];
selectedKeys: Set<string>;
completionOptions: Map<string, QueryCompletionOption>;
}
let batchColumnSelectionSession: BatchColumnSelectionSession | null = null;
type BatchColumnSelectionCheckboxMarker = NonNullable<QueryCompletionOption["dbxBatchColumnSelection"]>;
interface BatchColumnSelectionDragState {
view: EditorViewType;
sessionKey: string;
pointerId: number;
anchorCandidateKey: string;
baseSelectedKeys: Set<string>;
selected: boolean;
focusCandidateKey: string;
previousUserSelect: string;
scrollElement: HTMLElement | null;
pointerClientX: number;
pointerClientY: number;
autoScrollFrame: number;
}
const batchColumnSelectionCheckboxMarkers = new WeakMap<HTMLElement, BatchColumnSelectionCheckboxMarker>();
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();
const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();
let batchColumnSelectionDragState: BatchColumnSelectionDragState | null = null;
let batchColumnSelectionRefreshCleanup: (() => void) | null = null;
let batchColumnSelectionExpandedRendering = false;
let batchColumnSelectionRenderLimitTimer: number | null = null;
function cancelBatchColumnSelectionRefresh() {
batchColumnSelectionRefreshCleanup?.();
batchColumnSelectionRefreshCleanup = null;
}
function setBatchColumnSelectionExpandedRendering(expanded: boolean) {
if (batchColumnSelectionExpandedRendering === expanded) return;
batchColumnSelectionExpandedRendering = expanded;
if (batchColumnSelectionRenderLimitTimer !== null) window.clearTimeout(batchColumnSelectionRenderLimitTimer);
const currentView = view.value;
if (!currentView || !runtime.completionComp || !runtime.buildSqlCompletionExtension) return;
batchColumnSelectionRenderLimitTimer = window.setTimeout(() => {
batchColumnSelectionRenderLimitTimer = null;
if (view.value !== currentView || !runtime.completionComp || !runtime.buildSqlCompletionExtension) return;
currentView.dispatch({ effects: runtime.completionComp.reconfigure(runtime.buildSqlCompletionExtension()) });
if (expanded && runtime.codeMirrorCompletionStatus?.(currentView.state) === "active") runtime.codeMirrorStartCompletion?.(currentView);
}, 0);
}
function setBatchColumnSelectionValue(sessionKey: string, candidateKey: string, selected: boolean, checkbox?: HTMLInputElement) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return;
if (selected) session.selectedKeys.add(candidateKey);
else session.selectedKeys.delete(candidateKey);
if (checkbox) checkbox.checked = selected;
}
function batchColumnSelectionAllSelected(session: BatchColumnSelectionSession): boolean {
return session.candidates.length > 0 && session.selectedKeys.size === session.candidates.length;
}
function updateBatchColumnSelectionActionLabel(view: EditorViewType, sessionKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey) return;
const insertLabel = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size });
const toggleAllLabel = batchColumnSelectionAllSelected(session) ? t("editor.completion.deselectAllColumns") : t("editor.completion.selectAllColumns");
const tooltipParent = batchColumnSelectionTooltipParents.get(view) ?? view.dom;
tooltipParent.querySelectorAll<HTMLElement>(".cm-batch-column-selection-action-marker").forEach((marker) => {
const markerState = batchColumnSelectionActionMarkers.get(marker);
if (!markerState || markerState.sessionKey !== sessionKey) return;
const element = marker.closest("li")?.querySelector<HTMLElement>(".cm-completionLabel");
if (!element) return;
const label = markerState.toggleAll ? toggleAllLabel : insertLabel;
if (element.textContent !== label) element.textContent = label;
});
}
/** Check or clear every field checkbox of the active batch selection. */
function toggleAllBatchColumnSelection(view: EditorViewType, sessionKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey) return;
const selectAll = !batchColumnSelectionAllSelected(session);
session.selectedKeys = selectAll ? new Set(session.candidates.map((candidate) => candidate.key)) : new Set();
updateBatchColumnSelectionActionLabel(view, sessionKey);
// Reopen the list so every rendered checkbox and both action rows reflect the new state.
scheduleBatchColumnSelectionRefresh(view, sessionKey, session.candidates[0]?.key ?? "");
}
function scheduleBatchColumnSelectionRefresh(view: EditorViewType, sessionKey: string, focusCandidateKey: string, scrollState?: { element: HTMLElement | null; top: number; left: number }) {
cancelBatchColumnSelectionRefresh();
const preservedScroll =
scrollState ??
(() => {
const element =
visibleBatchColumnSelectionCheckboxes(sessionKey)
.map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox))
.find((candidate): candidate is HTMLElement => !!candidate) ?? null;
return { element, top: element?.scrollTop ?? 0, left: element?.scrollLeft ?? 0 };
})();
const restoreScroll = () => {
const currentElement =
visibleBatchColumnSelectionCheckboxes(sessionKey)
.map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox))
.find((candidate): candidate is HTMLElement => !!candidate) ?? preservedScroll.element;
if (!currentElement) return;
currentElement.scrollTop = Math.min(preservedScroll.top, Math.max(0, currentElement.scrollHeight - currentElement.clientHeight));
currentElement.scrollLeft = Math.min(preservedScroll.left, Math.max(0, currentElement.scrollWidth - currentElement.clientWidth));
};
let restoreAttempts = 0;
let restoreFrame = 0;
let restoreTimer = 0;
let restoreStartTimer = 0;
let stopped = false;
const restoreObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(restoreScroll);
const stopRestore = () => {
if (stopped) return;
stopped = true;
restoreObserver?.disconnect();
if (restoreFrame) window.cancelAnimationFrame(restoreFrame);
if (restoreTimer) window.clearTimeout(restoreTimer);
if (restoreStartTimer) window.clearTimeout(restoreStartTimer);
if (batchColumnSelectionRefreshCleanup === stopRestore) batchColumnSelectionRefreshCleanup = null;
};
batchColumnSelectionRefreshCleanup = stopRestore;
const restoreNextFrame = () => {
if (batchColumnSelectionSession?.key !== sessionKey) {
stopRestore();
return;
}
restoreScroll();
restoreAttempts += 1;
if (restoreAttempts < 60) restoreFrame = window.requestAnimationFrame(restoreNextFrame);
else stopRestore();
};
restoreStartTimer = window.setTimeout(() => {
if (stopped || batchColumnSelectionSession?.key !== sessionKey) {
stopRestore();
return;
}
restoreObserver?.observe(document.body, { childList: true, subtree: true });
// Keep CodeMirror's virtualized range anchored to the item where the drag ended.
const focusIndex =
runtime.codeMirrorCurrentCompletions?.(view.state).findIndex((completion) => {
const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection;
return marker?.sessionKey === sessionKey && marker.candidateKey === focusCandidateKey;
}) ?? -1;
if (focusIndex >= 0 && runtime.codeMirrorSetSelectedCompletion) view.dispatch({ effects: runtime.codeMirrorSetSelectedCompletion(focusIndex) });
runtime.codeMirrorStartCompletion?.(view);
restoreNextFrame();
restoreTimer = window.setTimeout(stopRestore, 1500);
}, 0);
}
function finishBatchColumnSelectionDrag(refresh = true) {
const state = batchColumnSelectionDragState;
if (!state) return;
const scrollState = state.scrollElement ? { element: state.scrollElement, top: state.scrollElement.scrollTop, left: state.scrollElement.scrollLeft } : undefined;
batchColumnSelectionDragState = null;
if (state.autoScrollFrame) window.cancelAnimationFrame(state.autoScrollFrame);
window.removeEventListener("pointermove", onBatchColumnSelectionPointerMove, true);
window.removeEventListener("pointerup", onBatchColumnSelectionPointerUp, true);
window.removeEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true);
window.removeEventListener("blur", onBatchColumnSelectionPointerCancel, true);
document.body.style.userSelect = state.previousUserSelect;
if (refresh) scheduleBatchColumnSelectionRefresh(state.view, state.sessionKey, state.focusCandidateKey, scrollState);
}
function clearBatchColumnSelectionSession() {
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
setBatchColumnSelectionExpandedRendering(false);
batchColumnSelectionSession = null;
}
function isBatchColumnSelectionAction(item: QueryCompletionItem | BatchColumnSelectionActionItem): item is BatchColumnSelectionActionItem {
return "batchColumnSelectionAction" in item && item.batchColumnSelectionAction === true;
}
function batchColumnSelectionCandidateKey(item: SqlCompletionItem): string {
return `${item.label}\u0000${item.apply ?? item.label}`;
}
function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document: string, from: number, to: number): BatchColumnSelectionSession | null {
const selectableItems = items.filter((item) => item.type === "column" && item.batchSelectionMode && item.apply);
if (selectableItems.length === 0) {
clearBatchColumnSelectionSession();
return null;
}
const mode = selectableItems[0]!.batchSelectionMode!;
const candidates = selectableItems.filter((item) => item.batchSelectionMode === mode).map((item) => ({ key: batchColumnSelectionCandidateKey(item), apply: item.apply! }));
const key = `${mode}\u0000${from}\u0000${to}\u0000${document}`;
if (!batchColumnSelectionSession || batchColumnSelectionSession.key !== key) {
batchColumnSelectionSession = {
key,
mode,
qualifier: selectableItems[0]!.batchSelectionQualifier,
document,
from,
to,
replaceClosingQuote: selectableItems[0]!.replaceClosingQuote,
replaceSelectWildcard: selectableItems[0]!.replaceSelectWildcard,
candidates,
selectedKeys: new Set(),
completionOptions: new Map(),
};
setBatchColumnSelectionExpandedRendering(true);
return batchColumnSelectionSession;
}
setBatchColumnSelectionExpandedRendering(true);
batchColumnSelectionSession.candidates = candidates;
batchColumnSelectionSession.qualifier = selectableItems[0]!.batchSelectionQualifier;
const candidateKeys = new Set(candidates.map((candidate) => candidate.key));
batchColumnSelectionSession.selectedKeys = new Set([...batchColumnSelectionSession.selectedKeys].filter((candidateKey) => candidateKeys.has(candidateKey)));
batchColumnSelectionSession.completionOptions = new Map([...batchColumnSelectionSession.completionOptions].filter(([candidateKey]) => candidateKeys.has(candidateKey)));
return batchColumnSelectionSession;
}
function batchColumnSelectionMarkerForItem(item: QueryCompletionItem): QueryCompletionOption["dbxBatchColumnSelection"] | undefined {
if (!("batchSelectionMode" in item) || item.type !== "column" || !item.batchSelectionMode || !item.apply) return undefined;
const session = batchColumnSelectionSession;
if (!session || session.mode !== item.batchSelectionMode) return undefined;
const candidateKey = batchColumnSelectionCandidateKey(item);
if (!session.candidates.some((candidate) => candidate.key === candidateKey)) return undefined;
return { sessionKey: session.key, candidateKey };
}
function cacheBatchColumnSelectionOption(marker: QueryCompletionOption["dbxBatchColumnSelection"], option: QueryCompletionOption): QueryCompletionOption {
if (!marker || !batchColumnSelectionSession || batchColumnSelectionSession.key !== marker.sessionKey) return option;
const cached = batchColumnSelectionSession.completionOptions.get(marker.candidateKey);
if (cached) return cached;
batchColumnSelectionSession.completionOptions.set(marker.candidateKey, option);
return option;
}
function toggleBatchColumnSelection(view: EditorViewType, sessionKey: string, candidateKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return;
setBatchColumnSelectionValue(sessionKey, candidateKey, !session.selectedKeys.has(candidateKey));
updateBatchColumnSelectionActionLabel(view, sessionKey);
// Reopen the list so the action row and all virtualized checkboxes reflect the new state.
scheduleBatchColumnSelectionRefresh(view, sessionKey, candidateKey);
}
function toggleSelectedBatchColumnSelection(view: EditorViewType): boolean {
const completion = runtime.codeMirrorSelectedCompletion?.(view.state) as QueryCompletionOption | null | undefined;
const marker = completion?.dbxBatchColumnSelection;
if (!marker) return false;
toggleBatchColumnSelection(view, marker.sessionKey, marker.candidateKey);
return true;
}
function batchColumnSelectionMarkerAtPoint(clientX: number, clientY: number): { checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker } | null {
const target = document.elementFromPoint(clientX, clientY);
if (!(target instanceof HTMLElement)) return null;
const checkbox = target.closest<HTMLInputElement>("input.cm-batch-column-selection-checkbox") ?? target.closest<HTMLElement>("[role='option']")?.querySelector<HTMLInputElement>("input.cm-batch-column-selection-checkbox") ?? null;
if (!checkbox) return null;
const marker = batchColumnSelectionCheckboxMarkers.get(checkbox);
return marker ? { checkbox, marker } : null;
}
function batchColumnSelectionScrollElement(checkbox: HTMLElement): HTMLElement | null {
let current = checkbox.parentElement;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
if (current.scrollHeight > current.clientHeight && /(auto|scroll|overlay)/.test(style.overflowY)) return current;
current = current.parentElement;
}
return null;
}
function visibleBatchColumnSelectionCheckboxes(sessionKey: string): Array<{ checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker }> {
return Array.from(document.querySelectorAll<HTMLInputElement>("input.cm-batch-column-selection-checkbox")).flatMap((checkbox) => {
const marker = batchColumnSelectionCheckboxMarkers.get(checkbox);
return marker?.sessionKey === sessionKey ? [{ checkbox, marker }] : [];
});
}
function updateBatchColumnSelectionAtPoint(state: BatchColumnSelectionDragState, clientX: number, clientY: number) {
const hit = batchColumnSelectionMarkerAtPoint(clientX, clientY);
if (!hit || hit.marker.sessionKey !== state.sessionKey) return;
state.focusCandidateKey = hit.marker.candidateKey;
const session = batchColumnSelectionSession;
if (!session) return;
const visibleCheckboxes = visibleBatchColumnSelectionCheckboxes(state.sessionKey);
const anchorIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === state.anchorCandidateKey);
const focusIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === hit.marker.candidateKey);
if (anchorIndex < 0 || focusIndex < 0) return;
const nextSelectedKeys = new Set(state.baseSelectedKeys);
const rangeStart = Math.min(anchorIndex, focusIndex);
const rangeEnd = Math.max(anchorIndex, focusIndex);
for (let index = rangeStart; index <= rangeEnd; index++) {
const candidate = visibleCheckboxes[index]?.marker;
if (!candidate) continue;
if (state.selected) nextSelectedKeys.add(candidate.candidateKey);
else nextSelectedKeys.delete(candidate.candidateKey);
}
session.selectedKeys = nextSelectedKeys;
visibleCheckboxes.forEach(({ checkbox, marker }) => {
checkbox.checked = nextSelectedKeys.has(marker.candidateKey);
});
updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);
}
const BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX = 36;
const BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX = 20;
function runBatchColumnSelectionAutoScroll() {
const state = batchColumnSelectionDragState;
if (!state) return;
state.autoScrollFrame = 0;
const scroller = state.scrollElement;
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
if (state.pointerClientX < rect.left || state.pointerClientX > rect.right) return;
let delta = 0;
if (state.pointerClientY < rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) {
delta = -BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX - state.pointerClientY) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX);
} else if (state.pointerClientY > rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) {
delta = BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (state.pointerClientY - (rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX)) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX);
}
if (!delta) return;
const previousScrollTop = scroller.scrollTop;
scroller.scrollTop = Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, scroller.scrollTop + delta));
if (scroller.scrollTop === previousScrollTop) return;
updateBatchColumnSelectionAtPoint(state, state.pointerClientX, state.pointerClientY);
state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll);
}
function scheduleBatchColumnSelectionAutoScroll(state: BatchColumnSelectionDragState) {
if (!state.scrollElement || state.autoScrollFrame) return;
state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll);
}
function onBatchColumnSelectionPointerMove(event: PointerEvent) {
const state = batchColumnSelectionDragState;
if (!state || event.pointerId !== state.pointerId) return;
if ((event.buttons & 1) === 0) {
finishBatchColumnSelectionDrag();
return;
}
state.pointerClientX = event.clientX;
state.pointerClientY = event.clientY;
updateBatchColumnSelectionAtPoint(state, event.clientX, event.clientY);
scheduleBatchColumnSelectionAutoScroll(state);
}
function onBatchColumnSelectionPointerUp(event: PointerEvent) {
const state = batchColumnSelectionDragState;
if (!state || event.pointerId !== state.pointerId) return;
finishBatchColumnSelectionDrag();
}
function onBatchColumnSelectionPointerCancel() {
finishBatchColumnSelectionDrag();
}
function startBatchColumnSelectionDrag(view: EditorViewType, marker: BatchColumnSelectionCheckboxMarker, checkbox: HTMLInputElement, event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return;
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
const session = batchColumnSelectionSession;
if (!session || session.key !== marker.sessionKey) return;
if (!session.candidates.some((candidate) => candidate.key === marker.candidateKey)) return;
const selected = !session.selectedKeys.has(marker.candidateKey);
const baseSelectedKeys = new Set(session.selectedKeys);
setBatchColumnSelectionValue(marker.sessionKey, marker.candidateKey, selected, checkbox);
batchColumnSelectionDragState = {
view,
sessionKey: marker.sessionKey,
pointerId: event.pointerId,
anchorCandidateKey: marker.candidateKey,
baseSelectedKeys,
selected,
focusCandidateKey: marker.candidateKey,
previousUserSelect: document.body.style.userSelect,
scrollElement: batchColumnSelectionScrollElement(checkbox),
pointerClientX: event.clientX,
pointerClientY: event.clientY,
autoScrollFrame: 0,
};
updateBatchColumnSelectionActionLabel(view, marker.sessionKey);
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onBatchColumnSelectionPointerMove, true);
window.addEventListener("pointerup", onBatchColumnSelectionPointerUp, true);
window.addEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true);
window.addEventListener("blur", onBatchColumnSelectionPointerCancel, true);
}
function renderBatchColumnSelectionCheckbox(completion: Completion, _state: import("@codemirror/state").EditorState, currentView: EditorViewType): Node | null {
const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection;
const session = batchColumnSelectionSession;
if (!marker || !session || session.key !== marker.sessionKey) return null;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "cm-batch-column-selection-checkbox";
checkbox.checked = session.selectedKeys.has(marker.candidateKey);
checkbox.tabIndex = -1;
checkbox.setAttribute("aria-label", completion.displayLabel ?? completion.label);
batchColumnSelectionCheckboxMarkers.set(checkbox, marker);
checkbox.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
startBatchColumnSelectionDrag(currentView, marker, checkbox, event);
});
checkbox.addEventListener("mousedown", (event) => {
// CodeMirror accepts the completion on a list-item mousedown. Keep this
// interaction local to the checkbox so a field can be toggled repeatedly.
event.preventDefault();
event.stopPropagation();
});
checkbox.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
return checkbox;
}
// The checkbox above only guards its own hitbox. A mousedown/click anywhere
// else in the row (the label, detail text, icon — most of the row's area)
// falls through to CodeMirror's own list-item handler, which accepts that
// row as a single completion and discards every other checked field. Guard
// the whole row the same way, at the document level, since the completion
// tooltip renders outside the editor's own DOM (see tooltipParent above).
function onBatchColumnSelectionRowGuard(event: MouseEvent) {
if (event.button !== 0 || !batchColumnSelectionSession) return;
const target = event.target;
if (!(target instanceof Element) || target.closest("input.cm-batch-column-selection-checkbox")) return;
const hit = batchColumnSelectionMarkerAtPoint(event.clientX, event.clientY);
if (!hit || hit.marker.sessionKey !== batchColumnSelectionSession.key) return;
event.preventDefault();
event.stopPropagation();
if (event.type !== "mousedown") return;
const currentView = view.value;
if (currentView) toggleBatchColumnSelection(currentView, hit.marker.sessionKey, hit.marker.candidateKey);
}
function renderBatchColumnSelectionActionMarker(completion: Completion): Node | null {
const action = (completion as QueryCompletionOption).dbxBatchColumnSelectionAction;
if (!action) return null;
const marker = document.createElement("span");
marker.className = "cm-batch-column-selection-action-marker";
marker.hidden = true;
batchColumnSelectionActionMarkers.set(marker, { sessionKey: action.sessionKey, toggleAll: action.toggleAll === true });
return marker;
}
function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelectionActionItem, from: number, to: number) {
const session = batchColumnSelectionSession;
if (!session || session.key !== item.sessionKey || session.document !== view.state.doc.toString()) return;
const selected = session.candidates.filter((candidate) => session.selectedKeys.has(candidate.key));
if (selected.length === 0) {
toast(t("editor.completion.selectColumnsBeforeInsert"), 3000);
return;
}
const columns = batchColumnSelectionColumnList(
selected.map((candidate) => candidate.apply),
session.mode,
session.qualifier,
);
let replaceTo = batchColumnSelectionReplaceTo({
from,
to,
mode: session.mode,
nextCharacter: view.state.sliceDoc(to, to + 1),
replaceClosingQuote: session.replaceClosingQuote,
replaceSelectWildcard: session.replaceSelectWildcard,
});
let insert = columns;
if (session.mode === "insert") {
const replacement = batchColumnSelectionInsertReplacement({
document: view.state.doc.toString(),
to,
columns,
valuesKeyword: settingsStore.editorSettings.sqlFormatter.keywordCase === "lower" ? "values" : "VALUES",
valueCount: selected.length,
});
replaceTo = replacement.replaceTo;
insert = replacement.insert;
}
if (session.mode === "insert" && !runtime.codeMirrorSnippetCompletion) {
clearBatchColumnSelectionSession();
return;
}
clearBatchColumnSelectionSession();
markCompletionAccepted(item);
if (session.mode === "insert") {
const snippet = runtime.codeMirrorSnippetCompletion(insert, { label: item.label });
if (typeof snippet.apply === "function") {
snippet.apply(view, snippet, from, replaceTo);
return;
}
}
view.dispatch({
changes: { from, to: replaceTo, insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
}
function applySelectedBatchColumnSelection(view: EditorViewType): boolean {
const session = batchColumnSelectionSession;
if (!session || !isBatchColumnSelectionCompletionActive(runtime.codeMirrorCompletionStatus?.(view.state) ?? null) || session.document !== view.state.doc.toString() || session.selectedKeys.size === 0) return false;
applyBatchColumnSelection(
view,
{
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
},
session.from,
session.to,
);
return true;
}
function attach(currentView: EditorViewType, tooltipParent: HTMLElement) {
batchColumnSelectionTooltipParents.set(currentView, tooltipParent);
document.addEventListener("mousedown", onBatchColumnSelectionRowGuard, true);
document.addEventListener("click", onBatchColumnSelectionRowGuard, true);
}
function dispose() {
finishBatchColumnSelectionDrag(false);
cancelBatchColumnSelectionRefresh();
if (batchColumnSelectionRenderLimitTimer !== null) window.clearTimeout(batchColumnSelectionRenderLimitTimer);
batchColumnSelectionRenderLimitTimer = null;
batchColumnSelectionSession = null;
document.removeEventListener("mousedown", onBatchColumnSelectionRowGuard, true);
document.removeEventListener("click", onBatchColumnSelectionRowGuard, true);
}
return {
cancelBatchColumnSelectionRefresh,
batchColumnSelectionAllSelected,
toggleAllBatchColumnSelection,
finishBatchColumnSelectionDrag,
clearBatchColumnSelectionSession,
isBatchColumnSelectionAction,
prepareBatchColumnSelectionSession,
batchColumnSelectionMarkerForItem,
cacheBatchColumnSelectionOption,
toggleSelectedBatchColumnSelection,
renderBatchColumnSelectionCheckbox,
onBatchColumnSelectionRowGuard,
renderBatchColumnSelectionActionMarker,
applyBatchColumnSelection,
applySelectedBatchColumnSelection,
attach,
dispose,
get expandedRendering() {
return batchColumnSelectionExpandedRendering;
},
};
}
@@ -0,0 +1,40 @@
import type { Ref, ShallowRef } from "vue";
import type { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import type { QueryEditorCodeMirrorModules, QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
interface QueryEditorCodeMirrorConfiguration {
state: EditorState;
parent: HTMLElement;
onReady: () => void;
}
interface QueryEditorCodeMirrorOptions {
editorRef: Readonly<Ref<HTMLElement | null | undefined>>;
view: ShallowRef<EditorView | null>;
runtime: QueryEditorCodeMirrorRuntime;
beforeLoad: () => void;
prepare: (modules: QueryEditorCodeMirrorModules) => Promise<QueryEditorCodeMirrorConfiguration | undefined>;
}
export function useQueryEditorCodeMirror(options: QueryEditorCodeMirrorOptions) {
let disposed = false;
async function initialize() {
if (disposed || !options.editorRef.value) return;
options.beforeLoad();
const modules = await options.runtime.load();
if (disposed) return;
const configuration = await options.prepare(modules);
if (disposed || !configuration) return;
options.view.value = new modules.EditorView({ state: configuration.state, parent: configuration.parent });
configuration.onReady();
}
function dispose() {
disposed = true;
options.view.value?.destroy();
}
return { initialize, dispose };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
import type { EditorView as EditorViewType } from "@codemirror/view";
import { type QueryCompletionOption } from "./useQueryEditorBatchSelection";
import { insertQueryEditorNewline } from "@/lib/editor/queryEditorNewline";
import { isBatchColumnSelectionCompletionActive } from "@/lib/editor/batchColumnSelection";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { useQueryEditorCompletion } from "./useQueryEditorCompletion";
import type { useQueryEditorBatchSelection } from "./useQueryEditorBatchSelection";
interface QueryEditorCompletionKeysOptions {
props: Readonly<QueryEditorProps>;
settingsStore: ReturnType<typeof useSettingsStore>;
runtime: QueryEditorCodeMirrorRuntime;
completion: Pick<ReturnType<typeof useQueryEditorCompletion>, "suppressAutoStartUntil">;
batchSelection: Pick<ReturnType<typeof useQueryEditorBatchSelection>, "applySelectedBatchColumnSelection">;
isEditorComposing: (view: EditorViewType) => boolean;
retryDelayMs: number;
tabMaxWaitMs: number;
enterMaxWaitMs: number;
}
export function useQueryEditorCompletionKeys(options: QueryEditorCompletionKeysOptions) {
const { props, settingsStore, runtime: codeMirrorRuntime, completion, isEditorComposing } = options;
const { applySelectedBatchColumnSelection } = options.batchSelection;
const COMPLETION_TAB_RETRY_DELAY_MS = options.retryDelayMs;
const COMPLETION_TAB_MAX_WAIT_MS = options.tabMaxWaitMs;
const COMPLETION_ENTER_MAX_WAIT_MS = options.enterMaxWaitMs;
let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;
let cancelPendingCompletionEnter: (() => void) | null = null;
// Resolve the indent unit (one Tab worth) from the SQL formatter settings so
// the Tab key, multi-line indent and auto-indent all honor the configured width.
function editorIndentUnit(): string {
const { useTabs, tabWidth } = settingsStore.editorSettings.sqlFormatter;
return useTabs ? "\t" : " ".repeat(tabWidth);
}
function handleTab(view: EditorViewType): boolean {
if (isEditorComposing(view)) return false;
if (view.state.selection.ranges.some((range) => !range.empty)) {
// Snippet navigation selects the whole field, including after Shift+Tab.
// Give that active session priority over ordinary selected-text indentation.
return (codeMirrorRuntime.codeMirrorNextSnippetField?.(view) ?? false) || (codeMirrorRuntime.codeMirrorIndentMore?.(view) ?? false);
}
if (tabKeyAcceptsCompletion()) {
return acceptCompletionOrNextSnippetField(view) || performNormalTab(view);
}
return handleTabWithoutAcceptingCompletion(view) || performNormalTab(view);
}
// The Tab key is always wired up for indentation and snippet-field navigation,
// but it must only accept an open completion popup when the user's configured
// "accept completion" shortcut is actually Tab — otherwise a user who remapped
// that shortcut (e.g. to Enter) would find Tab silently accepting completions
// anyway, ignoring their setting (dbx#6236).
function tabKeyAcceptsCompletion(): boolean {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
return shortcutToCodeMirrorKey(shortcuts.acceptCompletion) === "Tab";
}
function handleTabWithoutAcceptingCompletion(view: EditorViewType): boolean {
if (codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state)) return false;
return codeMirrorRuntime.codeMirrorNextSnippetField?.(view) ?? false;
}
function performNormalTab(view: EditorViewType): boolean {
const { state, dispatch } = view;
if (state.selection.ranges.some((range) => !range.empty)) return codeMirrorRuntime.codeMirrorIndentMore?.(view) ?? false;
const sel = state.selection.main;
const line = state.doc.lineAt(sel.from);
const before = line.text.slice(0, sel.from - line.from);
if (/^\s*$/.test(before)) return codeMirrorRuntime.codeMirrorIndentMore?.(view) ?? false;
dispatch(
state.update(state.replaceSelection(editorIndentUnit()), {
userEvent: "input.type",
}),
);
return true;
}
function handleEnter(view: EditorViewType): boolean {
// While an IME composition is active, Enter confirms the composition (the
// candidate list); intercepting it here would accept a completion popup on
// top of the composition instead of committing the typed text (issue #8029).
if (isEditorComposing(view)) return false;
clearPendingCompletionEnter();
if (isBatchColumnSelectionCompletionActive(codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true;
// CodeMirror's default completion keymap is disabled so batch selection can
// take precedence. Preserve its normal single-item acceptance here.
if (codeMirrorRuntime.codeMirrorAcceptCompletion?.(view)) return true;
if (settingsStore.editorSettings.selectFirstCompletionOnOpen && codeMirrorRuntime.codeMirrorCompletionStatus) {
let cancelRetry: (() => void) | null = null;
const result = acceptSelectedCompletionWithRetry(view, {
completionStatus: codeMirrorRuntime.codeMirrorCompletionStatus,
acceptCompletion: codeMirrorRuntime.codeMirrorAcceptCompletion,
selectedCompletionIndex: codeMirrorRuntime.codeMirrorSelectedCompletionIndex,
selectFirstCompletion: codeMirrorRuntime.codeMirrorSelectFirstCompletion,
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
isComposing: () => isEditorComposing(view),
onUnavailable: () => insertNewlineWithoutCompletion(view),
onSettled: () => {
if (cancelPendingCompletionEnter === cancelRetry) cancelPendingCompletionEnter = null;
},
});
if (result.handled) {
cancelRetry = result.cancel ?? null;
cancelPendingCompletionEnter = cancelRetry;
return true;
}
}
return insertNewlineWithoutCompletion(view);
}
function clearPendingCompletionEnter() {
cancelPendingCompletionEnter?.();
cancelPendingCompletionEnter = null;
}
function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
codeMirrorRuntime.codeMirrorCloseCompletion?.(view);
completion.suppressAutoStartUntil = Date.now() + 750;
const handled = insertQueryEditorNewline(view, codeMirrorRuntime.codeMirrorInsertNewlineKeepIndent, props.databaseType);
if (!handled) completion.suppressAutoStartUntil = 0;
return handled;
}
function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean {
// A non-empty selection belongs to snippet navigation or block indentation,
// not word completion. A popup opened by an indent edit must not hijack Tab.
if (isEditorComposing(view)) return false;
if (view.state.selection.ranges.every((range) => range.empty)) {
const completionStatus = codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) ?? null;
if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorRuntime.codeMirrorAcceptCompletion, codeMirrorRuntime.codeMirrorSelectedCompletionIndex, codeMirrorRuntime.codeMirrorSelectFirstCompletion)) return true;
if (completionStatus) return waitForCompletionTab(view);
}
return codeMirrorRuntime.codeMirrorNextSnippetField?.(view) ?? false;
}
function acceptSqlServerCompletionOnSpace(view: EditorViewType): boolean {
if (isEditorComposing(view)) return false;
if (props.databaseType !== "sqlserver" || !settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion) return false;
if (codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) !== "active") return false;
// A non-empty selection belongs to block editing, not word completion.
if (!view.state.selection.main.empty) return false;
const selected = codeMirrorRuntime.codeMirrorSelectedCompletion?.(view.state) as QueryCompletionOption | null | undefined;
const completionType = selected?.type;
if (completionType !== "keyword" && completionType !== "table" && completionType !== "column") return false;
// Batch-selection rows own Space for checkbox toggling; this Prec.highest binding outranks their keymap.
if (selected?.dbxBatchColumnSelection || selected?.dbxBatchColumnSelectionAction) return false;
if (!(codeMirrorRuntime.codeMirrorAcceptCompletion?.(view) ?? false)) return false;
const selection = view.state.selection.main;
if (!selection.empty) return true;
const cursor = selection.head;
const previousCharacter = cursor > 0 ? view.state.sliceDoc(cursor - 1, cursor) : "";
if (/\s/.test(previousCharacter)) return true;
const nextCharacter = view.state.sliceDoc(cursor, cursor + 1);
if (/\s/.test(nextCharacter)) {
view.dispatch({ selection: { anchor: cursor + 1 }, scrollIntoView: true });
} else {
view.dispatch({
changes: { from: cursor, insert: " " },
selection: { anchor: cursor + 1 },
scrollIntoView: true,
});
}
return true;
}
function clearPendingCompletionTab() {
if (pendingCompletionTabTimer === null) return;
clearTimeout(pendingCompletionTabTimer);
pendingCompletionTabTimer = null;
}
function waitForCompletionTab(view: EditorViewType): boolean {
clearPendingCompletionTab();
const initialDoc = view.state.doc;
const initialSelectionRanges = view.state.selection.ranges.map((range) => ({ anchor: range.anchor, head: range.head }));
const startedAt = Date.now();
const retry = () => {
pendingCompletionTabTimer = null;
// The user started an IME composition while waiting for the pending
// completion; Tab now belongs to the candidate list, so drop the queued
// acceptance (and the normal-Tab fallback) instead of fighting the IME.
if (isEditorComposing(view)) return;
const selectionRanges = view.state.selection.ranges;
if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => !range.empty || range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) return;
const completionStatus = codeMirrorRuntime.codeMirrorCompletionStatus?.(view.state) ?? null;
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorRuntime.codeMirrorAcceptCompletion, codeMirrorRuntime.codeMirrorSelectedCompletionIndex, codeMirrorRuntime.codeMirrorSelectFirstCompletion)) return;
if (completionStatus && Date.now() - startedAt < COMPLETION_TAB_MAX_WAIT_MS) {
pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS);
return;
}
// A pending completion may resolve without any applicable option. Preserve
// snippet navigation first, then fall back to the editor's normal Tab action.
if (codeMirrorRuntime.codeMirrorNextSnippetField?.(view)) return;
performNormalTab(view);
};
pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS);
return true;
}
return { editorIndentUnit, handleTab, handleEnter, acceptCompletionOrNextSnippetField, acceptSqlServerCompletionOnSpace, clearPendingCompletionEnter, clearPendingCompletionTab };
}
@@ -0,0 +1,598 @@
import type { EditorState, Text } from "@codemirror/state";
import type { CompletionMetadataScope } from "./queryEditorTypes";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import type { SqlSemanticModel } from "@/lib/sql/semantic/types";
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
import { mergeSqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import { requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier } from "@/lib/database/databaseFeatureSupport";
import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import * as api from "@/lib/backend/api";
import { isSqlVirtualTableReference } from "@/lib/sql/semantic/diagnostics";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { DatabaseType, SqlTableReference } from "@/types/database";
import type { ShallowRef } from "vue";
import type { EditorView } from "@codemirror/view";
import type { useConnectionStore } from "@/stores/connectionStore";
import type { QueryEditorProps } from "./queryEditorTypes";
export interface RoutineCompletionTarget {
schema?: string;
parentName?: string;
globalSearch?: boolean;
}
interface QueryEditorCompletionMetadataOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorView | null>;
connectionStore: ReturnType<typeof useConnectionStore>;
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
remoteLatencyBudgetMs: number;
maxCompletionTables: number;
onDemandMinPrefix: number;
semanticCompletionEnabled: boolean;
}
export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionMetadataOptions) {
const { props, view, connectionStore, sqlBehaviorDialect } = options;
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = options.remoteLatencyBudgetMs;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = options.onDemandMinPrefix;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
// Completion cache
let cachedTables: SqlCompletionTable[] = [];
const cachedCompletionObjectsByScope = new Map<string, SqlCompletionObject[]>();
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedPrefixColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
function sqlCompletionDialectOptions() {
return {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
editorState: view.value?.state,
};
}
let editorCompletionContextCache: {
doc: Text;
editorState: EditorState | undefined;
position: number;
databaseType: DatabaseType | undefined;
dialect: string | undefined;
context: ReturnType<typeof getSqlCompletionContext>;
} | null = null;
function getEditorSqlCompletionContext(sql: string, position: number, editorState = view.value?.state): ReturnType<typeof getSqlCompletionContext> {
const dialect = sqlBehaviorDialect();
const doc = editorState?.doc;
if (doc && editorCompletionContextCache?.doc === doc && editorCompletionContextCache.editorState === editorState && editorCompletionContextCache.position === position && editorCompletionContextCache.databaseType === props.databaseType && editorCompletionContextCache.dialect === dialect) {
return editorCompletionContextCache.context;
}
const context = getSqlCompletionContext(sql, position, {
databaseType: props.databaseType,
dialect,
editorState,
});
if (doc) {
editorCompletionContextCache = {
doc,
editorState,
position,
databaseType: props.databaseType,
dialect,
context,
};
}
return context;
}
// CTE definition metadata is cursor-independent, but hover/ctrl-click models still carry the
// position; memoize per (Text node, position, dialect, state) so hover and click at the same
// position share one parse. Any edit swaps the Text node, invalidating the cache for free.
let editorSemanticModelCache: {
doc: Text;
editorState: EditorState | undefined;
position: number;
databaseType: DatabaseType | undefined;
dialect: string | undefined;
model: SqlSemanticModel;
} | null = null;
function getEditorSemanticModel(sql: string, position: number, editorState = view.value?.state): SqlSemanticModel | null {
if (!SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const dialect = sqlBehaviorDialect();
const doc = editorState?.doc;
if (doc && editorSemanticModelCache?.doc === doc && editorSemanticModelCache.editorState === editorState && editorSemanticModelCache.position === position && editorSemanticModelCache.databaseType === props.databaseType && editorSemanticModelCache.dialect === dialect) {
return editorSemanticModelCache.model;
}
const model = buildSqlSemanticModel(sql, position, {
databaseType: props.databaseType,
dialect,
editorState,
});
if (doc) {
editorSemanticModelCache = {
doc,
editorState,
position,
databaseType: props.databaseType,
dialect,
model,
};
}
return model;
}
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
return shouldUseOracleSessionCompletionColumns({
databaseType: props.databaseType,
selectedSchema: props.schema,
referenceSchema: schema,
clientSessionId: props.clientSessionId,
});
}
function completionColumnRequestContext(reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
return {
clientSessionId: props.clientSessionId,
version: props.completionContextVersion,
tableQuoted: reference?.nameQuoted,
schemaQuoted: reference?.schemaQuoted,
};
}
async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, prefix?: string) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
let columns: SqlCompletionColumn[];
if (prefix && prefix.length >= 2 && (props.databaseType === "postgres" || props.databaseType === "mysql")) {
try {
columns = await connectionStore.listCompletionColumnsByPrefix(connectionId, database, table, schema, prefix, catalog, completionColumnRequestContext(reference));
} catch {
columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
}
} else {
columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
}
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
const requestedVersion = props.completionContextVersion;
const sessionScoped = usesOracleSessionCompletionColumns(schema);
const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog);
if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context");
return columns;
}
function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope?: CompletionMetadataScope) {
const schema = table.schema ?? scope?.schema ?? props.schema;
const scopedDatabase = scope && scope.database !== props.database ? scope.database : undefined;
const database = supportsDatabaseSchemaQualifierCompletion() ? (table.database ?? scopedDatabase) : undefined;
const baseKey = schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name;
if (props.databaseType !== "postgres" || (!table.nameQuoted && !table.schemaQuoted)) return baseKey;
return `${baseKey}:quoted:s=${table.schemaQuoted ? "1" : "0"}:t=${table.nameQuoted ? "1" : "0"}`;
}
function completionPrefixCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string) {
return `${completionCacheKey(table, scope)}:prefix:${prefix.trim().toLowerCase()}`;
}
function lookupCachedPrefixColumns(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope: CompletionMetadataScope | undefined, prefix: string): SqlCompletionColumn[] | undefined {
const normalizedPrefix = prefix.trim().toLowerCase();
const exactKey = completionPrefixCacheKey(table, scope, normalizedPrefix);
const exact = cachedPrefixColumnsByTable.get(exactKey);
if (exact) return exact;
const marker = `${completionCacheKey(table, scope)}:prefix:`;
let best: { prefix: string; columns: SqlCompletionColumn[] } | undefined;
for (const [key, columns] of cachedPrefixColumnsByTable) {
if (!key.startsWith(marker)) continue;
const cachedPrefix = key.slice(marker.length);
if (!normalizedPrefix.startsWith(cachedPrefix) || (best && cachedPrefix.length <= best.prefix.length)) continue;
best = { prefix: cachedPrefix, columns };
}
if (!best) return undefined;
return best.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix));
}
const pendingInsertValueHintColumnLoads = new Set<string>();
function insertHintCacheKey(table: { name: string; schema?: string | null; database?: string | null }) {
if (table.database) {
return table.schema ? `${table.database}.${table.schema}.${table.name}` : `${table.database}.${table.name}`;
}
return completionCacheKey(table);
}
function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string; catalog?: string } | null {
if (props.database == null) return null;
if (table.database) {
return { database: table.database, schema: table.schema ?? undefined, catalog: props.catalog };
}
return completionMetadataTarget(table);
}
function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined {
const cacheKey = insertHintCacheKey({ name: table, schema, database });
if (props.databaseType === "sqlserver") return cachedInsertValueHintColumnsByTable.get(cacheKey);
const cached = cachedColumnsByTable.get(cacheKey);
if (!cached) return undefined;
return cached.map((column) => column.name);
}
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
if (!props.connectionId || props.database == null) return;
if (!supportsInsertValueHints(props.databaseType)) return;
const cacheKey = insertHintCacheKey({ name: table, schema, database });
const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey);
if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
const target = insertHintMetadataTarget({ name: table, schema, database });
if (!target) return;
pendingInsertValueHintColumnLoads.add(cacheKey);
const connectionId = props.connectionId;
const databaseType = props.databaseType;
const loadColumns = async () => {
if (databaseType === "sqlserver") {
const querySchema = metadataSchemaForConnection(connectionStore.getConfig(connectionId), target.database, target.schema);
const columns = await api.getSqlServerColumnMetadata(connectionId, target.database, querySchema, table);
cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns));
return;
}
const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema, target.catalog);
cachedColumnsByTable.set(cacheKey, columns);
};
void loadColumns()
.then(() => {
loadedColumnsByTable.add(cacheKey.toLowerCase());
if (view.value) requestInsertValueHintsRefresh(view.value);
})
.catch(() => {})
.finally(() => {
pendingInsertValueHintColumnLoads.delete(cacheKey);
});
}
function supportsDatabaseQualifierCompletion(): boolean {
return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType);
}
function supportsDatabaseSchemaQualifierCompletion(): boolean {
return supportsDatabaseSchemaQualifier(props.databaseType);
}
function usesLocalOnlyCompletionMetadata(): boolean {
return usesLocalOnlyEditorCompletionMetadata(props.databaseType);
}
function usesOnDemandOnlyCompletionColumns(): boolean {
return usesOnDemandOnlyEditorColumnMetadata(props.databaseType);
}
function allowsOnDemandQualifiedTableCompletion(prefix: string): boolean {
if (!usesLocalOnlyCompletionMetadata()) return false;
if (props.databaseType !== "prestosql" && props.databaseType !== "trino") return false;
return prefix.trim().length >= PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX;
}
function completionMetadataTarget(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, scope?: CompletionMetadataScope): { database: string; schema?: string; catalog?: string } | null {
const currentDatabase = scope?.database ?? props.database;
if (currentDatabase == null) return null;
// SQL Server metadata queries require a schema even when the SQL uses an
// unqualified table name. The query editor commonly has no schema selected
// when the user is working from a database-level tab, so use the same
// default as the table/DDL metadata paths instead of returning no columns.
const selectedSchema = table.schema ?? scope?.schema ?? props.schema;
const effectiveSchema = selectedSchema ?? (props.databaseType === "sqlserver" ? metadataSchemaForConnection(connectionStore.getConfig(props.connectionId ?? ""), currentDatabase, undefined) : undefined);
if (supportsDatabaseSchemaQualifierCompletion() && table.database) {
return { database: table.database, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
if (supportsDatabaseQualifierCompletion() && effectiveSchema) {
return { database: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
return { database: currentDatabase, schema: effectiveSchema, catalog: table.catalog ?? props.catalog };
}
function isVirtualCompletionTableReference(table: { name: string; database?: string | null; schema?: string | null }): boolean {
return isSqlVirtualTableReference(table, props.databaseType);
}
function completionQualifiedTableTarget(completionContext: ReturnType<typeof getSqlCompletionContext>): { name: string; database?: string; schema: string } | null {
if (!completionContext.suggestColumns) return null;
const parts = completionContext.qualifierParts ?? completionContext.qualifier?.split(".").filter(Boolean) ?? [];
if (parts.length < 2) return null;
const name = parts[parts.length - 1];
const schema = parts[parts.length - 2];
if (!name || !schema) return null;
const database = supportsDatabaseSchemaQualifierCompletion() && parts.length >= 3 ? parts[parts.length - 3] : undefined;
return { name, database, schema };
}
function completionTablesMatch(left: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }, right: { name: string; catalog?: string | null; database?: string | null; schema?: string | null }) {
if (left.name.toLowerCase() !== right.name.toLowerCase()) return false;
if (left.catalog && right.catalog && left.catalog.toLowerCase() !== right.catalog.toLowerCase()) return false;
if (left.database && right.database && left.database.toLowerCase() !== right.database.toLowerCase()) return false;
if (!left.schema || !right.schema) return true;
return left.schema.toLowerCase() === right.schema.toLowerCase();
}
async function findExactSemanticDiagnosticTable(table: SqlTableReference, scope?: CompletionMetadataScope): Promise<SqlCompletionTable | null> {
if (!props.connectionId || props.database == null) return null;
const target = completionMetadataTarget(table, scope);
if (!target) return null;
const localMatches = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, target.catalog);
const localExact = localMatches.find((item) => completionTablesMatch(item, table));
if (localExact) {
cachedTables = mergeCompletionTables(cachedTables, [localExact]);
return localExact;
}
const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, scope?.schema ?? props.schema, target.catalog, {
verifySchemaMetadata: !!target.schema,
});
cachedTables = mergeCompletionTables(cachedTables, remoteMatches);
return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null;
}
async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }, reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, scope?: CompletionMetadataScope): Promise<boolean> {
if (isVirtualCompletionTableReference(table)) return false;
const cacheKey = completionCacheKey(table, scope);
if (cachedColumnsByTable.has(cacheKey)) return true;
if (!props.connectionId || props.database == null) return false;
const target = completionMetadataTarget(table, scope);
if (!target) return false;
const localColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, table.name, target.schema, target.catalog, completionColumnRequestContext(reference));
if (localColumns.length > 0) {
cachedColumnsByTable.set(cacheKey, localColumns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
return true;
}
let columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema, target.catalog, reference);
// A schema-aware connection can legitimately return an empty result when
// the editor has no selected schema. Resolve the physical table from the
// local/remote table cache and retry with its schema before reporting that
// star expansion is unavailable. This is especially important for aliased
// sources because the alias itself must never be sent as the table name.
if (columns.length === 0 && !table.schema && !target.schema && !supportsDatabaseQualifierCompletion()) {
const schemaCandidates: string[] = [];
const seenSchemas = new Set<string>();
const addSchema = (schema?: string | null) => {
const normalized = schema?.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seenSchemas.has(key)) return;
seenSchemas.add(key);
schemaCandidates.push(normalized);
};
if (props.databaseType === "sqlserver") {
addSchema(metadataSchemaForConnection(connectionStore.getConfig(props.connectionId), target.database, undefined));
}
const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, target.catalog);
localTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
if (schemaCandidates.length === 0 && !usesLocalOnlyCompletionMetadata()) {
const remoteTables = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, undefined, false, undefined, target.catalog);
remoteTables.forEach((candidate) => {
if (candidate.name.toLowerCase() === table.name.toLowerCase()) addSchema(candidate.schema);
});
}
for (const schema of schemaCandidates) {
const schemaTarget = completionMetadataTarget({ ...table, schema }, scope);
if (!schemaTarget) continue;
const retryColumns = await listCompletionColumnsForEditor(props.connectionId, schemaTarget.database, table.name, schemaTarget.schema, schemaTarget.catalog, reference);
if (retryColumns.length > 0) {
columns = retryColumns;
break;
}
}
}
// Do not memoize an empty response as a successful load. Empty results are
// commonly caused by a temporarily unresolved schema; keeping that value
// would prevent the next expansion attempt from retrying after metadata has
// become available.
if (columns.length > 0) {
cachedColumnsByTable.set(cacheKey, columns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
} else {
cachedColumnsByTable.delete(cacheKey);
loadedColumnsByTable.delete(cacheKey.toLowerCase());
}
return true;
}
function isMissingTableMetadataError(error: unknown) {
const message = String(error instanceof Error ? error.message : error).toLowerCase();
return message.includes("42s02") || message.includes("1146") || message.includes("doesn't exist") || message.includes("does not exist") || message.includes("unknown table");
}
async function ensureForeignKeysForTable(table: { name: string; database?: string | null; schema?: string | null }) {
if (isVirtualCompletionTableReference(table)) return;
const cacheKey = completionCacheKey(table);
if (cachedForeignKeysByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
const target = completionMetadataTarget(table);
if (!target) return;
try {
const foreignKeys = await connectionStore.listCompletionForeignKeys(props.connectionId, target.database, table.name, target.schema);
cachedForeignKeysByTable.set(cacheKey, foreignKeys);
} catch (e) {
console.warn(`[DBX] Failed to load foreign keys for ${cacheKey}:`, e);
cachedForeignKeysByTable.set(cacheKey, []);
}
}
async function ensureForeignKeysForTables(tables: Array<{ name: string; database?: string | null; schema?: string | null }>) {
const seen = new Set<string>();
const uniqueTables = tables.filter((table) => {
const key = completionCacheKey(table).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
for (let index = 0; index < uniqueTables.length; index += COMPLETION_METADATA_CONCURRENCY) {
await Promise.all(uniqueTables.slice(index, index + COMPLETION_METADATA_CONCURRENCY).map((table) => ensureForeignKeysForTable(table)));
}
}
function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlCompletionTable[]): SqlCompletionTable[] {
const merged = [...existing];
const indexes = new Map(existing.map((table, index) => [`${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(), index]));
for (const table of incoming) {
const key = `${table.catalog ?? ""}.${table.database ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase();
const index = indexes.get(key);
if (index == null) {
indexes.set(key, merged.length);
merged.push(table);
} else {
const existing = merged[index];
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
merged[index] = {
...existing,
...table,
type: mergeSqlObjectNavigationType(existing.type, table.type),
};
}
}
return merged;
}
function withCompletionLatencyBudget<T>(remote: Promise<T>, local: T): Promise<T> {
return Promise.race([remote, new Promise<T>((resolve) => setTimeout(() => resolve(local), COMPLETION_REMOTE_LATENCY_BUDGET_MS))]);
}
function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string, globalSearch = false, catalog = props.catalog, currentSchema = props.schema): Promise<SqlCompletionTable[]> {
const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema, catalog).map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database }));
const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog).then((tables) => {
const scopedTables = tables.map((table) => ({ ...table, catalog: table.catalog ?? catalog, database: table.database ?? database }));
cachedTables = mergeCompletionTables(cachedTables, scopedTables);
return scopedTables;
});
if (local.length === 0) return remote;
return withCompletionLatencyBudget(remote, local);
}
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
const merged = [...existing];
const indexes = new Map(existing.map((object, index) => [completionObjectIdentityKey(object), index]));
for (const object of incoming) {
const key = completionObjectIdentityKey(object);
const index = indexes.get(key);
if (index == null) {
indexes.set(key, merged.length);
merged.push(object);
} else {
merged[index] = { ...merged[index], ...object };
}
}
return merged;
}
function completionObjectScopeKey(scope: CompletionMetadataScope): string {
return `${scope.database}:${scope.schema ?? ""}`.toLowerCase();
}
function completionObjectsForScope(scope: CompletionMetadataScope): SqlCompletionObject[] {
return cachedCompletionObjectsByScope.get(completionObjectScopeKey(scope)) ?? [];
}
function completionObjectIdentityKey(object: SqlCompletionObject): string {
return `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase();
}
function completionObjectsDiffer(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]): boolean {
if (existing.length !== incoming.length) return true;
return existing.some((object, index) => {
const other = incoming[index];
return (
!other ||
object.name !== other.name ||
object.schema !== other.schema ||
object.type !== other.type ||
object.parentSchema !== other.parentSchema ||
object.parentName !== other.parentName ||
object.dataType !== other.dataType ||
object.signature !== other.signature ||
object.comment !== other.comment ||
object.applyName !== other.applyName ||
object.boost !== other.boost
);
});
}
function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjectsByScope.clear();
cachedColumnsByTable.clear();
cachedPrefixColumnsByTable.clear();
cachedInsertValueHintColumnsByTable.clear();
loadedColumnsByTable.clear();
cachedForeignKeysByTable.clear();
}
return {
sqlCompletionDialectOptions,
getEditorSqlCompletionContext,
ensureColumnsForTable,
cachedColumnsByTable,
completionCacheKey,
getEditorSemanticModel,
completionMetadataTarget,
mergeCompletionTables,
usesLocalOnlyCompletionMetadata,
supportsDatabaseSchemaQualifierCompletion,
supportsDatabaseQualifierCompletion,
completionObjectsForScope,
usesOracleSessionCompletionColumns,
completionQualifiedTableTarget,
completionTablesMatch,
lookupCachedPrefixColumns,
completionColumnRequestContext,
isVirtualCompletionTableReference,
cachedForeignKeysByTable,
usesOnDemandOnlyCompletionColumns,
ensureForeignKeysForTables,
mergeCompletionObjects,
completionObjectsDiffer,
cachedCompletionObjectsByScope,
completionObjectScopeKey,
refreshCompletionColumnsForEditor,
loadedColumnsByTable,
findExactSemanticDiagnosticTable,
isMissingTableMetadataError,
listCompletionColumnsForEditor,
listCompletionTablesWithLatencyBudget,
allowsOnDemandQualifiedTableCompletion,
completionPrefixCacheKey,
cachedPrefixColumnsByTable,
getInsertValueHintTableColumns,
requestInsertValueHintTableColumns,
refreshCompletionCache,
get cachedTables() {
return cachedTables;
},
set cachedTables(tables: SqlCompletionTable[]) {
cachedTables = tables;
},
};
}
@@ -0,0 +1,509 @@
import { onBeforeUnmount, type ShallowRef, type ComputedRef } from "vue";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { useConnectionStore } from "@/stores/connectionStore";
import type { QueryEditorProps, CompletionMetadataScope } from "./queryEditorTypes";
import * as api from "@/lib/backend/api";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { mergeSqlSemanticReferenceAnalysis } from "@/lib/sql/semantic/references";
import { sqlServerUseDatabaseBeforeCursor } from "@/lib/sql/sqlCompletionLookupTarget";
import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics";
import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics";
import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics";
import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics";
import { needsDiagnosticCaretReanchor } from "@/lib/editor/queryEditorDiagnosticCaretAnchor";
import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import {
areSqlSemanticDiagnosticsEqual,
buildSqlParserErrorDiagnostic,
buildSqlSemanticDiagnostics,
isSqlSemanticDiagnosticInputContext,
isSqlVirtualTableReference,
shouldRunSqlSemanticDiagnostics,
sqlSemanticDiagnosticRangesForViewport,
sqlServerRoutineDefinitionRangesForViewport,
tableReferenceKey,
type SqlSemanticDiagnostic,
} from "@/lib/sql/semantic/diagnostics";
import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
import { buildMongoSyntaxDiagnostics } from "@/lib/mongo/mongoSyntaxDiagnostics";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
interface QueryEditorDiagnosticsRuntime {
setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null;
diagnosticComp: import("@codemirror/state").Compartment | null;
buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null;
codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null;
executableStatementRangeCache: ExecutableStatementRangeCache | null;
editorIsActive: boolean;
}
interface QueryEditorDiagnosticMetadata {
readonly cachedTables: SqlCompletionTable[];
cachedColumnsByTable: Map<string, SqlCompletionColumn[]>;
loadedColumnsByTable: Set<string>;
usesOracleSessionCompletionColumns: (schema?: string | null) => boolean;
findExactSemanticDiagnosticTable: (table: SqlTableReference, scope?: CompletionMetadataScope) => Promise<SqlCompletionTable | null>;
completionCacheKey: (table: SqlTableReference, scope?: CompletionMetadataScope) => string;
ensureColumnsForTable: (table: SqlTableReference, reference: undefined, scope?: CompletionMetadataScope) => Promise<boolean>;
isMissingTableMetadataError: (error: unknown) => boolean;
}
interface QueryEditorDiagnosticsOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
settingsStore: ReturnType<typeof useSettingsStore>;
connectionStore: ReturnType<typeof useConnectionStore>;
sqlDriverProfile: ComputedRef<string | undefined>;
sqlStatementParameterOptions: () => SqlParameterOptions;
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
runtime: QueryEditorDiagnosticsRuntime;
metadata: QueryEditorDiagnosticMetadata;
}
export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions) {
const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4;
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
let semanticDiagnosticRunId = 0;
let pendingSemanticDiagnosticPreserveOutsideRanges = false;
function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) {
if (!props.executionError) return [];
if (!props.executionErrorSql || !sqlErrorSqlMatchesEditor(currentState.doc.toString(), props.executionErrorSql)) return [];
const range = resolveSqlErrorDecorationRange(currentState.doc.toString(), props.executionError);
if (!range) return [];
return [
{
...range,
message: props.executionError,
},
];
}
function sqlTextSpanToRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
if (!span.start_line || !span.start_column) return null;
const from = lineColumnToOffset(sql, {
line: span.start_line - 1,
column: span.start_column - 1,
});
const to = lineColumnToOffset(sql, {
line: Math.max(span.end_line - 1, span.start_line - 1),
column: Math.max(span.end_column, span.start_column),
});
if (from == null || to == null || to <= from) return null;
return { from, to };
}
function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").EditorState) {
const sql = currentState.doc.toString();
return semanticDiagnostics
.map((diagnostic) => {
const range = sqlTextSpanToRange(sql, diagnostic.span);
return range
? {
...range,
message: diagnostic.message,
severity: diagnostic.severity,
}
: null;
})
.filter(
(
range,
): range is {
from: number;
to: number;
message: string;
severity: "error" | "warning";
} => !!range,
);
}
// Mirrors CodeMirror's own root handling: shadow roots only expose
// `getSelection` on some browsers, otherwise the owner document holds it.
function editorRootSelection(currentView: EditorViewType): Selection | null {
const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null };
if (root.nodeType !== 11) return (root as unknown as Document).getSelection();
if (typeof root.getSelection === "function") return root.getSelection() ?? null;
return root.ownerDocument?.getSelection() ?? null;
}
// See queryEditorDiagnosticCaretAnchor.ts for why the browser caret needs re-anchoring.
function reanchorCaretAfterDiagnostics(currentView: EditorViewType) {
const selection = currentView.state.selection;
const target = selection.ranges.length === 1 && selection.main.empty ? currentView.domAtPos(selection.main.head) : null;
const domSelection = editorRootSelection(currentView);
const reanchor = needsDiagnosticCaretReanchor({
hasFocus: currentView.hasFocus,
composing: currentView.composing,
domRangeCount: domSelection?.rangeCount ?? 0,
currentAnchorNode: domSelection?.anchorNode ?? null,
currentAnchorOffset: domSelection?.anchorOffset ?? 0,
targetNode: target?.node ?? null,
targetOffset: target?.offset ?? 0,
});
if (!reanchor || !domSelection || !target) return;
domSelection.collapse(target.node, target.offset);
}
function reconfigureDiagnostics() {
const currentView = view.value;
if (!currentView) return;
if (runtime.setSqlDiagnosticsEffect) {
currentView.dispatch({
effects: runtime.setSqlDiagnosticsEffect.of(semanticDiagnostics),
});
} else {
if (!runtime.diagnosticComp || !runtime.buildSqlDiagnosticExtension) return;
currentView.dispatch({
effects: runtime.diagnosticComp.reconfigure(runtime.buildSqlDiagnosticExtension()),
});
}
// Diagnostic decorations re-parent the DOM text nodes around the caret; put the
// browser's insertion point back on the caret before the next keystroke (#9480).
reanchorCaretAfterDiagnostics(currentView);
}
function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
if (areSqlSemanticDiagnosticsEqual(semanticDiagnostics, next)) return;
semanticDiagnostics = next;
reconfigureDiagnostics();
}
function clearScheduledSemanticDiagnostics() {
semanticDiagnosticRunId++;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = null;
pendingSemanticDiagnosticPreserveOutsideRanges = false;
}
function invalidateSemanticDiagnosticsForDocumentChange() {
semanticDiagnosticRunId++;
semanticDiagnostics = [];
}
function shouldSkipSqlSemanticDiagnostics() {
return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
return left.from < right.to && right.from < left.to;
}
function sqlLineColumnAtOffset(sql: string, offset: number): { line: number; column: number } {
const safeOffset = Math.max(0, Math.min(offset, sql.length));
let line = 1;
let lineStart = 0;
for (let index = 0; index < safeOffset; index += 1) {
if (sql[index] === "\n") {
line += 1;
lineStart = index + 1;
}
}
return { line, column: safeOffset - lineStart + 1 };
}
function offsetSqlTextSpan(span: SqlTextSpan, rangeStart: { line: number; column: number }): SqlTextSpan {
const offsetLine = (line: number) => rangeStart.line + line - 1;
const offsetColumn = (line: number, column: number) => (line === 1 ? rangeStart.column + column - 1 : column);
return {
start_line: offsetLine(span.start_line),
start_column: offsetColumn(span.start_line, span.start_column),
end_line: offsetLine(span.end_line),
end_column: offsetColumn(span.end_line, span.end_column),
};
}
function offsetSqlSemanticDiagnostics(diagnostics: readonly SqlSemanticDiagnostic[], range: SqlTextRange, fullSql: string): SqlSemanticDiagnostic[] {
const rangeStart = sqlLineColumnAtOffset(fullSql, range.from);
return diagnostics.map((diagnostic) => ({
...diagnostic,
span: offsetSqlTextSpan(diagnostic.span, rangeStart),
}));
}
function replaceSemanticDiagnosticsInRanges(next: SqlSemanticDiagnostic[], ranges: readonly SqlTextRange[], fullSql: string) {
const retained = semanticDiagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(fullSql, diagnostic.span);
return !diagnosticRange || !ranges.some((range) => rangesOverlap(diagnosticRange, range));
});
setSemanticDiagnostics([...retained, ...next].sort(compareSqlSemanticDiagnostics));
}
function compareSqlSemanticDiagnostics(left: SqlSemanticDiagnostic, right: SqlSemanticDiagnostic): number {
return left.span.start_line - right.span.start_line || left.span.start_column - right.span.start_column || left.span.end_line - right.span.end_line || left.span.end_column - right.span.end_column || left.message.localeCompare(right.message);
}
function semanticDiagnosticMetadataScope(sql: string, range: SqlTextRange): CompletionMetadataScope {
const selectedDatabase = props.database!;
const parsedDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(sql, range.from) : undefined;
if (!parsedDatabase || !props.connectionId) return { database: selectedDatabase, schema: props.schema };
const database = connectionStore.lookupLocalCompletionDatabases(props.connectionId, parsedDatabase, MAX_COMPLETION_TABLES).find((candidate) => candidate.toLowerCase() === parsedDatabase.toLowerCase()) ?? parsedDatabase;
return {
database,
schema: metadataSchemaForConnection(connectionStore.getConfig(props.connectionId), database, undefined),
};
}
function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope): SqlTableReference[] {
if (props.databaseType !== "sqlserver" || scope.database === props.database) return tables;
return tables.map((table) => (table.database ? table : { ...table, database: scope.database, schema: table.schema ?? scope.schema }));
}
async function enrichSemanticDiagnosticTables(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<{ tables: SqlTableReference[]; missingTables: Set<string> }> {
if (!props.connectionId || props.database == null) return { tables, missingTables: new Set() };
const enriched: SqlTableReference[] = [];
const missingTables = new Set<string>();
for (const table of tables) {
if (isStatementLocalSemanticTable(table) || isSqlVirtualTableReference(table, props.databaseType)) {
enriched.push(table);
continue;
}
if (metadata.usesOracleSessionCompletionColumns(table.schema)) {
enriched.push(table);
continue;
}
try {
const match = await metadata.findExactSemanticDiagnosticTable(table, scope);
if (!match) missingTables.add(tableReferenceKey(table));
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
} catch {
enriched.push(table);
}
}
return { tables: enriched, missingTables };
}
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<Set<string>> {
const missingTables = new Set<string>();
const seen = new Set<string>();
const targets: SqlTableReference[] = [];
for (const table of tables) {
if (isStatementLocalSemanticTable(table) || isSqlVirtualTableReference(table, props.databaseType)) continue;
const tableWithInlineColumns = table as SqlTableReference & {
columns?: string[];
};
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
const cacheKey = metadata.completionCacheKey(table, scope);
if (metadata.cachedColumnsByTable.has(cacheKey)) continue;
const normalizedKey = cacheKey.toLowerCase();
if (seen.has(normalizedKey)) continue;
seen.add(normalizedKey);
targets.push(table);
if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break;
}
await Promise.all(
targets.map(async (table) => {
try {
await metadata.ensureColumnsForTable(table, undefined, scope);
} catch (error) {
if (metadata.isMissingTableMetadataError(error)) {
missingTables.add(tableReferenceKey(table));
}
}
}),
);
return missingTables;
}
function isStatementLocalSemanticTable(table: SqlTableReference): boolean {
const kind = (table as SqlTableReference & { semanticSourceKind?: string }).semanticSourceKind;
return kind === "cte" || kind === "subquery" || kind === "table_function";
}
async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) {
const currentView = view.value;
const runId = ++semanticDiagnosticRunId;
if (!currentView || !props.connectionId || props.database == null) {
setSemanticDiagnostics([]);
return;
}
const sql = currentView.state.doc.toString();
if (!sql.trim()) {
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics") {
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "mongodb") {
// Shell commands have no SQL semantics; surface the parser's own diagnosis instead of waiting for Run.
const cursor = currentView.state.selection.main.head;
setSemanticDiagnostics(buildMongoSyntaxDiagnostics(sql, cursor));
return;
}
if (props.databaseType === "redis") {
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
return;
}
if (shouldSkipSqlSemanticDiagnostics()) {
setSemanticDiagnostics([]);
return;
}
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(1200, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
if (runtime.codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
});
return;
}
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
if (props.databaseType !== "sqlserver") {
runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
}
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
// SQL Server routine batches are excluded from `diagnosticRanges` (see
// `sqlServerRoutineDefinitionRangesForViewport`), so they are recomputed here
// and stay part of the replaced range set below.
const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];
if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
return;
}
const nextDiagnostics: SqlSemanticDiagnostic[] = [];
const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType);
nextDiagnostics.push(
...oracleSyntaxDiagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range));
}),
);
// The analyzer never sees routine batches (the MsSql grammar cannot parse their
// parameter list), so run the token-based routine syntax rules instead of leaving
// a stored procedure without any check at all (dbx#9315).
for (const range of sqlServerRoutineRanges) {
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
}
const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
if (mysqlRoutineAnalysis) {
nextDiagnostics.push(
...mysqlRoutineAnalysis.diagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range));
}),
);
}
for (const range of diagnosticRanges) {
if (mysqlRoutineAnalysis?.routineRanges.some((routineRange) => rangesOverlap(routineRange, range))) continue;
try {
const analysis = await api.analyzeSqlReferences(
range.sql,
sqlReferenceAnalysisDialectFor({
databaseType: props.databaseType,
identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId),
fallbackDialect: props.formatDialect ?? props.dialect ?? "generic",
}),
);
if (runId !== semanticDiagnosticRunId) return;
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(range.sql, semanticCursor, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
})
: null;
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
const scopedAnalysis = {
...semanticAnalysis,
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
};
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope);
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
if (runId !== semanticDiagnosticRunId) return;
const enrichedAnalysis: SqlReferenceAnalysis = {
...scopedAnalysis,
tables,
};
nextDiagnostics.push(
...offsetSqlSemanticDiagnostics(
buildSqlSemanticDiagnostics(enrichedAnalysis, {
tables: metadata.cachedTables,
columnsByTable: metadata.cachedColumnsByTable,
missingTables,
loadedColumnTables: metadata.loadedColumnsByTable,
sql: range.sql,
databaseType: props.databaseType,
}),
range,
sql,
),
);
} catch (error) {
if (runId !== semanticDiagnosticRunId) return;
const diagnostic = buildSqlParserErrorDiagnostic(error, range.sql);
if (diagnostic) nextDiagnostics.push(...offsetSqlSemanticDiagnostics([diagnostic], range, sql));
}
}
if (options.preserveOutsideRanges) {
replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);
} else {
setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics));
}
}
function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRanges?: boolean } = {}) {
if (!runtime.editorIsActive) return;
if (shouldSkipSqlSemanticDiagnostics()) {
clearScheduledSemanticDiagnostics();
setSemanticDiagnostics([]);
return;
}
pendingSemanticDiagnosticPreserveOutsideRanges = !!options.preserveOutsideRanges;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = setTimeout(() => {
const preserveOutsideRanges = pendingSemanticDiagnosticPreserveOutsideRanges;
pendingSemanticDiagnosticPreserveOutsideRanges = false;
semanticDiagnosticTimer = null;
void refreshSemanticDiagnostics({ preserveOutsideRanges });
}, delay);
}
onBeforeUnmount(clearScheduledSemanticDiagnostics);
return {
sqlErrorDecorationRange,
sqlSemanticDecorationRanges,
reconfigureDiagnostics,
setSemanticDiagnostics,
clearScheduledSemanticDiagnostics,
invalidateSemanticDiagnosticsForDocumentChange,
shouldSkipSqlSemanticDiagnostics,
scheduleSemanticDiagnostics,
get diagnostics() {
return semanticDiagnostics;
},
};
}
@@ -0,0 +1,368 @@
import { watch, nextTick } from "vue";
import { Transaction } from "@codemirror/state";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import type { ShallowRef, Ref } from "vue";
import type { QueryEditorProps } from "./queryEditorTypes";
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
interface QueryEditorDocumentStateRuntime {
historyResetComp: import("@codemirror/state").Compartment | null;
codeMirrorHistory: typeof import("@codemirror/commands").history | null;
codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null;
editorIsActive: boolean;
}
interface QueryEditorDocumentStateOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
previewContextSql: Readonly<Ref<string>>;
runtime: QueryEditorDocumentStateRuntime;
emit: {
(event: "selectionStateChange", selection: { anchor: number; head: number }): void;
(event: "viewportChange", viewport: { scrollTop: number; scrollLeft: number }, tabId?: string): void;
(event: "editorStateFlushed"): void;
(event: "editorRevealConsumed"): void;
(event: "previewChangesAvailable", value: boolean): void;
};
scheduleSemanticDiagnostics: (delay?: number, options?: { preserveOutsideRanges?: boolean }) => void;
clearScheduledPreviewContextRefresh: () => void;
syncContextMenuState: (view: EditorViewType) => void;
applyEditorAppearance: () => Promise<void>;
applyEditorShortcutKeymaps: () => void;
applyEditorIndentExtension: () => void;
applyEditorCompletionExtension: () => void;
invalidateSemanticDiagnosticsForDocumentChange: () => void;
currentEditorDocText: (view: EditorViewType) => string;
scheduleDocumentSearchUpdate: () => void;
isEditorComposing: (view: EditorViewType) => boolean;
}
export function useQueryEditorDocumentState(options: QueryEditorDocumentStateOptions) {
const { isEditorComposing } = options;
const {
props,
view,
previewContextSql,
runtime,
emit,
scheduleDocumentSearchUpdate,
scheduleSemanticDiagnostics,
clearScheduledPreviewContextRefresh,
syncContextMenuState,
applyEditorAppearance,
applyEditorShortcutKeymaps,
applyEditorIndentExtension,
applyEditorCompletionExtension,
invalidateSemanticDiagnosticsForDocumentChange,
currentEditorDocText,
} = options;
let viewportOwnerTabId = props.tabId;
const viewportEmitTask = createDeferredEditorTask(() => {
if (latestViewport) emitEditorViewport(latestViewport);
}, 150);
let viewportRestoreFrame: number | null = null;
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let tabSwitchStateCaptured = false;
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
// A single editor instance serves every tab, so the document swap on tab
// switches must not push "previous tab's content → new content" onto a shared
// undo history (one undo in the new tab restored the old tab's text). Each
// tab's editor state — including its undo history — is cached per tabId and
// reinstalled with setState; first-seen tabs get the document swapped in with a
// transaction excluded from history, and the history extension is dropped and
// re-added in two separate transactions (a compartment reconfigure alone keeps
// the old field value) so the previous tab's edits cannot leak in.
const tabStateCache = new Map<string, import("@codemirror/state").EditorState>();
const MAX_CACHED_TAB_STATES = 16;
function swapEditorDocument(doc: string) {
const currentView = view.value;
if (!currentView || !runtime.historyResetComp || !runtime.codeMirrorHistory) return;
if (doc !== currentView.state.doc.toString()) {
currentView.dispatch({
changes: { from: 0, to: currentView.state.doc.length, insert: doc },
annotations: Transaction.addToHistory.of(false),
});
}
currentView.dispatch({ effects: runtime.historyResetComp.reconfigure([]) });
currentView.dispatch({ effects: runtime.historyResetComp.reconfigure(runtime.codeMirrorHistory()) });
scheduleSemanticDiagnostics();
}
function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) {
const currentView = view.value;
if (!currentView) return;
// Flush the outgoing document before props and restored scroll positions
// become the new tab's state. The event carries its original owner. A
// before-tab-switch capture already flushed this editor while it was still
// visible, so avoid reading the reset scroll position during the transition.
if (!tabSwitchStateCaptured) flushEditorViewport();
viewportOwnerTabId = tabId;
latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 };
lastEmittedViewport = undefined;
clearScheduledPreviewContextRefresh();
if (prevTabId !== undefined) {
tabStateCache.set(prevTabId, currentView.state);
if (tabStateCache.size > MAX_CACHED_TAB_STATES) {
const oldest = tabStateCache.keys().next();
if (!oldest.done) tabStateCache.delete(oldest.value);
}
}
const cached = tabId === undefined ? undefined : tabStateCache.get(tabId);
if (!cached) {
swapEditorDocument(doc);
// First activation in this editor instance (or a cache-evicted tab, e.g.
// beyond MAX_CACHED_TAB_STATES): restore the tab's saved cursor and scroll
// position exactly like the cached-state branch, otherwise the swapped-in
// document keeps whatever scroll offset the dispatch left behind (#8374).
// A brand-new tab has no saved state, so reset it instead of falling back
// to the previous tab's latest position (#8378).
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 }, !props.initialViewport);
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
// is not a transaction, so update-listener side effects are re-run manually.
currentView.setState(cached);
// Compartments in the restored state may lag behind settings that changed
// while another tab was active; re-sync them from current values.
void applyEditorAppearance();
applyEditorShortcutKeymaps();
applyEditorIndentExtension();
applyEditorCompletionExtension();
if (doc !== currentView.state.doc.toString()) {
// Content changed while the tab was inactive (external file change, AI
// edit, another split group): apply it as a regular undoable edit.
currentView.dispatch({
changes: { from: 0, to: currentView.state.doc.length, insert: doc },
});
}
scheduleDocumentSearchUpdate();
invalidateSemanticDiagnosticsForDocumentChange();
restoreEditorSelection(undefined, !props.initialViewport);
restoreEditorViewport();
clearScheduledPreviewContextRefresh();
syncContextMenuState(currentView);
emit("previewChangesAvailable", !!previewContextSql.value);
scheduleSemanticDiagnostics();
}
watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) => {
if (!view.value) return;
if (tabId !== prevTabId) {
activateTabDocument(prevTabId, tabId, val);
if (props.autoFocus) restoreEditorFocus();
return;
}
if (val !== currentEditorDocText(view.value)) {
if (isEditorComposing(view.value)) return;
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
scheduleSemanticDiagnostics();
}
});
watch(
() => props.initialViewport,
(viewport, previousViewport) => {
if (!view.value || !viewport || previousViewport) return;
// Saved SQL content can hydrate after the editor has already mounted. In
// that case the initial prop was undefined and the mount-time restore had
// nothing to apply.
latestViewport = { ...viewport };
lastEmittedViewport = { ...viewport };
restoreEditorViewport(viewport);
},
{ deep: true },
);
watch(
() => props.initialSelection,
(selection, previousSelection) => {
if (!view.value || !selection || previousSelection) return;
// Keep the cursor and viewport in sync when a saved SQL tab hydrates after
// the editor component has already been mounted.
restoreEditorSelection(selection, !props.initialViewport);
},
{ deep: true },
);
function captureEditorStateBeforeTabSwitch(event: Event) {
const fromTabId = (event as CustomEvent<{ fromTabId?: string }>).detail?.fromTabId;
if (!view.value || !fromTabId || fromTabId !== props.tabId) return;
// Capture while the outgoing editor is still visible. Once KeepAlive starts
// deactivating the surface, WebKit can report a reset scrollTop of zero.
flushEditorViewport();
flushEditorSelection();
emit("editorStateFlushed");
tabSwitchStateCaptured = true;
}
function readEditorViewport(currentView: EditorViewType) {
return {
scrollTop: Math.max(0, currentView.scrollDOM.scrollTop),
scrollLeft: Math.max(0, currentView.scrollDOM.scrollLeft),
};
}
function sameEditorViewport(a: { scrollTop: number; scrollLeft: number } | undefined, b: { scrollTop: number; scrollLeft: number }) {
return a?.scrollTop === b.scrollTop && a.scrollLeft === b.scrollLeft;
}
function normalizedEditorSelection(selection: { anchor: number; head: number } | undefined, docLength: number) {
if (!selection) return undefined;
return {
anchor: Math.min(Math.max(0, selection.anchor), docLength),
head: Math.min(Math.max(0, selection.head), docLength),
};
}
function readEditorSelection(currentView: EditorViewType) {
const selection = currentView.state.selection.main;
return {
anchor: selection.anchor,
head: selection.head,
};
}
function emitEditorSelection(selection: { anchor: number; head: number }) {
emit("selectionStateChange", selection);
}
function flushEditorSelection() {
if (view.value) latestSelection = readEditorSelection(view.value);
if (latestSelection) emitEditorSelection(latestSelection);
}
function restoreEditorSelection(selection = props.initialSelection ?? latestSelection, scrollIntoView = false) {
const normalizedSelection = normalizedEditorSelection(selection, props.modelValue.length);
if (!view.value || !normalizedSelection) return;
view.value.dispatch({ selection: normalizedSelection, scrollIntoView });
}
/** Move cursor/scroll to a 1-based line/column, e.g. from a global content-search match. */
function revealEditorSelection(line: number, column?: number) {
const currentView = view.value;
const EditorSelection = runtime.codeMirrorEditorSelection;
if (!currentView || !EditorSelection) return;
const doc = currentView.state.doc;
const targetLine = Math.max(1, Math.min(line ?? 1, doc.lines));
const lineOffset = doc.line(targetLine).from;
const charColumn = Math.max(1, column ?? 1);
const offset = Math.min(lineOffset + charColumn - 1, doc.length);
currentView.dispatch({ selection: EditorSelection.cursor(offset), scrollIntoView: true });
focusEditorView(currentView);
}
/** Consume a pending reveal request (fires once per new id / on mount). */
function performEditorReveal() {
const request = props.revealRequest;
if (!request || !view.value) return;
revealEditorSelection(request.line, request.column);
emit("editorRevealConsumed");
}
function restoreEditorFocus() {
const focusEditorAcrossFrames = () => {
focusEditorView(view.value);
};
focusEditorAcrossFrames();
nextTick(() => {
focusEditorAcrossFrames();
requestAnimationFrame(focusEditorAcrossFrames);
});
}
function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) {
if (sameEditorViewport(lastEmittedViewport, viewport)) return;
lastEmittedViewport = { ...viewport };
emit("viewportChange", viewport, viewportOwnerTabId);
}
function scheduleEditorViewportEmit() {
if (!view.value || !runtime.editorIsActive) return;
latestViewport = readEditorViewport(view.value);
scheduleSemanticDiagnostics(700, { preserveOutsideRanges: true });
viewportEmitTask.schedule();
}
function flushEditorViewport() {
if (view.value) latestViewport = readEditorViewport(view.value);
viewportEmitTask.flush();
if (latestViewport) emitEditorViewport(latestViewport);
}
function restoreEditorViewport(viewport = props.initialViewport ?? latestViewport) {
if (!view.value || !viewport) return;
const restoreScroll = () => {
if (!view.value) return;
view.value.scrollDOM.scrollTo({
top: viewport.scrollTop,
left: viewport.scrollLeft,
});
view.value.scrollDOM.scrollTop = viewport.scrollTop;
view.value.scrollDOM.scrollLeft = viewport.scrollLeft;
};
if (viewportRestoreFrame !== null) cancelAnimationFrame(viewportRestoreFrame);
restoreScroll();
nextTick(() => {
restoreScroll();
let attempts = 0;
const restoreNextFrame = () => {
restoreScroll();
attempts += 1;
if (attempts >= 32) {
viewportRestoreFrame = null;
return;
}
viewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
};
viewportRestoreFrame = requestAnimationFrame(restoreNextFrame);
});
}
function recordSelection(currentView: EditorViewType) {
latestSelection = readEditorSelection(currentView);
if (runtime.editorIsActive) emitEditorSelection(latestSelection);
}
function flushBeforeDeactivation() {
const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;
tabSwitchStateCaptured = false;
// A KeepAlive-evicted editor can be unmounted after it was already
// deactivated. Its DOM scroll position has been reset by then, so flushing
// that inactive view would overwrite the saved viewport with zero.
if (runtime.editorIsActive && !stateWasCapturedBeforeTabSwitch) {
flushEditorViewport();
flushEditorSelection();
emit("editorStateFlushed");
}
}
function attach() {
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
}
function dispose() {
viewportEmitTask.cancel();
if (viewportRestoreFrame !== null) {
cancelAnimationFrame(viewportRestoreFrame);
viewportRestoreFrame = null;
}
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
}
return { normalizedEditorSelection, restoreEditorSelection, performEditorReveal, restoreEditorFocus, scheduleEditorViewportEmit, restoreEditorViewport, recordSelection, flushBeforeDeactivation, attach, dispose };
}
@@ -0,0 +1,184 @@
import { ref, type ShallowRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useToast } from "@/composables/useToast";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { createQueryEditorExecutionViewportOwnership } from "@/lib/editor/queryEditorExecutionViewport";
import { executionCandidateForMode, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges";
import type { QueryEditorProps } from "./queryEditorTypes";
interface RequestExecuteOptions {
ignoreSelection?: boolean;
bypassPicker?: boolean;
openInNewResultTab?: boolean;
}
interface QueryEditorExecutionOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
editorRef: Ref<HTMLDivElement | undefined>;
settingsStore: ReturnType<typeof useSettingsStore>;
executionViewportOwnership: ReturnType<typeof createQueryEditorExecutionViewportOwnership>;
sqlStatementParameterOptions: () => Parameters<typeof buildExecutionCandidates>[3];
setPreviewRange: (range: { from: number; to: number } | null) => void;
emitExecutionRequest: (source: SqlExecutionOverride, openInNewResultTab?: boolean) => void;
onExecute: (source: SqlExecutionOverride) => void;
}
export function useQueryEditorExecution(options: QueryEditorExecutionOptions) {
const { props, view, editorRef, settingsStore, executionViewportOwnership, sqlStatementParameterOptions, setPreviewRange, emitExecutionRequest, onExecute } = options;
const { t } = useI18n();
const { toast } = useToast();
// Execution target picker state
const pickerVisible = ref(false);
const pickerCandidates = ref<SqlExecutionCandidate[]>([]);
const pickerActiveIndex = ref(0);
const pickerAnchor = ref<{ left: number; top: number }>();
/**
* Captures a manual selection before a toolbar click can cause a platform
* focus transition. The snapshot is immutable, so downstream execution keeps
* the exact SQL and source offsets that were visible when the button was
* pressed.
*/
function captureExecutionSnapshot(): SqlExecutionSnapshot | undefined {
const currentView = view.value;
if (!currentView || currentView.state.selection.main.empty) return undefined;
return sqlExecutionSnapshotFromView(currentView);
}
function requestExecute(options: RequestExecuteOptions = {}) {
executionViewportOwnership.cancelPendingRequest();
const currentView = view.value;
if (!currentView) return false;
currentView.focus();
return requestExecuteFromView(currentView, currentView.state.selection.main.head, options);
}
function requestExecuteInNewResultTab() {
return requestExecute({ bypassPicker: true, openInNewResultTab: true });
}
function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, options: RequestExecuteOptions = {}) {
const selection = currentView.state.selection.main;
if (!options.ignoreSelection && !selection.empty) {
// Has manual selection → execute directly, skip picker.
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
if (!supportsExecutionTargetPicker(props.databaseType)) {
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
// No selection → resolve the execution target, optionally via the picker.
const doc = currentView.state.doc.toString();
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
const executeMode = settingsStore.editorSettings.executeMode;
if (candidates.length === 0) {
if (executeMode === "current") toast(t("editor.noExecutableStatementAtCursor"), 3000);
return true;
}
const candidate = executionCandidateForMode(candidates, executeMode, {
executeAllOnBlankLine: settingsStore.editorSettings.executeAllOnBlankLine,
});
if (!candidate) {
toast(t("editor.noExecutableStatementAtCursor"), 3000);
return true;
}
// The execution shortcut keeps executing the configured target (cursor/all) directly:
// it stays keyboard-driven and never pops the picker, which is reserved for click entry points.
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab);
return true;
}
closePicker();
pickerCandidates.value = candidates;
pickerActiveIndex.value = 0;
pickerAnchor.value = executionPickerAnchor(currentView, cursorPos, candidates.length);
pickerVisible.value = true;
setPreviewRange({ from: candidates[0].from, to: candidates[0].to });
return true;
}
function executionPickerAnchor(currentView: EditorViewType, cursorPos: number, candidateCount: number): { left: number; top: number } | undefined {
const cursorRect = currentView.coordsAtPos(cursorPos);
const rootRect = editorRef.value?.getBoundingClientRect();
if (!cursorRect || !rootRect) return undefined;
const verticalGap = 8;
const pickerHeight = 40 + Math.max(1, candidateCount) * 36;
const verticalMargin = 12;
const left = rootRect.width / 2;
const cursorBottom = cursorRect.bottom - rootRect.top;
const maxTop = Math.max(verticalMargin, rootRect.height - pickerHeight - verticalMargin);
const top = Math.min(cursorBottom + verticalGap, maxTop);
return { left, top };
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
if (candidate) {
setPreviewRange({ from: candidate.from, to: candidate.to });
}
}
function onPickerConfirm(candidate: SqlExecutionCandidate) {
const currentView = view.value;
closePicker();
onExecute(currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql);
}
function closePicker() {
pickerVisible.value = false;
pickerAnchor.value = undefined;
setPreviewRange(null);
// Restore focus to the CodeMirror editor.
view.value?.focus();
}
function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecutionSnapshot {
const selection = currentView.state.selection.main;
return {
fullSql: currentView.state.doc.toString(),
selectedSql: currentView.state.sliceDoc(selection.from, selection.to),
cursorPos: selection.head,
selectionFrom: selection.from,
selectionTo: selection.to,
};
}
function sqlExecutionSnapshotForRange(currentView: EditorViewType, range: Pick<SqlExecutionCandidate, "sql" | "from" | "to">): SqlExecutionSnapshot {
return {
fullSql: currentView.state.doc.toString(),
selectedSql: range.sql,
cursorPos: currentView.state.selection.main.head,
selectionFrom: range.from,
selectionTo: range.to,
};
}
return {
pickerVisible,
pickerCandidates,
pickerActiveIndex,
pickerAnchor,
captureExecutionSnapshot,
requestExecute,
requestExecuteInNewResultTab,
requestExecuteFromView,
executionPickerAnchor,
onPickerActiveIndexChange,
onPickerConfirm,
closePicker,
sqlExecutionSnapshotFromView,
sqlExecutionSnapshotForRange,
};
}
@@ -0,0 +1,320 @@
import type { EditorView as EditorViewType } from "@codemirror/view";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { findCteColumnResolution, resolveCteColumnOrigins, type CteColumnOrigin } from "@/lib/sql/semantic/cteNavigation";
import type { SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types";
import { resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
import { extractQualifiedIdentifierAt, isSqlKeyword, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationSourceKind } from "@/lib/sql/sqlNavigation";
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
import { matchHoverTableCandidates, resolveHoverTableLookupTarget } from "@/lib/editor/hoverTableLookup";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import * as api from "@/lib/backend/api";
import type { SqlCompletionColumn } from "@/lib/sql/sqlCompletion";
import type { ColumnInfo, IndexInfo } from "@/types/database";
import type { Ref } from "vue";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { useConnectionStore } from "@/stores/connectionStore";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { useQueryEditorCompletionMetadata } from "./useQueryEditorCompletionMetadata";
import type { createQueryEditorHoverContent } from "./queryEditorHoverContent";
interface QueryEditorHoverOptions {
props: Readonly<QueryEditorProps>;
contextMenuOpen: Readonly<Ref<boolean>>;
settingsStore: ReturnType<typeof useSettingsStore>;
connectionStore: ReturnType<typeof useConnectionStore>;
metadata: ReturnType<typeof useQueryEditorCompletionMetadata>;
createHoverDom: ReturnType<typeof createQueryEditorHoverContent>["createHoverDom"];
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
}
export function useQueryEditorHover(options: QueryEditorHoverOptions) {
const { props, contextMenuOpen, settingsStore, connectionStore, metadata: completionMetadata, createHoverDom } = options;
const { ensureColumnsForTable, cachedColumnsByTable, completionCacheKey, getEditorSemanticModel, completionMetadataTarget, mergeCompletionTables, usesLocalOnlyCompletionMetadata, getEditorSqlCompletionContext } = completionMetadata;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null {
const located = extractQualifiedIdentifierAt(sql, pos);
if (!located) return null;
if (located.parts.length === 1 && !located.parts[0].quoted && isSqlKeyword(located.parts[0].value)) return null;
const text = located.parts.map((part) => quoteIdentifier(part.value)).join(".");
if (!text) return null;
return { from: located.start, to: located.end, text };
}
function referencedTableLikeFromSemanticSource(source: SqlSemanticRowSource) {
const identifierParts = source.qualifiedName?.parts ?? [];
return {
name: source.name,
nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,
database: source.metadataTarget?.database,
schema: source.qualifierParts[source.qualifierParts.length - 1],
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
};
}
async function resolveCteColumnHoverColumn(semanticModel: SqlSemanticModel, columnName: string, qualifier?: string): Promise<SqlCompletionColumn | null> {
try {
const hit = findCteColumnResolution(semanticModel, columnName, qualifier);
if (!hit) return null;
const origins: CteColumnOrigin[] = resolveCteColumnOrigins(semanticModel, hit, columnName);
if (origins.length === 0) return null;
// Fetch all candidate origins concurrently (same pattern as expandSelectStar);
// origin order still decides which match wins.
const matched = await Promise.all(
origins.map(async (origin) => {
const reference = referencedTableLikeFromSemanticSource(origin.source);
await ensureColumnsForTable(reference, reference);
const columns = cachedColumnsByTable.get(completionCacheKey(reference));
return columns?.find((candidate) => candidate.name.toLowerCase() === origin.column.toLowerCase()) ?? null;
}),
);
return matched.find((column) => column != null) ?? null;
} catch {
return null;
}
}
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
if (!props.connectionId || props.database == null || contextMenuOpen.value) return null;
const sql = currentView.state.doc.toString();
const range = identifierRangeAt(sql, pos);
if (!range) return null;
const identifier = range.text;
const parts = splitQualifiedIdentifier(identifier);
const name = parts[parts.length - 1] ?? identifier;
const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined;
let semanticModel: ReturnType<typeof buildSqlSemanticModel> | null = null;
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
try {
semanticModel = getEditorSemanticModel(sql, pos, currentView.state);
} catch (error) {
semanticModel = null;
console.warn(`[DBX] Failed to build semantic model for hover tooltip:`, error);
}
}
const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null;
const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase());
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
// CTE-derived columns: trace to the physical column and show its type/source/comment before
// the generic table/column fallback (which cannot see inside a CTE body).
if (semanticModel) {
const cteColumn = await resolveCteColumnHoverColumn(semanticModel, name, qualifier);
if (cteColumn) {
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(cteColumn.name, cteColumn.dataType || "column", undefined, [cteColumn.schema ? `${cteColumn.schema}.${cteColumn.table}` : cteColumn.table, ...(cteColumn.comment?.trim() ? [cteColumn.comment.trim()] : [])]),
};
}
}
const lookup = resolveHoverTableLookupTarget({
database: props.database,
schema: props.schema,
catalog: props.catalog,
databaseType: props.databaseType,
tableName: tableLookupName,
// Alias.column must not be treated as schema.table; keep the bare column/table token.
identifierParts: semanticQualifierIsRowSource ? [tableLookupName] : parts,
semanticDatabase: semanticQualifierIsRowSource ? undefined : semanticTarget?.database,
semanticSchema: semanticQualifierIsRowSource ? undefined : semanticTarget?.schema,
mode: settingsStore.editorSettings.tableHoverLookupMode,
});
const hoverTarget = completionMetadataTarget({
name: lookup.tableName,
catalog: props.catalog,
database: lookup.database !== props.database ? lookup.database : undefined,
schema: lookup.schema,
});
if (!hoverTarget) return null;
const hoverScope: HoverTableScope = {
catalog: hoverTarget.catalog,
database: hoverTarget.database,
schema: hoverTarget.schema,
};
const preferredSchema = props.schema;
const matchHoverTable = (tables: typeof completionMetadata.cachedTables) =>
matchHoverTableCandidates(tables, {
lookups: [qualifiedTableLookup, identifier],
tableName: tableLookupName,
preferredSchema,
});
try {
const searchLocal = (schema?: string) => {
const scopeForFilter: HoverTableScope = { ...hoverScope, schema };
const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, hoverScope.catalog);
const localHoverTables = schema ? scopeHoverTables(localTables, scopeForFilter) : scopeHoverTables(localTables, { ...hoverScope, schema: undefined });
return localHoverTables;
};
const searchRemote = async (schema: string | undefined, globalSearch: boolean) => {
const loadedTables = await connectionStore.listCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, globalSearch, preferredSchema, hoverScope.catalog);
return schema ? scopeHoverTables(loadedTables, { ...hoverScope, schema }) : loadedTables;
};
let hoverTables = completionMetadata.cachedTables.filter((table) => (lookup.preferGlobalFirst || !hoverScope.schema ? hoverTableMatchesScope(table, { ...hoverScope, schema: undefined }) : hoverTableMatchesScope(table, hoverScope)));
let table = matchHoverTable(hoverTables);
if (!table && !lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(hoverScope.schema);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
completionMetadata.cachedTables = mergeCompletionTables(localHoverTables, completionMetadata.cachedTables);
table = matchHoverTable(hoverTables);
}
if (!table && lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(undefined);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
completionMetadata.cachedTables = mergeCompletionTables(localHoverTables, completionMetadata.cachedTables);
table = matchHoverTable(hoverTables);
}
if (!table && !usesLocalOnlyCompletionMetadata() && !lookup.preferGlobalFirst) {
const remoteHoverTables = await searchRemote(hoverScope.schema, false);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
if (!table && !usesLocalOnlyCompletionMetadata() && lookup.preferGlobalFirst) {
const remoteHoverTables = await searchRemote(undefined, true);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
if (!table && lookup.allowGlobalFallback && !lookup.preferGlobalFirst) {
const localHoverTables = searchLocal(undefined);
hoverTables = mergeCompletionTables(localHoverTables, hoverTables);
completionMetadata.cachedTables = mergeCompletionTables(localHoverTables, completionMetadata.cachedTables);
table = matchHoverTable(hoverTables);
if (!table && !usesLocalOnlyCompletionMetadata()) {
const remoteHoverTables = await searchRemote(undefined, true);
hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, remoteHoverTables);
table = matchHoverTable(hoverTables);
}
}
if (table && settingsStore.editorSettings.showTableDdlHoverPreview && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
const hoverDatabase = table.database ?? hoverScope.database;
const hoverSchema = table.schema ?? hoverScope.schema ?? "";
const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join(".");
const objectMetadataRequest = {
connectionId: props.connectionId,
database: hoverDatabase,
schema: hoverSchema,
tableName: table.name,
catalog: hoverScope.catalog,
objectType: sqlObjectNavigationSourceKind(table),
};
let sqlContent: string | undefined;
const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType);
let metadataLoadFailed = false;
// The persisted display DDL is canonical across the full-page and hover
// views. Hover only removes PostgreSQL's appended access-control tail.
try {
const { ddl } = await loadObjectDdl(objectMetadataRequest);
const rawDdl = ddlForHoverPreview(applyDdlStoragePreference(ddl, props.databaseType, settingsStore.editorSettings.excludeDdlStorage));
if (rawDdl && rawDdl.trim()) {
// A view's display DDL wraps the raw (often single-line) view source
// in `CREATE ... VIEW ... AS`; the table-oriented reformatter cannot
// lay out a SELECT body, so views reuse the shared display formatter
// (the same one the sidebar/object-source viewers use). Tables keep
// the aligned column layout from reformatHoverDdl.
const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW";
const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
const unqualified = applyDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, hoverDatabase, props.catalog);
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
}
} catch (error) {
console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
// Fallback path: rebuild the DDL from cached table metadata when the
// backend DDL is unavailable (empty result or request failure).
if (!sqlContent) {
let fullColumns: ColumnInfo[] = [];
let fullIndexes: IndexInfo[] = [];
let tableComment: string | undefined;
try {
const [columnsResult, indexesResult] = await Promise.all([
loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)),
loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)).catch(() => ({ value: [] as IndexInfo[], cacheStatus: "remote" as const })),
]);
fullColumns = columnsResult.value;
fullIndexes = indexesResult.value;
} catch (error) {
metadataLoadFailed = true;
console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
if (!metadataLoadFailed) {
try {
const commentResult = await loadObjectMetadataFacet(objectMetadataRequest, "comment", () => api.getTableComment(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog));
if (commentResult.value) tableComment = commentResult.value;
} catch (error) {
console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
}
}
if (fullColumns.length > 0) {
sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment);
sqlContent = applyDdlDatabaseQualifier(sqlContent, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, hoverDatabase, props.catalog);
if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect);
metadataLoadFailed = false;
}
}
// Re-check after async metadata load — the context menu may have opened
// while the DDL request was in flight, and we must not display a hover
// tooltip on top of an open context menu.
if (contextMenuOpen.value) return null;
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined),
};
}
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;
for (const refTable of candidates) {
const columns: SqlCompletionColumn[] =
refTable.columns?.map((columnName) => ({
name: columnName,
table: refTable.name,
...(refTable.schema ? { schema: refTable.schema } : {}),
})) ?? [];
if (columns.length === 0) {
await ensureColumnsForTable(refTable);
columns.push(...(cachedColumnsByTable.get(completionCacheKey(refTable)) ?? []));
}
const column = columns.find((col) => col.name.toLowerCase() === name.toLowerCase());
if (!column) continue;
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]),
};
}
} catch {
return null;
}
return null;
}
return { resolveSqlHoverTooltip };
}
@@ -0,0 +1,154 @@
import { ref, onBeforeUnmount, type ShallowRef } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { analyzeIntentionActions, prepareExpandWildcardContext, buildExpandWildcardReplacement, type IntentionAction } from "@/lib/editor/sqlIntentionActions";
import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
import type { QueryEditorProps } from "./queryEditorTypes";
export // ==================== Intention Popup ====================
interface IntentionPopupState {
visible: boolean;
// 直接复用 IntentionAction 类型,避免手动重声明导致 replacements 等字段丢失(TS2551)
actions: IntentionAction[];
position: { x: number; y: number };
selectedIndex: number;
}
interface QueryEditorIntentionsOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
focusEditor: () => void;
}
export function useQueryEditorIntentions(options: QueryEditorIntentionsOptions) {
const { props, view, sqlBehaviorDialect, focusEditor } = options;
const intentionPopup = ref<IntentionPopupState | null>(null);
function closeIntentionPopup() {
document.removeEventListener("keydown", onIntentionPopupKey);
intentionPopup.value = null;
focusEditor();
}
function onIntentionPopupKey(e: KeyboardEvent) {
if (!intentionPopup.value?.visible) return;
switch (e.key) {
case "Escape":
e.preventDefault();
closeIntentionPopup();
break;
case "ArrowDown":
e.preventDefault();
intentionPopup.value.selectedIndex = Math.min(intentionPopup.value.selectedIndex + 1, intentionPopup.value.actions.length - 1);
break;
case "ArrowUp":
e.preventDefault();
intentionPopup.value.selectedIndex = Math.max(intentionPopup.value.selectedIndex - 1, 0);
break;
case "Enter":
e.preventDefault();
executeIntentionAction(intentionPopup.value.actions[intentionPopup.value.selectedIndex]);
break;
}
}
function executeIntentionAction(action: IntentionPopupState["actions"][number]) {
if (!intentionPopup.value) return;
closeIntentionPopup();
const currentView = view.value;
if (!currentView) return;
switch (action.kind) {
case "expand_wildcard": {
const sql = currentView.state.doc.toString();
const cursor = currentView.state.selection.main.head;
void (async () => {
try {
const ctx = prepareExpandWildcardContext(sql, cursor, props.databaseType, sqlBehaviorDialect());
if (!ctx) return;
const replacement = await buildExpandWildcardReplacement(props.databaseType, ctx.rowSources, async (source) => {
const schema = source.metadataTarget?.schema;
const tableName = source.metadataTarget?.table ?? source.name;
if (!tableName) return [];
const result = await loadTableMetadata({
connectionId: props.connectionId ?? "",
database: props.database ?? "",
schema,
tableName,
databaseType: props.databaseType ?? "mysql",
force: false,
});
return result.metadata.columns.map((c) => c.name);
});
const v = view.value;
if (!v || v.state.doc.toString() !== sql) return;
v.dispatch({ changes: { from: ctx.starSpan.start, to: ctx.starSpan.end, insert: replacement } });
} catch {
// metadata load failed
}
})();
break;
}
case "qualify_identifier":
case "unqualify_identifier":
currentView.dispatch({
changes: { from: action.span.start, to: action.span.end, insert: action.replacement },
});
break;
case "batch_qualify_identifiers": {
// 按从后往前的顺序逐一替换,避免 offset 漂移
const reps = action.replacements ?? [];
for (let i = reps.length - 1; i >= 0; i--) {
const r = reps[i];
currentView.dispatch({
changes: { from: r.span.start, to: r.span.end, insert: r.replacement },
});
}
break;
}
}
}
function handleSqlIntentionActions(currentView: EditorViewType): boolean {
if (props.readOnly) return false;
try {
const sql = currentView.state.doc.toString();
const sel = currentView.state.selection.main;
const actions = analyzeIntentionActions({
sql,
cursor: sel.head,
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
selection: sel.from !== sel.to ? { from: sel.from, to: sel.to } : undefined,
});
if (actions.length === 0) return false;
// 计算光标视口坐标,用于定位弹出菜单
const coords = currentView.coordsAtPos(sel.head);
if (!coords) return false;
// 显示弹出菜单(参考 DataGrip Alt+Enter 意图操作弹出菜单)
intentionPopup.value = {
visible: true,
actions,
position: { x: coords.right + 8, y: coords.bottom + 4 },
selectedIndex: 0,
};
document.addEventListener("keydown", onIntentionPopupKey);
return true;
} catch (err) {
console.error("[SQL Intention] error:", err);
return false;
}
}
onBeforeUnmount(() => document.removeEventListener("keydown", onIntentionPopupKey));
return { intentionPopup, closeIntentionPopup, executeIntentionAction, handleSqlIntentionActions };
}
@@ -0,0 +1,89 @@
import { watch, onBeforeUnmount, type Ref } from "vue";
import { isPanelResizing } from "@/lib/app/panelResizeState";
import { uiTuning } from "@/lib/app/uiTuning";
export function useQueryEditorLayout(editorRef: Ref<HTMLDivElement | undefined>) {
// While any panel divider is dragged, CodeMirror re-wraps and re-measures
// every visible line on every frame the editor box changes — with a 100+ line
// script that is the dominant drag cost (profiler: 2k+ measure() calls with
// getBoundingClientRect in a 3s drag). We pin the editor root to an explicit
// pixel size and then step-update that pin at most every TRACK_EVERY_FRAMES
// animation frames (~30fps), which keeps the editor visually following the
// divider while cutting the relayout frequency (and the layout thrash from
// interleaved reads/writes) by half or more. The step interval is tunable via
// ~/.dbx/ui-tuning.json (panelResizeTrackEveryFrames). The pin is released
// once on pointer-up for one final exact layout.
let frozenEditorBox: { width: number; height: number } | null = null;
let editorTrackFrameId = 0;
let editorTrackFrameCount = 0;
function editorAvailableSize(): { width: number; height: number } | null {
const host = editorRef.value?.parentElement;
if (!host) return null;
const width = host.clientWidth;
const height = host.clientHeight;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function pinEditorBox(size: { width: number; height: number }) {
const root = editorRef.value;
if (!root) return;
frozenEditorBox = size;
root.style.width = `${Math.round(size.width)}px`;
root.style.height = `${Math.round(size.height)}px`;
root.style.maxWidth = "none";
root.style.minHeight = "0";
}
function releaseEditorBox() {
const root = editorRef.value;
frozenEditorBox = null;
if (!root) return;
root.style.width = "";
root.style.height = "";
root.style.maxWidth = "";
root.style.minHeight = "";
}
function stopEditorBoxTracking() {
if (editorTrackFrameId) {
cancelAnimationFrame(editorTrackFrameId);
editorTrackFrameId = 0;
}
editorTrackFrameCount = 0;
}
function editorBoxTrackStep() {
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
if (++editorTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return;
editorTrackFrameCount = 0;
const next = editorAvailableSize();
if (!next || !frozenEditorBox) return;
if (Math.round(next.width) === Math.round(frozenEditorBox.width) && Math.round(next.height) === Math.round(frozenEditorBox.height)) return;
// Writing the pin keeps the editor a fixed-size box: the parent's layout is
// reflowed (cheap, the editor subtree is `contain`ed and skipped) but
// CodeMirror only observes one discrete size change per step.
pinEditorBox(next);
}
watch(
isPanelResizing,
(resizing) => {
const root = editorRef.value;
if (!root) return;
if (resizing) {
if (frozenEditorBox) return;
const size = editorAvailableSize();
if (!size) return;
pinEditorBox(size);
editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep);
} else {
stopEditorBoxTracking();
if (frozenEditorBox) releaseEditorBox();
}
},
{ flush: "sync" },
);
onBeforeUnmount(stopEditorBoxTracking);
}
@@ -0,0 +1,412 @@
import type { EditorView as EditorViewType } from "@codemirror/view";
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
import { findCteColumnResolution, findCteReferenceAt } from "@/lib/sql/semantic/cteNavigation";
import { extractIdentifierDetailsAt, isSqlKeyword, matchSqlObject, matchTable, resolveSqlObjectNavigationIdentity, sqlObjectNavigationTarget, sqlObjectNavigationTargetFromIdentity, sqlObjectNavigationTypeFromCompletionObjectType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { matchHoverTableCandidates, resolveHoverTableLookupTarget } from "@/lib/editor/hoverTableLookup";
import { stabilizeUnfocusedQueryEditorPointerDown } from "@/lib/editor/queryEditorUnfocusedPointer";
import { usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection";
import { queryTableNavigationTargetAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
import type { SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { Ref, ShallowRef } from "vue";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { useConnectionStore } from "@/stores/connectionStore";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { useQueryEditorCompletionMetadata } from "./useQueryEditorCompletionMetadata";
import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
interface QueryEditorObjectNavigationOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
editorRef: Readonly<Ref<HTMLElement | null | undefined>>;
settingsStore: ReturnType<typeof useSettingsStore>;
connectionStore: ReturnType<typeof useConnectionStore>;
metadata: ReturnType<typeof useQueryEditorCompletionMetadata>;
runtime: QueryEditorCodeMirrorRuntime;
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
dismissHoverTooltip: () => void;
startEditorSelectionDrag: (view: EditorViewType, event: MouseEvent) => boolean;
emit: {
(event: "closeColumnPanel"): void;
(event: "clickTable", target: SqlObjectNavigationTarget): void;
(event: "openObjectSource", target: SqlObjectNavigationTarget, initialEditing: boolean): void;
(event: "clickColumn", columns: Array<{ name: string; table: string; schema?: string }>, error?: string): void;
};
}
export function useQueryEditorObjectNavigation(options: QueryEditorObjectNavigationOptions) {
const { props, view, editorRef, settingsStore, connectionStore, metadata: completionMetadata, runtime: codeMirrorRuntime, dismissHoverTooltip, startEditorSelectionDrag, emit } = options;
const { getEditorSemanticModel, mergeCompletionTables, usesLocalOnlyCompletionMetadata, usesOracleSessionCompletionColumns, completionCacheKey, cachedColumnsByTable, completionMetadataTarget, listCompletionColumnsForEditor } = completionMetadata;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
const tableNavigationHoverClass = "query-editor--table-navigation-hover";
function jumpToCteRange(target: { from: number; to: number }, highlight: { from: number; to: number }) {
const currentView = view.value;
if (!currentView || !codeMirrorRuntime.editorViewModule || !codeMirrorRuntime.setResultSourceRangeEffect) return;
const docLength = currentView.state.doc.length;
const targetFrom = Math.max(0, Math.min(target.from, docLength));
const targetTo = Math.max(targetFrom, Math.min(target.to, docLength));
const highlightFrom = Math.max(0, Math.min(highlight.from, docLength));
const highlightTo = Math.max(highlightFrom, Math.min(highlight.to, docLength));
if (targetFrom >= targetTo) return;
currentView.dispatch({
selection: { anchor: targetFrom, head: targetTo },
effects: [codeMirrorRuntime.setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo }), codeMirrorRuntime.editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })],
});
currentView.focus();
}
function clearTableNavigationHover() {
editorRef.value?.classList.remove(tableNavigationHoverClass);
}
function tableNavigationIdentifierAt(currentView: EditorViewType, event: MouseEvent): string | null {
if (!props.connectionId || props.database == null) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return null;
// Identifier extraction only looks around `pos`; slice a small window so
// modifier-held mouse moves never materialize the whole document string.
const windowFrom = Math.max(0, pos - 1024);
const doc = currentView.state.doc;
const extracted = extractIdentifierDetailsAt(doc.sliceString(windowFrom, Math.min(doc.length, pos + 1024)), pos - windowFrom);
if (!extracted || (!extracted.quoted && isSqlKeyword(extracted.identifier))) return null;
return extracted.identifier;
}
function updateTableNavigationHover(currentView: EditorViewType, event: MouseEvent) {
if (!usesQueryEditorObjectNavigationModifier(event)) {
clearTableNavigationHover();
return false;
}
const identifier = tableNavigationIdentifierAt(currentView, event);
editorRef.value?.classList.toggle(tableNavigationHoverClass, !!identifier);
return !!identifier;
}
function clearTableNavigationHoverOnModifierRelease(event: KeyboardEvent) {
if (!usesQueryEditorObjectNavigationModifier(event)) clearTableNavigationHover();
}
function onEditorMouseDown(event: MouseEvent) {
clearTableNavigationHover();
dismissHoverTooltip();
const currentView = view.value;
if (currentView && startEditorSelectionDrag(currentView, event)) {
return true;
}
if (currentView) stabilizeUnfocusedQueryEditorPointerDown(currentView, event);
// Alt belongs to CodeMirror's rectangular and multi-cursor gestures,
// even when Cmd/Ctrl is held at the same time.
if (!usesQueryEditorObjectNavigationModifier(event)) {
// Click without modifier -> close column panel
if (!event.metaKey && !event.ctrlKey && event.button === 0) {
emit("closeColumnPanel");
}
return false;
}
if (event.button !== 0) return false;
if (!currentView || !props.connectionId || props.database == null) {
return false;
}
// Use posAtCoords for accurate click position
const coords = { x: event.clientX, y: event.clientY };
const pos = currentView.posAtCoords(coords);
if (pos == null) {
return false;
}
const doc = currentView.state.doc.toString();
const extracted = extractIdentifierDetailsAt(doc, pos);
if (!extracted) {
return false;
}
if (!extracted.quoted && isSqlKeyword(extracted.identifier)) {
return false;
}
const identifier = extracted.identifier;
// Prevent default, resolve async
event.preventDefault();
setTimeout(async () => {
try {
// Single identity model: quote flags + role (relation column list vs routine call vs unknown).
const identity = resolveSqlObjectNavigationIdentity(doc, pos);
if (!identity) return;
const identifierParts = identity.parts.map((part) => part.value);
const tableLookupFilter = identity.name;
const objectNameFilter = identity.name;
// 3-part schema.package.member; 2-part stays ambiguous until metadata resolves it.
const objectParentHint = identity.parts.length >= 3 ? identity.qualifier : undefined;
const objectSchemaHint = identity.parts.length >= 3 ? identity.schema : identity.parts.length === 1 ? props.schema : undefined;
const isRoutineCall = identity.role === "routine_call";
const isRelationColumnList = identity.role === "relation_column_list";
const tableLookup = resolveHoverTableLookupTarget({
database: props.database!,
schema: props.schema,
catalog: props.catalog,
databaseType: props.databaseType,
tableName: tableLookupFilter,
identifierParts,
mode: settingsStore.editorSettings.tableHoverLookupMode,
});
const tableLookupSchema = tableLookup.preferGlobalFirst ? undefined : (tableLookup.schema ?? props.schema);
const matchNavigationTable = (tables: typeof completionMetadata.cachedTables) =>
matchHoverTableCandidates(tables, {
lookups: [identifier],
tableName: tableLookupFilter,
preferredSchema: props.schema,
});
const relationNavigationTarget = (target: SqlObjectNavigationTarget) =>
queryTableNavigationTargetAtSqlPosition(
{
connectionId: props.connectionId!,
database: props.database!,
schema: props.schema,
databaseType: props.databaseType,
sql: doc,
position: pos,
},
target,
);
// 0. CTE (WITH ... AS) in-editor navigation — jump within the editor instead of
// opening metadata for a name that does not exist as a physical table.
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
try {
const cteModel = getEditorSemanticModel(doc, pos, currentView.state);
if (cteModel) {
const referenceHit = findCteReferenceAt(cteModel, pos);
if (referenceHit?.definition.nameSpan) {
jumpToCteRange({ from: referenceHit.definition.nameSpan.start, to: referenceHit.definition.nameSpan.end }, { from: referenceHit.definition.sourceSpan.start, to: referenceHit.definition.sourceSpan.end });
return;
}
const clickQualifier = identity.parts.length >= 2 ? identity.parts[identity.parts.length - 2]?.value : undefined;
const columnHit = findCteColumnResolution(cteModel, identity.name, clickQualifier);
const columnTargetSpan = columnHit?.output?.jumpSpan ?? columnHit?.stars?.[0]?.starSpan;
if (columnHit && columnTargetSpan) {
jumpToCteRange({ from: columnTargetSpan.start, to: columnTargetSpan.end }, { from: columnHit.definition.sourceSpan.start, to: columnHit.definition.sourceSpan.end });
return;
}
}
} catch (error) {
console.warn("[DBX] CTE ctrl+click resolution failed:", error);
}
}
// 1. Local table lookup with the resolved scope (do not trust stale editor cache alone —
// completion/hover may have filled cachedTables with the current schema only).
const localScopedTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, props.catalog);
completionMetadata.cachedTables = mergeCompletionTables(localScopedTables, completionMetadata.cachedTables);
let matchedTable = matchNavigationTable(localScopedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (!matchedTable && tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const localGlobalTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, props.catalog);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, localGlobalTables);
matchedTable = matchNavigationTable(localGlobalTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
const preserveOracleStoreCase = (value?: string) => !!value && value !== value.toUpperCase();
const openMatchedObject = (matchedObject: { name: string; schema?: string; type: string; signature?: string; parentName?: string; parentSchema?: string }) => {
const navigationType = sqlObjectNavigationTypeFromCompletionObjectType(matchedObject.type);
if (!navigationType) return false;
// Metadata names are store-correct; mark mixed-case (or click-quoted) so Oracle normalize won't force UPPER.
emit(
"openObjectSource",
sqlObjectNavigationTarget({
name: matchedObject.name,
schema: matchedObject.schema,
type: navigationType,
signature: matchedObject.signature,
parentName: matchedObject.parentName,
parentSchema: matchedObject.parentSchema,
nameQuoted: identity.nameQuoted || preserveOracleStoreCase(matchedObject.name),
schemaQuoted: matchedObject.schema ? identity.schemaQuoted || identity.qualifierQuoted || preserveOracleStoreCase(matchedObject.schema) : undefined,
parentNameQuoted: matchedObject.parentName ? identity.qualifierQuoted || preserveOracleStoreCase(matchedObject.parentName) : undefined,
parentSchemaQuoted: matchedObject.parentSchema ? identity.schemaQuoted || preserveOracleStoreCase(matchedObject.parentSchema) : undefined,
}),
false,
);
return true;
};
// 1b. Local routine cache — skip for pure relation column lists (INSERT INTO t(...)).
if (!isRelationColumnList) {
const localObjects = connectionStore.lookupLocalCompletionObjects(props.connectionId!, props.database!, objectNameFilter, MAX_COMPLETION_TABLES, props.schema);
let matchedObject = matchSqlObject(identifier, localObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
if (!usesLocalOnlyCompletionMetadata()) {
// Disambiguate schema.routine vs package.member with small scoped lookups (no global scan).
if (identity.parts.length === 2 && identity.qualifier) {
// Prefer package.member under session schema (Oracle common: PKG.MEMBER()).
const packageObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, props.schema, identity.qualifier, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, packageObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
// Then schema.routine with qualifier as owner.
const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, identity.qualifier, undefined, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, schemaObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
} else {
const scopedObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, objectNameFilter, 20, objectSchemaHint, objectParentHint, false, props.schema, ["routine"]);
matchedObject = matchSqlObject(identifier, scopedObjects);
if (matchedObject && openMatchedObject(matchedObject)) return;
}
}
}
// 1c. Remote table metadata — never skip for relation column lists or unknown identifiers.
// Routine-call sites may still hit this when a table and procedure share a name and
// local caches were empty; table wins only if listed as a relation.
if (!usesLocalOnlyCompletionMetadata() && (!isRoutineCall || isRelationColumnList || identity.role === "unknown")) {
completionMetadata.cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog);
matchedTable = matchNavigationTable(completionMetadata.cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, true, props.schema, props.catalog);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, globalTables);
matchedTable = matchNavigationTable(completionMetadata.cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
} else if (!usesLocalOnlyCompletionMetadata() && isRoutineCall) {
// Lightweight table check so INSERT INTO ORDERS(…) is not the only guarded path —
// still avoid unbounded scans: session-scoped first, optional name-filtered global fallback.
completionMetadata.cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog);
matchedTable = matchNavigationTable(completionMetadata.cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) {
const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, undefined, true, props.schema, props.catalog);
completionMetadata.cachedTables = mergeCompletionTables(completionMetadata.cachedTables, globalTables);
matchedTable = matchNavigationTable(completionMetadata.cachedTables);
if (matchedTable) {
emit("clickTable", relationNavigationTarget(matchedTable));
return;
}
}
}
// 1d. Optimistic routine open only for confirmed call sites after metadata + table checks.
if (isRoutineCall) {
const optimistic = sqlObjectNavigationTargetFromIdentity(identity, {
fallbackSchema: props.schema,
preferType: "procedure",
// 2-part: package.member under session schema (not schema.routine).
asPackageMember: identity.twoPartAmbiguous,
});
if (optimistic) {
emit("openObjectSource", optimistic, false);
return;
}
}
// 2. Parse SQL at click position to get referenced tables
const context = getSqlCompletionContext(doc, pos);
let referencedTables: Array<SqlCompletionReferencedTable & Pick<SqlCompletionTable, "type">> = context.referencedTables;
// Enrich referenced tables with schema from cachedTables
referencedTables = referencedTables.map((rt) => {
if (usesOracleSessionCompletionColumns(rt.schema)) return rt;
const cached = completionMetadata.cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase() && (!rt.schema || !ct.schema || ct.schema.toLowerCase() === rt.schema.toLowerCase()));
if (!cached) return rt;
return {
...rt,
...(!rt.schema && cached.schema ? { schema: cached.schema } : {}),
...(cached.type ? { type: cached.type } : {}),
};
});
// Check if identifier has a qualifier (e.g., c.card_name or schema.table)
const qualifier = identifierParts.length >= 2 ? identifierParts[identifierParts.length - 2] : null;
const matchedRef = matchTable(identifier, referencedTables);
if (matchedRef) {
emit("clickTable", relationNavigationTarget(matchedRef));
return;
}
const colName = identifierParts[identifierParts.length - 1] ?? identifier;
const colLower = colName.toLowerCase();
if (referencedTables.length === 0) {
return;
}
// 3. Fetch columns — if qualifier, only check matching table; otherwise check all
const tablesToCheck = qualifier ? referencedTables.filter((rt) => rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase()) : referencedTables;
if (tablesToCheck.length === 0 && qualifier) {
return;
}
const matchedCols: Array<{
name: string;
table: string;
schema?: string;
}> = [];
for (const refTable of tablesToCheck) {
const cacheKey = completionCacheKey(refTable);
// Use persistent column cache; fetch only if missing
let cols = cachedColumnsByTable.get(cacheKey);
if (!cols) {
try {
const target = completionMetadataTarget(refTable);
if (!target) continue;
cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable);
cachedColumnsByTable.set(cacheKey, cols);
} catch {
continue;
}
}
for (const col of cols) {
if (col.name.toLowerCase() === colLower) {
matchedCols.push({
name: col.name,
table: refTable.name,
schema: col.schema || refTable.schema,
});
}
}
}
if (matchedCols.length > 0) {
emit("clickColumn", matchedCols);
}
} catch (e) {
console.error("[DBX] Ctrl+click error:", e);
}
}, 0);
return true;
}
function attach() {
window.addEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.addEventListener("blur", clearTableNavigationHover);
}
function dispose() {
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
window.removeEventListener("blur", clearTableNavigationHover);
}
return { clearTableNavigationHover, updateTableNavigationHover, onEditorMouseDown, attach, dispose };
}
@@ -0,0 +1,211 @@
import type { EditorView as EditorViewType } from "@codemirror/view";
import { startsQueryEditorSelectionDrag } from "@/lib/editor/queryEditorPointerSelection";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
import type { QueryEditorProps } from "./queryEditorTypes";
interface QueryEditorPointerOptions {
props: Readonly<QueryEditorProps>;
clearTableNavigationHover: () => void;
emit: (event: "closeColumnPanel") => void;
}
export function useQueryEditorPointer(options: QueryEditorPointerOptions) {
const { props, clearTableNavigationHover, emit } = options;
let editorScrollbarPointerCleanup: (() => void) | null = null;
let editorSelectionDragCleanup: (() => void) | null = null;
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18;
const EDITOR_SELECTION_DRAG_THRESHOLD_PX = 6;
function isEditorScrollbarPointerEvent(currentView: EditorViewType, event: MouseEvent) {
if (event.button !== 0) return false;
const scrollDOM = currentView.scrollDOM;
const rect = scrollDOM.getBoundingClientRect();
const hasVerticalScrollbar = scrollDOM.scrollHeight > scrollDOM.clientHeight + 1;
const hasHorizontalScrollbar = scrollDOM.scrollWidth > scrollDOM.clientWidth + 1;
const verticalGutter = Math.max(scrollDOM.offsetWidth - scrollDOM.clientWidth, EDITOR_SCROLLBAR_POINTER_GUTTER_PX);
const horizontalGutter = Math.max(scrollDOM.offsetHeight - scrollDOM.clientHeight, EDITOR_SCROLLBAR_POINTER_GUTTER_PX);
const inVerticalScrollbar = hasVerticalScrollbar && event.clientX >= rect.right - verticalGutter && event.clientX <= rect.right;
const inHorizontalScrollbar = hasHorizontalScrollbar && event.clientY >= rect.bottom - horizontalGutter && event.clientY <= rect.bottom;
return inVerticalScrollbar || inHorizontalScrollbar;
}
function registerEditorScrollbarPointerGuard(currentView: EditorViewType) {
editorScrollbarPointerCleanup?.();
const onPointerDown = (event: MouseEvent) => {
if (!isEditorScrollbarPointerEvent(currentView, event)) return;
clearTableNavigationHover();
event.stopPropagation();
if (isTauriRuntime() && isMacOS() && !currentView.contentDOM.contains(event.target as Node | null)) {
event.preventDefault();
}
};
currentView.scrollDOM.addEventListener("mousedown", onPointerDown, true);
editorScrollbarPointerCleanup = () => {
currentView.scrollDOM.removeEventListener("mousedown", onPointerDown, true);
editorScrollbarPointerCleanup = null;
};
}
function selectedRangeAtPointer(currentView: EditorViewType, event: MouseEvent) {
if (props.readOnly || event.button !== 0) return null;
if (!currentView.contentDOM.contains(event.target as Node | null)) return null;
const range = currentView.state.selection.main;
if (range.empty) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null || pos < range.from || pos > range.to) return null;
return {
from: range.from,
to: range.to,
text: currentView.state.sliceDoc(range.from, range.to),
};
}
function moveOrCopySelectionToPointer(currentView: EditorViewType, selection: { from: number; to: number; text: string }, event: MouseEvent) {
const dropPos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (dropPos == null) return false;
const copy = event.ctrlKey || event.metaKey;
if (!copy && dropPos >= selection.from && dropPos <= selection.to) return true;
const insert = { from: dropPos, insert: selection.text };
const changes = copy ? currentView.state.changes(insert) : currentView.state.changes([{ from: selection.from, to: selection.to }, insert]);
currentView.dispatch({
changes,
selection: {
anchor: changes.mapPos(dropPos, -1),
head: changes.mapPos(dropPos, 1),
},
scrollIntoView: true,
userEvent: copy ? "input.drop" : "move.drop",
});
currentView.focus();
return true;
}
function hideEditorSelectionDropCursor() {
editorSelectionDropCursorEl?.remove();
editorSelectionDropCursorEl = null;
}
function updateEditorSelectionDropCursor(currentView: EditorViewType, event: MouseEvent) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }, false);
if (pos == null) {
hideEditorSelectionDropCursor();
return;
}
const coords = currentView.coordsAtPos(pos);
if (!coords) {
hideEditorSelectionDropCursor();
return;
}
const ownerDocument = currentView.dom.ownerDocument;
const cursor = editorSelectionDropCursorEl ?? ownerDocument.createElement("div");
if (!editorSelectionDropCursorEl) {
cursor.setAttribute("aria-hidden", "true");
cursor.className = "dbx-editor-selection-drop-cursor";
// Use a fixed overlay instead of CodeMirror's internal drop cursor layer so
// the marker stays visible above selection layers, themes, and scrollers.
cursor.style.position = "fixed";
cursor.style.zIndex = "2147483647";
cursor.style.width = "2px";
cursor.style.pointerEvents = "none";
cursor.style.backgroundImage = "repeating-linear-gradient(to bottom, #e879f9 0 4px, transparent 4px 7px)";
cursor.style.filter = "drop-shadow(0 0 1px rgba(0, 0, 0, 0.7))";
ownerDocument.body.appendChild(cursor);
editorSelectionDropCursorEl = cursor;
}
cursor.style.left = `${Math.round(coords.left) - 1}px`;
cursor.style.top = `${Math.round(coords.top)}px`;
cursor.style.height = `${Math.max(16, Math.round(coords.bottom - coords.top))}px`;
}
function startEditorSelectionDrag(currentView: EditorViewType, event: MouseEvent): boolean {
if (!startsQueryEditorSelectionDrag(event)) return false;
const selection = selectedRangeAtPointer(currentView, event);
if (!selection) return false;
event.preventDefault();
event.stopPropagation();
if (!event.ctrlKey && !event.metaKey) {
emit("closeColumnPanel");
}
editorSelectionDragCleanup?.();
const startX = event.clientX;
const startY = event.clientY;
let dragging = false;
const cleanup = () => {
currentView.contentDOM.ownerDocument.removeEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.removeEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.removeEventListener("keydown", onKeyDown, true);
hideEditorSelectionDropCursor();
editorSelectionDragCleanup = null;
};
const onMove = (moveEvent: MouseEvent) => {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (!dragging && distance < EDITOR_SELECTION_DRAG_THRESHOLD_PX) return;
dragging = true;
if (moveEvent.ctrlKey || moveEvent.metaKey) {
currentView.contentDOM.style.cursor = "copy";
} else {
currentView.contentDOM.style.cursor = "move";
}
updateEditorSelectionDropCursor(currentView, moveEvent);
moveEvent.preventDefault();
moveEvent.stopImmediatePropagation();
};
const onUp = (upEvent: MouseEvent) => {
cleanup();
currentView.contentDOM.style.cursor = "";
upEvent.preventDefault();
upEvent.stopImmediatePropagation();
if (dragging) {
moveOrCopySelectionToPointer(currentView, selection, upEvent);
return;
}
const pos = currentView.posAtCoords({
x: upEvent.clientX,
y: upEvent.clientY,
});
if (pos != null) {
currentView.dispatch({
selection: { anchor: pos },
userEvent: "select.pointer",
});
currentView.focus();
}
};
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key !== "Escape") return;
cleanup();
currentView.contentDOM.style.cursor = "";
keyEvent.preventDefault();
keyEvent.stopImmediatePropagation();
};
currentView.contentDOM.ownerDocument.addEventListener("mousemove", onMove, true);
currentView.contentDOM.ownerDocument.addEventListener("mouseup", onUp, true);
currentView.contentDOM.ownerDocument.addEventListener("keydown", onKeyDown, true);
editorSelectionDragCleanup = () => {
cleanup();
currentView.contentDOM.style.cursor = "";
};
return true;
}
function dispose() {
editorScrollbarPointerCleanup?.();
editorSelectionDragCleanup?.();
}
return { registerEditorScrollbarPointerGuard, startEditorSelectionDrag, dispose };
}
@@ -0,0 +1,125 @@
import type { ShallowRef } from "vue";
import type { EditorView as EditorViewType, ViewPlugin as ViewPluginType } from "@codemirror/view";
import { executableStatementRangeCacheForDoc, statementGutterStartIndexForCache, mapStatementGutterStartIndex, type ExecutableStatementRangeCache, type StatementGutterStartIndex } from "@/lib/sql/executableStatementRangeCache";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
interface QueryEditorStatementBoundariesOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
sqlStatementParameterOptions: () => SqlParameterOptions;
cache: { value: ExecutableStatementRangeCache | null };
runtime: QueryEditorCodeMirrorRuntime;
}
export function useQueryEditorStatementBoundaries(options: QueryEditorStatementBoundariesOptions) {
const { props, view, sqlStatementParameterOptions, cache, runtime: codeMirrorRuntime } = options;
// Lenient statement-boundary view shared by the run-statement gutter and the
// current-statement frame. Re-parsing the whole document on every keystroke is
// the dominant typing cost on large scripts, so while typing we only shift the
// known start positions / frame range through the ChangeSet (O(statements)) and
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
// click-to-execute, execution picker) keep using the full on-demand parse.
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
let statementBoundariesView: {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
} | null = null;
let statementBoundariesGeneration = 0;
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
cache.value = executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions());
statementBoundariesView = {
doc: state.doc,
startsIndex: statementGutterStartIndexForCache(cache.value),
frameRange: null,
fresh: true,
generation: statementBoundariesGeneration,
};
}
interface StatementBoundariesView {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
}
// Returns the view matching `state`, rebuilding it synchronously when the
// tracked doc fell out of sync (first use, tab switch via setState) or the
// dialect generation moved. While typing, the tracking plugin keeps the doc
// reference current through ChangeSet mapping, so this stays cheap.
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
refreshStatementBoundaries(state);
return statementBoundariesView!;
}
function scheduleStatementBoundariesRefresh(currentView: EditorViewType) {
// True debounce: continuous typing must never pay a full-document parse —
// mapped positions serve the gutter/frame, and one rebuild lands only after
// the pause.
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = setTimeout(() => {
statementBoundariesRefreshTimer = null;
if (view.value !== currentView || !currentView.dom.isConnected) return;
refreshStatementBoundaries(currentView.state);
if (codeMirrorRuntime.statementBoundariesRefreshEffect) {
currentView.dispatch({ effects: codeMirrorRuntime.statementBoundariesRefreshEffect.of(null) });
}
}, STATEMENT_BOUNDARIES_REFRESH_MS);
}
function createTrackingPlugin(ViewPlugin: typeof ViewPluginType) {
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
class {
update(update: import("@codemirror/view").ViewUpdate) {
if (!update.docChanged) return;
const boundaries = statementBoundariesView;
// No consumer (run gutter off + statement frame off) ever initialized
// the view — nothing to maintain and no refresh to schedule.
if (!boundaries) return;
if (boundaries.doc === update.startState.doc) {
statementBoundariesView = {
doc: update.state.doc,
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
frameRange: boundaries.frameRange
? {
from: update.changes.mapPos(boundaries.frameRange.from, 1),
to: update.changes.mapPos(boundaries.frameRange.to, 1),
}
: null,
fresh: false,
generation: boundaries.generation,
};
}
scheduleStatementBoundariesRefresh(update.view);
}
destroy() {
if (statementBoundariesRefreshTimer !== null) {
clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = null;
}
}
},
);
return statementBoundariesTrackingPlugin;
}
return {
statementBoundariesForState,
createTrackingPlugin,
invalidate() {
statementBoundariesGeneration += 1;
},
};
}
@@ -0,0 +1,157 @@
import { ref, computed } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import {
DBX_TABLE_REFERENCE_MIME,
DBX_TABLE_REFERENCE_DROP_EVENT,
DBX_TABLE_REFERENCE_HOVER_EVENT,
DBX_TABLE_REFERENCE_DRAG_END_EVENT,
activeTableReferencePayloadValue,
clearActiveTableReferencePayload,
hasTableReferencePayloadType,
parseTableReferencePayload,
tableReferenceInsertText,
type QueryEditorTableReferenceDropDetail,
type QueryEditorTableReferenceHoverDetail,
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
import type { Ref } from "vue";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { useSettingsStore } from "@/stores/settingsStore";
interface QueryEditorTableDropOptions {
props: Readonly<QueryEditorProps>;
view: Readonly<Ref<EditorViewType | null>>;
editorRef: Readonly<Ref<HTMLElement | null | undefined>>;
settingsStore: ReturnType<typeof useSettingsStore>;
}
export function useQueryEditorTableDrop(options: QueryEditorTableDropOptions) {
const { props, view, editorRef, settingsStore } = options;
let tableReferenceDropListenerRegistered = false;
// --- 表引用拖拽悬停时的插入光标线(指针模拟拖拽经 window 事件驱动) ---
const queryEditorDropCaret = ref<{ left: number; top: number; height: number } | null>(null);
const queryEditorDropCaretStyle = computed(() => {
const caret = queryEditorDropCaret.value;
return caret ? { left: `${caret.left}px`, top: `${caret.top}px`, height: `${caret.height}px` } : {};
});
function droppedTableReference(event: DragEvent) {
return activeTableReferencePayloadValue() ?? parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME));
}
function hasDroppedTableReference(event: DragEvent) {
return !!activeTableReferencePayloadValue() || hasTableReferencePayloadType(event.dataTransfer?.types);
}
function insertTableReferencePayload(currentView: EditorViewType, payload: QueryEditorTableReferencePayload, coords?: { clientX: number; clientY: number }): boolean {
if (props.readOnly) return false;
const insertText = tableReferenceInsertText(payload, props.databaseType, {
tableNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
includeTableSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
});
const dropPos = coords ? currentView.posAtCoords({ x: coords.clientX, y: coords.clientY }) : null;
const selection = currentView.state.selection.main;
const from = dropPos ?? selection.from;
const to = dropPos == null && !selection.empty ? selection.to : from;
currentView.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + insertText.length },
scrollIntoView: true,
userEvent: "input.drop",
});
clearActiveTableReferencePayload(payload);
hideQueryEditorDropCaret();
currentView.focus();
return true;
}
function insertDroppedTableReference(currentView: EditorViewType, event: DragEvent): boolean {
const payload = droppedTableReference(event);
if (!payload) return false;
event.preventDefault();
event.stopPropagation();
return insertTableReferencePayload(currentView, payload, {
clientX: event.clientX,
clientY: event.clientY,
});
}
function onTableReferenceDropEvent(event: Event) {
const currentView = view.value;
if (!currentView || props.readOnly || !(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceDropDetail | undefined;
if (!detail?.payload) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
insertTableReferencePayload(currentView, detail.payload, detail);
}
}
function showQueryEditorDropCaretAt(clientX: number, clientY: number) {
const currentView = view.value;
if (!currentView || props.readOnly || !editorRef.value) {
hideQueryEditorDropCaret();
return;
}
let dropPos: number | null = null;
try {
dropPos = currentView.posAtCoords({ x: clientX, y: clientY });
} catch {
dropPos = null;
}
if (dropPos == null) {
hideQueryEditorDropCaret();
return;
}
const coords = currentView.coordsAtPos(dropPos);
if (!coords) {
hideQueryEditorDropCaret();
return;
}
const rect = editorRef.value.getBoundingClientRect();
queryEditorDropCaret.value = { left: coords.left - rect.left, top: coords.top - rect.top, height: Math.max(coords.bottom - coords.top, 0) };
}
function hideQueryEditorDropCaret() {
queryEditorDropCaret.value = null;
}
function onTableReferenceHoverEvent(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceHoverDetail | undefined;
if (!detail) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (!isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
hideQueryEditorDropCaret();
return;
}
showQueryEditorDropCaretAt(detail.clientX, detail.clientY);
}
function onTableReferenceDragEndEvent() {
hideQueryEditorDropCaret();
}
function registerTableReferenceDropListener() {
if (tableReferenceDropListenerRegistered) return;
window.addEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.addEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.addEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = true;
}
function unregisterTableReferenceDropListener() {
if (!tableReferenceDropListenerRegistered) return;
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = false;
}
return { hasDroppedTableReference, insertDroppedTableReference, queryEditorDropCaret, queryEditorDropCaretStyle, showQueryEditorDropCaretAt, hideQueryEditorDropCaret, registerTableReferenceDropListener, unregisterTableReferenceDropListener };
}
@@ -0,0 +1,302 @@
import { ref, computed, type ShallowRef, type Ref } from "vue";
import { useI18n } from "vue-i18n";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useToast } from "@/composables/useToast";
import type { useSettingsStore } from "@/stores/settingsStore";
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { queryEditorClipboardPasteChange } from "@/lib/editor/queryEditorClipboardPaste";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { convertSqlSelectionCase, type SqlSelectionCaseMode } from "@/lib/sql/sqlSelectionCase";
import { convertToNextNamingStyle } from "@/lib/naming/namingStyleConverter";
import { copySqlAsRichText } from "@/lib/sql/sqlRichText";
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import type { QueryEditorProps } from "./queryEditorTypes";
interface QueryEditorTextActionsOptions {
props: Readonly<QueryEditorProps>;
view: ShallowRef<EditorViewType | null>;
selectedSql: Ref<string>;
settingsStore: ReturnType<typeof useSettingsStore>;
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
focusEditor: () => void;
getEditorSelection: () => typeof import("@codemirror/state").EditorSelection | null;
toggleLineComment: (view: EditorViewType) => void;
toggleBlockComment: (view: EditorViewType) => void;
}
export function useQueryEditorTextActions(options: QueryEditorTextActionsOptions) {
const { props, view, selectedSql, settingsStore, sqlBehaviorDialect, focusEditor, getEditorSelection, toggleLineComment, toggleBlockComment } = options;
const { t } = useI18n();
const { toast } = useToast();
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
// Delimited list dialog state
const delimitedListOpen = ref(false);
const delimitedListSelectedText = ref("");
const codeSnapshotOpen = ref(false);
const codeSnapshotSource = ref<CodeSnapshotSource | null>(null);
function openDelimitedListDialog() {
if (props.readOnly) return;
if (!selectedSql.value.trim()) {
toast(t("editor.delimitedList.selectFirst"), 3000);
return;
}
delimitedListSelectedText.value = selectedSql.value;
delimitedListOpen.value = true;
focusEditor();
}
function applyDelimitedListResult(result: string) {
const currentView = view.value;
if (!currentView || props.readOnly) return;
if (!replaceSelectedEditorText(currentView, result)) return;
focusEditor();
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
await copyToClipboard(selectedSql.value);
toast(t("grid.copied"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
// 富文本复制:写入 text/html + text/plain,粘贴到邮件/Word/IM 时保留语法高亮。
async function copySelectedSqlAsRichTextFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
await copySqlAsRichText(selectedSql.value);
toast(t("grid.copied"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function cutSelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
const currentView = view.value;
if (!currentView) return;
try {
await copyToClipboard(selectedSql.value);
// 剪切:复制后删除选中内容
const selection = currentView.state.selection.main;
if (!selection.empty) {
currentView.dispatch({
changes: { from: selection.from, to: selection.to },
selection: { anchor: selection.from, head: selection.from },
scrollIntoView: true,
userEvent: "input.cut",
});
}
toast(t("grid.cut"));
focusEditor();
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function pasteClipboardSqlFromContextMenu() {
if (props.readOnly) return;
const currentView = view.value;
if (!currentView) return;
try {
const text = await readTextFromClipboard();
if (!text) return;
const selection = currentView.state.selection.main;
// 粘贴:替换选中内容或在光标处插入
currentView.dispatch({
...queryEditorClipboardPasteChange(text, selection.from, selection.to),
scrollIntoView: true,
userEvent: "input.paste",
});
focusEditor();
} catch (e: any) {
toast(t("editor.contextMenu.pasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
}
}
function toggleCommentFromContextMenu() {
const currentView = view.value;
if (!currentView || props.readOnly) return;
toggleLineComment(currentView);
focusEditor();
}
function toggleBlockCommentFromContextMenu() {
const currentView = view.value;
if (!currentView || props.readOnly || !supportsQueryEditorBlockComments(props.databaseType)) return;
toggleBlockComment(currentView);
focusEditor();
}
function selectAllSqlFromContextMenu() {
const currentView = view.value;
if (!currentView) return;
currentView.dispatch({
selection: { anchor: 0, head: currentView.state.doc.length },
scrollIntoView: true,
});
focusEditor();
}
function convertSelectedSqlCase(mode: SqlSelectionCaseMode): boolean {
const currentView = view.value;
const EditorSelection = getEditorSelection();
if (!currentView || !EditorSelection) return false;
const state = currentView.state;
const documentText = state.doc.toString();
const transaction = state.changeByRange((range) => {
if (range.empty) return { range };
const convertedText = convertSqlSelectionCase(documentText, { from: range.from, to: range.to }, mode, sqlBehaviorDialect());
return {
changes: { from: range.from, to: range.to, insert: convertedText },
range: EditorSelection.range(range.from, range.from + convertedText.length),
};
});
if (!transaction.changes.empty) {
currentView.dispatch({
...transaction,
scrollIntoView: true,
userEvent: "input",
});
focusEditor();
return true;
}
return false;
}
function convertSelectedNamingStyle(): boolean {
const currentView = view.value;
const EditorSelection = getEditorSelection();
if (!currentView || !EditorSelection) return false;
const state = currentView.state;
const transaction = state.changeByRange((range) => {
if (range.empty) return { range };
const selectedText = state.doc.sliceString(range.from, range.to);
const result = convertToNextNamingStyle(selectedText);
return {
changes: { from: range.from, to: range.to, insert: result.text },
range: EditorSelection.range(range.from, range.from + result.text.length),
};
});
if (!transaction.changes.empty) {
currentView.dispatch({
...transaction,
scrollIntoView: true,
userEvent: "input",
});
focusEditor();
return true;
}
return false;
}
async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
if (!supportsSqlInListPaste(props.databaseType)) return false;
if (props.readOnly) return false;
const currentView = view.value;
if (!currentView) return false;
const selection = currentView.state.selection.main;
const selectedSource = selection.empty ? "" : currentView.state.sliceDoc(selection.from, selection.to);
let source = selectedSource;
if (!source) {
try {
source = await readTextFromClipboard();
} catch (e: any) {
toast(
t("editor.exPasteClipboardReadFailed", {
message: e?.message || String(e),
}),
5000,
);
focusEditor();
return false;
}
}
const result = buildSqlInConditionFromPasteSource(source, settingsStore.editorSettings.sqlFormatter.keywordCase);
if (!result.ok) {
const key = result.reason === "too-large" ? "editor.exPasteTooLarge" : result.reason === "too-many-values" ? "editor.exPasteTooManyValues" : result.reason === "not-list" ? "editor.exPasteNotList" : "editor.exPasteNoValues";
toast(t(key, { limit: result.limit ?? 0 }), 5000);
focusEditor();
return false;
}
if (view.value !== currentView || props.readOnly) return false;
const state = currentView.state;
const line = state.doc.lineAt(selection.from);
const prefix = state.sliceDoc(line.from, selection.from);
const insertText = insertTextForSqlInCondition(result.sql, prefix);
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: insertText },
selection: { anchor: selection.from + insertText.length },
scrollIntoView: true,
userEvent: "input.paste",
});
currentView.focus();
toast(t("editor.exPastePasted", { count: result.valueCount }), 2000);
return true;
}
function deleteEmptyLines() {
const currentView = view.value;
if (!currentView || props.readOnly) return;
const state = currentView.state;
const selection = state.selection.main;
const changes = blankLineDeletionChanges(state.doc, selection);
if (changes.length === 0) return;
currentView.dispatch({
changes,
scrollIntoView: true,
});
focusEditor();
}
function openCodeSnapshot() {
if (selectedSql.value.trim()) {
codeSnapshotSource.value = { code: selectedSql.value, lang: "sql" };
codeSnapshotOpen.value = true;
}
}
return {
delimitedListOpen,
delimitedListSelectedText,
codeSnapshotOpen,
codeSnapshotSource,
openCodeSnapshot,
openDelimitedListDialog,
applyDelimitedListResult,
copySelectedSqlFromContextMenu,
copySelectedSqlAsRichTextFromContextMenu,
cutSelectedSqlFromContextMenu,
pasteClipboardSqlFromContextMenu,
toggleCommentFromContextMenu,
toggleBlockCommentFromContextMenu,
selectAllSqlFromContextMenu,
convertSelectedSqlCase,
convertSelectedNamingStyle,
pasteClipboardAsSqlInCondition,
deleteEmptyLines,
};
}
@@ -9,10 +9,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
// completion row other than the checkbox itself used to fall through to
// CodeMirror's own list-item click handler, which applied that single row
// as the completion and silently discarded every other checked field. See
// onBatchColumnSelectionRowGuard in QueryEditor.vue.
// onBatchColumnSelectionRowGuard in useQueryEditorBatchSelection.ts.
// happy-dom's global URL polyfill rejects the file: scheme, so resolve the
// path with Node's own path/url helpers instead of `new URL(..., import.meta.url)`.
const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/QueryEditor.vue"), "utf8");
const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/useQueryEditorBatchSelection.ts"), "utf8");
function extractFunction(name: string): string {
const start = queryEditorSource.indexOf(`function ${name}(`);
@@ -7,10 +7,10 @@ import { describe, expect, it, vi } from "vitest";
// Regression coverage for #9122: the batch-selection completion list offered a
// per-field checkbox plus an "insert selected fields" row, but no way to check
// every field at once. See toggleAllBatchColumnSelection in QueryEditor.vue.
// every field at once. See toggleAllBatchColumnSelection in useQueryEditorBatchSelection.ts.
// happy-dom's global URL polyfill rejects the file: scheme, so resolve the
// path with Node's own path/url helpers instead of `new URL(..., import.meta.url)`.
const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/QueryEditor.vue"), "utf8");
const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/useQueryEditorBatchSelection.ts"), "utf8");
function extractFunction(name: string): string {
const start = queryEditorSource.indexOf(`function ${name}(`);
@@ -6,6 +6,10 @@ import { describe, expect, it, vi } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const contextMenuSource = readFileSync(new URL("../../../components/editor/QueryEditorContextMenu.vue", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../../../components/editor/queryEditorCodeMirrorRuntime.ts", import.meta.url), "utf8");
function runToggleBlockComment(doc: string, selection: EditorSelection | { anchor: number; head?: number }) {
let state = EditorState.create({ doc, selection, extensions: [sql()] });
const dispatch = vi.fn((transaction: Transaction) => {
@@ -23,15 +27,16 @@ function runToggleBlockComment(doc: string, selection: EditorSelection | { ancho
describe("QueryEditor block comment", () => {
it("wires toggleBlockComment into the right-click context menu, the keymap, and the editor module load", () => {
expect(queryEditorSource).toContain('let codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBlockComment | null = null;');
expect(runtimeSource).toContain('codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBlockComment | null;');
expect(runtimeSource).toContain("codeMirrorToggleBlockComment: null,");
expect(queryEditorSource).toContain("codeMirrorToggleBlockComment?.(currentView);");
expect(queryEditorSource).toContain('label: t("editor.contextMenu.blockCommentSelection")');
expect(queryEditorSource).toContain("shortcut: shortcuts.toggleBlockComment");
expect(contextMenuSource).toContain('label: t("editor.contextMenu.blockCommentSelection")');
expect(contextMenuSource).toContain("shortcut: shortcuts.toggleBlockComment");
expect(queryEditorSource).toContain("...binding(shortcuts.toggleBlockComment, (view) => {");
expect(queryEditorSource).toContain("!supportsQueryEditorBlockComments(props.databaseType)");
expect(queryEditorSource).toContain("defaultKeymapForGlobalShortcuts(codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorToggleBlockComment)");
expect(queryEditorSource).toContain("defaultKeymapForGlobalShortcuts(codeMirrorRuntime.codeMirrorDefaultKeymap, settingsStore.editorSettings.shortcuts).filter((item) => item.run !== codeMirrorRuntime.codeMirrorToggleBlockComment)");
expect(queryEditorSource).toContain("defaultKeymapComp.reconfigure(defaultKeymapExtension())");
expect(queryEditorSource).toContain("codeMirrorToggleBlockComment = toggleBlockComment;");
expect(runtimeSource).toContain("runtime.codeMirrorToggleBlockComment = toggleBlockComment;");
});
it("wraps the selected SQL in a block comment", () => {
@@ -7,15 +7,21 @@ import { DEFAULT_SHORTCUT_SETTINGS, normalizeShortcutSettings, shortcutToCodeMir
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const batchSource = readFileSync(new URL("../../../components/editor/useQueryEditorBatchSelection.ts", import.meta.url), "utf8");
const completionSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletion.ts", import.meta.url), "utf8");
const completionKeysSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletionKeys.ts", import.meta.url), "utf8");
function extractFunction(name: string): string {
const start = queryEditorSource.indexOf(`function ${name}(`);
const source = [queryEditorSource, completionSource, batchSource, completionKeysSource].find((candidate) => candidate.includes(`function ${name}(`)) ?? "";
const start = source.indexOf(`function ${name}(`);
if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`);
const bodyStart = queryEditorSource.indexOf("{", start);
const bodyStart = source.indexOf("{", start);
let depth = 0;
for (let index = bodyStart; index < queryEditorSource.length; index++) {
const character = queryEditorSource[index];
for (let index = bodyStart; index < source.length; index++) {
const character = source[index];
if (character === "{") depth++;
if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1);
if (character === "}" && --depth === 0) return source.slice(start, index + 1);
}
throw new Error(`Unterminated QueryEditor function: ${name}`);
}
@@ -84,6 +90,8 @@ function createHarness(options: {
"let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;",
"let cancelPendingCompletionEnter: (() => void) | null = null;",
"let suppressNextSqlCompletionAutoStartUntil = 0;",
"const completion = { get suppressAutoStartUntil() { return suppressNextSqlCompletionAutoStartUntil; }, set suppressAutoStartUntil(value) { suppressNextSqlCompletionAutoStartUntil = value; } };",
"const codeMirrorRuntime = { codeMirrorCompletionStatus, codeMirrorSelectedCompletion, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion, codeMirrorCloseCompletion, codeMirrorInsertNewlineKeepIndent, codeMirrorNextSnippetField, codeMirrorIndentMore };",
extractFunction("editorIndentUnit"),
extractFunction("handleTab"),
extractFunction("tabKeyAcceptsCompletion"),
@@ -181,7 +189,7 @@ afterEach(() => {
describe("QueryEditor completion Tab keymap", () => {
it("guards the batch INSERT snippet factory before applying", () => {
expect(queryEditorSource).toContain('if (session.mode === "insert" && !codeMirrorSnippetCompletion)');
expect(batchSource).toContain('if (session.mode === "insert" && !runtime.codeMirrorSnippetCompletion)');
});
it("closes completion and suppresses its restart for the Enter newline", () => {
const closeCompletion = vi.fn(() => true);
@@ -554,7 +562,7 @@ describe("QueryEditor completion Tab keymap", () => {
const javascript = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
}).outputText;
const factory = new Function("codeMirrorStartCompletion", `${javascript}\nreturn { triggerSqlCompletion, isEditorComposing };`);
const factory = new Function("codeMirrorStartCompletion", `const runtime = { codeMirrorStartCompletion };\n${javascript}\nreturn { triggerSqlCompletion, isEditorComposing };`);
const startCompletion = vi.fn(() => true);
const { triggerSqlCompletion } = factory(startCompletion) as {
triggerSqlCompletion: (view: MockView) => boolean;
@@ -9,7 +9,7 @@ import { EditorView, keymap, runScopeHandlers } from "@codemirror/view";
import { describe, expect, it, vi } from "vitest";
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
const queryEditorSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const queryEditorSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/editor/queryEditorSqlExtensions.ts"), "utf8");
function createCompletionView(doc = "", selectFirstCompletionOnOpen = false, interactionDelay: number | null = 0) {
return new EditorView({
@@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const diagnosticsSource = readFileSync(new URL("../../../components/editor/useQueryEditorDiagnostics.ts", import.meta.url), "utf8");
const completionSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletion.ts", import.meta.url), "utf8");
const metadataSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletionMetadata.ts", import.meta.url), "utf8");
function extractFunction(name: string): string {
const start = queryEditorSource.indexOf(`function ${name}(`);
const source = [queryEditorSource, completionSource, metadataSource, diagnosticsSource].find((candidate) => candidate.includes(`function ${name}(`)) ?? "";
const start = source.indexOf(`function ${name}(`);
if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`);
const bodyStart = queryEditorSource.indexOf("{", start);
const bodyStart = source.indexOf("{", start);
let depth = 0;
for (let index = bodyStart; index < queryEditorSource.length; index++) {
const character = queryEditorSource[index];
for (let index = bodyStart; index < source.length; index++) {
const character = source[index];
if (character === "{") depth++;
if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1);
if (character === "}" && --depth === 0) return source.slice(start, index + 1);
}
throw new Error(`Unterminated QueryEditor function: ${name}`);
}
@@ -43,7 +49,7 @@ describe("QueryEditor database name completion wiring", () => {
expect(extractFunction("routineCompletionScopeForContext")).toContain("database: target.database");
expect(extractFunction("completionObjectScopeKey")).toContain("scope.database");
expect(extractFunction("completionObjectScopeKey")).toContain("scope.schema");
expect(queryEditorSource).toContain("cachedCompletionObjectsByScope");
expect(metadataSource).toContain("cachedCompletionObjectsByScope");
});
it("loads SQL Server capability metadata before offering or applying USE completion", () => {
@@ -58,8 +64,8 @@ describe("QueryEditor database name completion wiring", () => {
const scope = extractFunction("semanticDiagnosticMetadataScope");
expect(scope).toContain("sqlServerUseDatabaseBeforeCursor(sql, range.from)");
expect(scope).toContain("lookupLocalCompletionDatabases");
expect(queryEditorSource).toMatch(/async function refreshSemanticDiagnostics[\s\S]*?semanticDiagnosticTablesForScope\(semanticAnalysis\.tables, metadataScope\)/);
expect(queryEditorSource).toContain("enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope)");
expect(queryEditorSource).toContain("ensureColumnsForSemanticDiagnostics(tables, metadataScope)");
expect(diagnosticsSource).toMatch(/async function refreshSemanticDiagnostics[\s\S]*?semanticDiagnosticTablesForScope\(semanticAnalysis\.tables, metadataScope\)/);
expect(diagnosticsSource).toContain("enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope)");
expect(diagnosticsSource).toContain("ensureColumnsForSemanticDiagnostics(tables, metadataScope)");
});
});
@@ -49,7 +49,7 @@ describe("needsDiagnosticCaretReanchor", () => {
});
describe("QueryEditor diagnostic caret re-anchor wiring", () => {
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorSource = readFileSync(new URL("../../../components/editor/useQueryEditorDiagnostics.ts", import.meta.url), "utf8");
it("re-anchors the browser caret after applying diagnostics, outside a transaction", () => {
expect(queryEditorSource).toMatch(/function reconfigureDiagnostics\(\)[\s\S]*?reanchorCaretAfterDiagnostics\(currentView\)/);
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisible } from "../../editor/queryEditorExecutionViewport";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorSource = ["QueryEditor.vue", "useQueryEditorExecution.ts"].map((file) => readFileSync(new URL(`../../../components/editor/${file}`, import.meta.url), "utf8")).join("\n");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
const editorGroupSource = readFileSync(new URL("../../../components/layout/EditorGroup.vue", import.meta.url), "utf8");
@@ -148,7 +148,7 @@ describe("QueryEditor execution routing", () => {
expect(queryEditorSource).toContain("shouldBlockExecutionShortcut(event, currentView)");
expect(queryEditorSource).toContain("if (props.readOnly) return true;");
expect(queryEditorSource).toContain("settingsStore.editorSettings.sqlShortcuts");
expect(queryEditorSource).toContain("runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap))");
expect(queryEditorSource).toContain("runKeymapComp.reconfigure(runKeymapExtension(codeMirrorRuntime.editorViewModule.keymap))");
});
});
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { focusEditorView, type EditorViewLike } from "@/lib/editor/queryEditorFocus";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorSource = ["QueryEditor.vue", "queryEditorTypes.ts", "useQueryEditorExecution.ts", "useQueryEditorDocumentState.ts"].map((file) => readFileSync(new URL(`../../../components/editor/${file}`, import.meta.url), "utf8")).join("\n");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
@@ -1,12 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorSource = readFileSync(new URL("../../../components/editor/useQueryEditorHover.ts", import.meta.url), "utf8");
const contentSource = readFileSync(new URL("../../../components/editor/queryEditorHoverContent.ts", import.meta.url), "utf8");
describe("QueryEditor hover DDL copy", () => {
it("reconciles the local sidebar index before remote hover fallback", () => {
const hoverStart = queryEditorSource.indexOf("async function resolveSqlHoverTooltip");
const hoverEnd = queryEditorSource.indexOf("\n}\n\nfunction sqlErrorDecorationRange", hoverStart);
const hoverEnd = queryEditorSource.indexOf("\n }\n", hoverStart);
const hoverSource = queryEditorSource.slice(hoverStart, hoverEnd);
const localLookup = hoverSource.indexOf("connectionStore.lookupLocalCompletionTables");
const remoteLookup = hoverSource.indexOf("connectionStore.listCompletionTables");
@@ -16,7 +17,7 @@ describe("QueryEditor hover DDL copy", () => {
expect(localLookup).toBeGreaterThanOrEqual(0);
expect(remoteLookup).toBeGreaterThan(localLookup);
expect(hoverSource).toContain("hoverTables = mergeCompletionTables(localHoverTables, hoverTables);");
expect(hoverSource).toContain("cachedTables = mergeCompletionTables(localHoverTables, cachedTables);");
expect(hoverSource).toContain("completionMetadata.cachedTables = mergeCompletionTables(localHoverTables, completionMetadata.cachedTables);");
});
it("gates only the table DDL branch on the preference, keeping column hover", () => {
@@ -25,12 +26,12 @@ describe("QueryEditor hover DDL copy", () => {
});
it("adds an accessible copy button that preserves SQL whitespace semantics", () => {
expect(queryEditorSource).toContain('copyButton.textContent = t("grid.copyDdl");');
expect(queryEditorSource).toContain('copyButton.setAttribute("aria-label", t("grid.copyDdl"));');
expect(queryEditorSource).toContain('copyButton.addEventListener("pointerdown"');
expect(queryEditorSource).toContain("event.stopPropagation();");
expect(queryEditorSource).toContain("await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent));");
expect(queryEditorSource).toContain('toast(t("contextMenu.ddlCopied"), 2000);');
expect(queryEditorSource).toContain('toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);');
expect(contentSource).toContain('copyButton.textContent = t("grid.copyDdl");');
expect(contentSource).toContain('copyButton.setAttribute("aria-label", t("grid.copyDdl"));');
expect(contentSource).toContain('copyButton.addEventListener("pointerdown"');
expect(contentSource).toContain("event.stopPropagation();");
expect(contentSource).toContain("await copyToClipboard(normalizeAlignedSqlWhitespace(sqlContent));");
expect(contentSource).toContain('toast(t("contextMenu.ddlCopied"), 2000);');
expect(contentSource).toContain('toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000);');
});
});
@@ -5,10 +5,11 @@ import { EditorState, Prec, type Transaction } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const editorThemesSource = readFileSync(new URL("../../editor/editorThemes.ts", import.meta.url), "utf8");
const shellHighlightSource = readFileSync(new URL("../../editor/codemirrorShellLineCommentHighlight.ts", import.meta.url), "utf8");
const extensionsSource = readFileSync(new URL("../../../components/editor/queryEditorSqlExtensions.ts", import.meta.url), "utf8");
function runToggleLineComment(doc: string, commentToken: string) {
let state = EditorState.create({
doc,
@@ -72,13 +73,13 @@ describe("QueryEditor word selection", () => {
describe("QueryEditor line comment", () => {
it("overrides the language comment tokens in the SQL language compartment", () => {
expect(queryEditorSource).toContain("Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }]))");
expect(extensionsSource).toContain("Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }]))");
});
it("highlights // comments with the theme's comment style", () => {
expect(queryEditorSource).toContain('queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : []');
expect(queryEditorSource).toContain("const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });");
expect(queryEditorSource).toContain("shellLineCommentTheme(EditorView),");
expect(extensionsSource).toContain('queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : []');
expect(extensionsSource).toContain("const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });");
expect(extensionsSource).toContain("shellLineCommentTheme(EditorView),");
expect(editorThemesSource).toContain('".cm-shell-line-comment *"');
expect(editorThemesSource).toContain('color: "inherit !important"');
});
@@ -4,6 +4,10 @@ import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const diagnosticsSource = readFileSync(new URL("../../../components/editor/useQueryEditorDiagnostics.ts", import.meta.url), "utf8");
const extensionsSource = readFileSync(new URL("../../../components/editor/queryEditorSqlExtensions.ts", import.meta.url), "utf8");
describe("QueryEditor semantic highlighting while scrolling", () => {
it("recognizes the large single-statement shape from the reported regression", () => {
const sql = `CREATE TABLE [dbo].[code] ([id] int, [label] nvarchar(32));\nINSERT INTO [dbo].[code] ([id], [label]) VALUES\n${Array.from({ length: 240 }, (_, index) => `(${index}, N'row-${String(index).padStart(4, "0")}'),`).join("\n")}`;
@@ -15,38 +19,40 @@ describe("QueryEditor semantic highlighting while scrolling", () => {
});
it("reuses semantic statement windows across viewport-only updates", () => {
expect(queryEditorSource).toContain('private cachedDoc: import("@codemirror/state").Text | null = null;');
expect(queryEditorSource).toContain("private cachedWindows: Array<{");
expect(queryEditorSource).toContain("const cached = this.cachedWindows.find");
expect(queryEditorSource).toContain("const pendingWindows: Array<{ from: number; to: number }>");
expect(queryEditorSource).toContain("MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32");
expect(queryEditorSource).toContain("this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS)");
expect(extensionsSource).toContain('private cachedDoc: import("@codemirror/state").Text | null = null;');
expect(extensionsSource).toContain("private cachedWindows: Array<{");
expect(extensionsSource).toContain("const cached = this.cachedWindows.find");
expect(extensionsSource).toContain("const pendingWindows: Array<{ from: number; to: number }>");
expect(extensionsSource).toContain("MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32");
expect(extensionsSource).toContain("this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS)");
});
it("defers semantic highlighting while the document is changing", () => {
expect(queryEditorSource).toContain("SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100");
expect(queryEditorSource).toContain("this.decorations = this.decorations.map(update.changes)");
expect(queryEditorSource).toContain("refreshSqlSemanticHighlightEffect.of(null)");
expect(extensionsSource).toContain("SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100");
expect(extensionsSource).toContain("this.decorations = this.decorations.map(update.changes)");
expect(extensionsSource).toContain("refreshSqlSemanticHighlightEffect.of(null)");
});
it("never exposes undefined decorations and prewarms the full document only once per document", () => {
expect(queryEditorSource).toContain("this.decorations = Decoration.none;");
expect(queryEditorSource).toContain('private prewarmedDoc: import("@codemirror/state").Text | null = null;');
expect(queryEditorSource).toContain("const shouldPrewarmFullDocument =");
expect(queryEditorSource).toContain("if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;");
expect(extensionsSource).toContain("this.decorations = Decoration.none;");
expect(extensionsSource).toContain('private prewarmedDoc: import("@codemirror/state").Text | null = null;');
expect(extensionsSource).toContain("const shouldPrewarmFullDocument =");
expect(extensionsSource).toContain("if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;");
});
it("keeps existing highlights while the parser catches up instead of wiping them", () => {
// An incomplete Lezer parse must not clear table-name decorations: the
// deferred refresh retries, so a freshly mounted editor (tab switch) also
// recovers even without a later viewport change.
expect(queryEditorSource).toContain("this.scheduleRefresh(currentView);");
expect(queryEditorSource).toContain("return this.decorations;");
expect(extensionsSource).toContain("this.scheduleRefresh(currentView);");
expect(extensionsSource).toContain("return this.decorations;");
});
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(diagnosticsSource).toContain('props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges');
expect(queryEditorSource).toContain("get executableStatementRangeCache()");
expect(queryEditorSource).toContain("set executableStatementRangeCache(value)");
expect(queryEditorSource).toContain("const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos)");
});
});
@@ -3,10 +3,13 @@ import { describe, expect, it } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../../../components/editor/queryEditorCodeMirrorRuntime.ts", import.meta.url), "utf8");
describe("QueryEditor SQL signature refresh wiring", () => {
it("reconfigures signature help when the database dialect or driver profile changes", () => {
expect(queryEditorSource).toContain('let sqlSignatureComp: import("@codemirror/state").Compartment | null = null;');
expect(queryEditorSource).toContain("sqlSignatureComp.of(buildSqlSignatureExtension())");
expect(runtimeSource).toContain('sqlSignatureComp: import("@codemirror/state").Compartment | null;');
expect(runtimeSource).toContain("runtime.sqlSignatureComp = new Compartment();");
expect(queryEditorSource).toContain("initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension())");
const watcherStart = queryEditorSource.indexOf("watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile]");
const watcherEnd = queryEditorSource.indexOf("\n});", watcherStart);
@@ -14,6 +17,6 @@ describe("QueryEditor SQL signature refresh wiring", () => {
expect(watcherStart).toBeGreaterThanOrEqual(0);
expect(watcherEnd).toBeGreaterThan(watcherStart);
expect(dialectWatcher).toContain("sqlSignatureComp.reconfigure(buildSqlSignatureExtension())");
expect(dialectWatcher).toContain("codeMirrorRuntime.sqlSignatureComp.reconfigure(codeMirrorRuntime.buildSqlSignatureExtension())");
});
});
@@ -3,7 +3,9 @@ import { EditorSelection, EditorState } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorSource = ["QueryEditor.vue", "useQueryEditorTextActions.ts"].map((file) => readFileSync(new URL(`../../../components/editor/${file}`, import.meta.url), "utf8")).join("\n");
const contextMenuSource = readFileSync(new URL("../../../components/editor/QueryEditorContextMenu.vue", import.meta.url), "utf8");
function applyBlankLineDeletion(doc: string, from: number, to: number): string {
const state = EditorState.create({
@@ -27,7 +29,7 @@ describe("replaceSelectedEditorText", () => {
});
it("disables and guards delimited-list generation in QueryEditor", () => {
expect(queryEditorSource).toContain("disabled: props.readOnly || !canCopySelectedSql.value");
expect(contextMenuSource).toContain("disabled: state.readOnly || !canCopySelectedSql");
expect(queryEditorSource).toMatch(/function openDelimitedListDialog\(\) \{\s+if \(props\.readOnly\) return;/);
expect(queryEditorSource).toMatch(/function applyDelimitedListResult\(result: string\) \{[\s\S]*if \(!currentView \|\| props\.readOnly\) return;[\s\S]*replaceSelectedEditorText/);
});
@@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const queryEditorPropsSource = readFileSync(new URL("../../../components/editor/queryEditorTypes.ts", import.meta.url), "utf8");
const completionSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletion.ts", import.meta.url), "utf8");
const metadataSource = readFileSync(new URL("../../../components/editor/useQueryEditorCompletionMetadata.ts", import.meta.url), "utf8");
const sidebarRuntimeSource = readFileSync(new URL("../../../components/sidebar/SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
const openTabsPersistenceSource = readFileSync(new URL("../../app/openTabsPersistence.ts", import.meta.url), "utf8");
@@ -10,16 +13,17 @@ describe("Doris external catalog completion wiring", () => {
it("preserves the selected catalog from the sidebar query action into QueryEditor", () => {
expect(sidebarRuntimeSource).toContain('queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema, undefined, node.catalog)');
expect(contentAreaSource).toContain(':catalog="activeTab.catalog"');
expect(queryEditorSource).toContain("catalog?: string;");
expect(queryEditorSource).toContain("defineProps<QueryEditorProps>()");
expect(queryEditorPropsSource).toContain("catalog?: string;");
expect(openTabsPersistenceSource).toContain("catalog: tab.catalog");
});
it("includes catalog scope in table and column completion requests", () => {
expect(queryEditorSource).toMatch(/lookupLocalCompletionTables\([\s\S]*?props\.catalog/);
expect(queryEditorSource).toMatch(/listCompletionTables\([\s\S]*?props\.catalog/);
expect(queryEditorSource).toMatch(/listCompletionColumns\([\s\S]*?catalog\)/);
expect(completionSource).toMatch(/lookupLocalCompletionTables\([\s\S]*?props\.catalog/);
expect(completionSource).toMatch(/listCompletionTables\([\s\S]*?props\.catalog/);
expect(metadataSource).toMatch(/listCompletionColumns\([\s\S]*?catalog\)/);
expect(queryEditorSource).toMatch(/watch\(\s*\(\) => props\.catalog,[\s\S]*?refreshCompletionCache\(\)/);
expect(queryEditorSource).toMatch(/function shouldLoadCompletionObjects[\s\S]*?if \(props\.catalog\) return false/);
expect(queryEditorSource).toMatch(/if \(!props\.catalog && props\.databaseType !== "oracle"[\s\S]*?listCompletionObjects/);
expect(completionSource).toMatch(/function shouldLoadCompletionObjects[\s\S]*?if \(props\.catalog\) return false/);
expect(completionSource).toMatch(/if \(!props\.catalog && props\.databaseType !== "oracle"[\s\S]*?listCompletionObjects/);
});
});
+13 -12
View File
@@ -13,10 +13,10 @@ import { test } from "vitest";
* line 1 instead of the previous viewport (#8374).
*/
function activateTabDocumentSource(): string {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
const start = source.indexOf("function activateTabDocument");
assert.notEqual(start, -1, "expected activateTabDocument in QueryEditor.vue");
const end = source.indexOf("\n}", start);
assert.notEqual(start, -1, "expected activateTabDocument in useQueryEditorDocumentState.ts");
const end = source.indexOf("\n }", start);
assert.notEqual(end, -1, "expected activateTabDocument closing brace");
return source.slice(start, end);
}
@@ -39,12 +39,12 @@ test("cached tab activation keeps restoring selection and viewport", () => {
});
test("viewport restore prefers the per-tab saved viewport", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
assert.match(source, /function restoreEditorViewport\(viewport = props\.initialViewport \?\? latestViewport\) \{/);
});
test("captures the outgoing editor viewport before KeepAlive deactivation", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
assert.match(source, /function captureEditorStateBeforeTabSwitch\(event: Event\) \{/);
assert.match(source, /fromTabId !== props\.tabId/);
assert.match(source, /captureEditorStateBeforeTabSwitch[\s\S]*?flushEditorViewport\(\);[\s\S]*?flushEditorSelection\(\);[\s\S]*?emit\("editorStateFlushed"\);/);
@@ -53,27 +53,28 @@ test("captures the outgoing editor viewport before KeepAlive deactivation", () =
});
test("does not flush a reset viewport after a tab-switch capture", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
assert.match(source, /let tabSwitchStateCaptured = false;/);
assert.match(source, /tabSwitchStateCaptured = true;/);
assert.match(source, /const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;[\s\S]*?tabSwitchStateCaptured = false;[\s\S]*?if \(editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
assert.match(source, /const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;[\s\S]*?tabSwitchStateCaptured = false;[\s\S]*?if \(runtime\.editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
assert.match(source, /if \(!tabSwitchStateCaptured\) flushEditorViewport\(\);/);
});
test("does not flush an editor again when KeepAlive evicts it after deactivation", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
assert.match(source, /if \(editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
assert.match(source, /if \(runtime\.editorIsActive && !stateWasCapturedBeforeTabSwitch\) \{[\s\S]*?flushEditorViewport\(\);/);
});
test("restores a saved cursor into view when no viewport was persisted", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
assert.match(source, /restoreEditorSelection\(props\.initialSelection, !props\.initialViewport\);/);
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
const editorSource = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
assert.match(editorSource, /restoreEditorSelection\(props\.initialSelection, !props\.initialViewport\);/);
assert.match(source, /function restoreEditorSelection\(selection = props\.initialSelection \?\? latestSelection, scrollIntoView = false\)/);
assert.match(source, /view\.value\.dispatch\(\{ selection: normalizedSelection, scrollIntoView \}\);/);
});
test("re-applies the viewport when saved SQL content hydrates after mount", () => {
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
const source = readFileSync(path.resolve("apps/desktop/src/components/editor/useQueryEditorDocumentState.ts"), "utf8");
assert.match(source, /watch\(\s*\(\) => props\.initialViewport,[\s\S]*?previousViewport[\s\S]*?latestViewport = \{ \.\.\.viewport \};[\s\S]*?restoreEditorViewport\(viewport\);/);
assert.match(source, /watch\(\s*\(\) => props\.initialSelection,[\s\S]*?previousSelection[\s\S]*?restoreEditorSelection\(selection, !props\.initialViewport\);/);
assert.match(source, /if \(attempts >= 32\)/);
+2 -2
View File
@@ -123,8 +123,8 @@ test("keeps completed diagnostics while the cursor is in an unfinished command",
const unfinished = underlined(source, 0).find((diagnostic) => diagnostic.severity === "error");
assert.match(unfinished!.message, /unclosed/i);
const queryEditorSource = readFileSync("apps/desktop/src/components/editor/QueryEditor.vue", "utf8");
const mongoBranch = queryEditorSource.slice(queryEditorSource.indexOf('if (props.databaseType === "mongodb")'), queryEditorSource.indexOf('if (props.databaseType === "redis")'));
const diagnosticsSource = readFileSync("apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts", "utf8");
const mongoBranch = diagnosticsSource.slice(diagnosticsSource.indexOf('if (props.databaseType === "mongodb")'), diagnosticsSource.indexOf('if (props.databaseType === "redis")'));
assert.doesNotMatch(mongoBranch, /shouldRunMongoDiagnostics/);
assert.match(mongoBranch, /setSemanticDiagnostics\(buildMongoSyntaxDiagnostics\(sql, cursor\)\)/);
});