mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(editor): split query editor into focused components
This commit is contained in:
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>
|
||||
Vendored
+397
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
-11
@@ -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();");
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
Vendored
+2
-2
@@ -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, "");
|
||||
|
||||
+202
@@ -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);
|
||||
});
|
||||
});
|
||||
+56
-46
@@ -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,");
|
||||
});
|
||||
});
|
||||
|
||||
+131
@@ -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();
|
||||
});
|
||||
});
|
||||
+195
@@ -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);
|
||||
});
|
||||
});
|
||||
+149
@@ -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();
|
||||
});
|
||||
});
|
||||
+229
@@ -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();
|
||||
});
|
||||
});
|
||||
+177
@@ -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);
|
||||
});
|
||||
});
|
||||
+137
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+218
@@ -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);
|
||||
});
|
||||
});
|
||||
+196
@@ -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();
|
||||
});
|
||||
});
|
||||
+184
@@ -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);
|
||||
});
|
||||
});
|
||||
+179
@@ -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);
|
||||
});
|
||||
});
|
||||
+103
@@ -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);
|
||||
});
|
||||
});
|
||||
+145
@@ -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");
|
||||
});
|
||||
});
|
||||
+184
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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}(`);
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+15
-9
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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"');
|
||||
});
|
||||
|
||||
+22
-16
@@ -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
@@ -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
@@ -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\)\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user