diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 88a694fa15..cc10350b32 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -14,154 +14,83 @@ let lastHandledCompressRequestId = 0; + + diff --git a/apps/desktop/src/components/editor/QueryEditorIntentionPopup.vue b/apps/desktop/src/components/editor/QueryEditorIntentionPopup.vue new file mode 100644 index 0000000000..b448a0752f --- /dev/null +++ b/apps/desktop/src/components/editor/QueryEditorIntentionPopup.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.componentSplit.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.componentSplit.integration.spec.ts new file mode 100644 index 0000000000..0af33d9111 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.componentSplit.integration.spec.ts @@ -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 = {}) { + 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({ modelValue: "SELECT 1;\nSELECT 2;", tabId: "split-a", databaseType: "mysql", dialect: "mysql", autoFocus: false, ...overrides }); + const editor = shallowRef>(); + 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('[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('[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(); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.cteNavigationWiring.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.cteNavigationWiring.spec.ts index 30cde48c38..dd396b28d4 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditor.cteNavigationWiring.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.cteNavigationWiring.spec.ts @@ -2,22 +2,26 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; /** - * QueryEditor.vue 的 CTE 接线(hover 溯源、Ctrl+点击跳转、语义模型缓存)都写在 - * `