From 143be57d41b38826ce3890f0fe743626a079865b Mon Sep 17 00:00:00 2001 From: skyler <1156263951@qq.com> Date: Sun, 30 Aug 2026 10:35:34 +0800 Subject: [PATCH] fix(editor): stabilize batch column selection --- .../src/components/editor/QueryEditor.vue | 45 ++++++++++++++----- .../__tests__/QueryEditorContextMenu.spec.ts | 4 +- .../editor/batchColumnSelection.spec.ts | 36 ++++++++++++++- .../queryEditorCompletionKeymap.spec.ts | 25 +++++++++++ .../src/lib/editor/batchColumnSelection.ts | 15 +++++++ 5 files changed, 110 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index b853949599..42865256ff 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -118,7 +118,7 @@ import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignm import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap"; import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap"; import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion"; -import { batchColumnSelectionColumnList, batchColumnSelectionReplaceTo, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; +import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { enabledSqlShortcutActions, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions"; @@ -2085,7 +2085,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") function handleEnter(view: EditorViewType): boolean { clearPendingCompletionEnter(); - if (applySelectedBatchColumnSelection(view)) return true; + if (isBatchColumnSelectionCompletionActive(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 (codeMirrorAcceptCompletion?.(view)) return true; @@ -2139,8 +2139,8 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { // not word completion. A completion popup can still appear as a side effect // of the indent edit itself, so it must never hijack this or a following Tab. if (view.state.selection.ranges.every((range) => range.empty)) { - if (applySelectedBatchColumnSelection(view)) return true; const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; + if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true; if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return true; if (completionStatus) return waitForCompletionTab(view); } @@ -3513,6 +3513,10 @@ type QueryCompletionOption = Completion & { let batchColumnSelectionSession: BatchColumnSelectionSession | null = null; +function clearBatchColumnSelectionSession() { + batchColumnSelectionSession = null; +} + function isBatchColumnSelectionAction(item: QueryCompletionItem | BatchColumnSelectionActionItem): item is BatchColumnSelectionActionItem { return "batchColumnSelectionAction" in item && item.batchColumnSelectionAction === true; } @@ -3627,20 +3631,35 @@ function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelect return; } - const replaceTo = batchColumnSelectionReplaceTo({ - to, - mode: session.mode, - nextCharacter: view.state.sliceDoc(to, to + 1), - replaceClosingQuote: session.replaceClosingQuote, - }); const columns = batchColumnSelectionColumnList( selected.map((candidate) => candidate.apply), session.mode, session.qualifier, ); - const insert = session.mode === "insert" ? `${columns}) ${settingsStore.editorSettings.sqlFormatter.keywordCase === "lower" ? "values" : "VALUES"} (${selected.map((_, index) => `\${${index + 1}:value}`).join(", ")})` : columns; + let replaceTo = batchColumnSelectionReplaceTo({ + to, + mode: session.mode, + nextCharacter: view.state.sliceDoc(to, to + 1), + replaceClosingQuote: session.replaceClosingQuote, + }); + 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; + } - batchColumnSelectionSession = null; + if (session.mode === "insert" && !codeMirrorSnippetCompletion) { + clearBatchColumnSelectionSession(); + return; + } + clearBatchColumnSelectionSession(); markCompletionAccepted(item); if (session.mode === "insert") { const snippet = codeMirrorSnippetCompletion(insert, { label: item.label }); @@ -3658,7 +3677,7 @@ function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelect function applySelectedBatchColumnSelection(view: EditorViewType): boolean { const session = batchColumnSelectionSession; - if (!session || session.document !== view.state.doc.toString() || session.selectedKeys.size === 0) return false; + if (!session || !isBatchColumnSelectionCompletionActive(codeMirrorCompletionStatus?.(view.state) ?? null) || session.document !== view.state.doc.toString() || session.selectedKeys.size === 0) return false; applyBatchColumnSelection( view, { @@ -5659,6 +5678,7 @@ onMounted(async () => { { key: "Escape", run: () => { + clearBatchColumnSelectionSession(); return searchPanelRef.value?.closeSearch() ?? false; }, }, @@ -5718,6 +5738,7 @@ onMounted(async () => { const status = codeMirrorCompletionStatus(update.state) ?? null; if (status === null) { activeCompletionOrigin = null; + clearBatchColumnSelectionSession(); } } }), diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts index 6b5af30117..16ebf6a81b 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts @@ -120,9 +120,9 @@ describe("QueryEditor batch column selection", () => { const tabEnd = source.indexOf("\n}\n\nfunction clearPendingCompletionTab", tabStart); expect(source).toContain("function applySelectedBatchColumnSelection"); - expect(source.slice(handleEnterStart, handleEnterEnd)).toContain("if (applySelectedBatchColumnSelection(view)) return true;"); + 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 (applySelectedBatchColumnSelection(view)) return true;"); + expect(source.slice(tabStart, tabEnd)).toContain("if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;"); expect(source).toContain("defaultKeymap: false"); expect(source).toContain('{ key: "ArrowDown", run: (view) => moveCompletion(view, true) }'); expect(source).toContain('{ key: "ArrowUp", run: (view) => moveCompletion(view, false) }'); diff --git a/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts index 50f1cbb8b4..16466bf54f 100644 --- a/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { batchColumnSelectionColumnList, batchColumnSelectionReplaceTo, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; +import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; describe("batchColumnSelectionColumnList", () => { it("keeps a typed qualifier on every projection after the first", () => { @@ -25,6 +25,40 @@ describe("batchColumnSelectionReplaceTo", () => { }); }); +describe("batchColumnSelectionInsertReplacement", () => { + it("consumes whitespace before an existing closing parenthesis", () => { + expect( + batchColumnSelectionInsertReplacement({ + document: "INSERT INTO users (id )", + to: "INSERT INTO users (id".length, + columns: "id, name", + valuesKeyword: "VALUES", + valueCount: 2, + }), + ).toEqual({ replaceTo: "INSERT INTO users (id )".length, insert: "id, name) VALUES (${1:value}, ${2:value})" }); + }); + + it("does not duplicate an existing VALUES clause", () => { + expect( + batchColumnSelectionInsertReplacement({ + document: "INSERT INTO users (id) VALUES (1)", + to: "INSERT INTO users (id".length, + columns: "id, name", + valuesKeyword: "VALUES", + valueCount: 2, + }), + ).toEqual({ replaceTo: "INSERT INTO users (id)".length, insert: "id, name)" }); + }); +}); + +describe("isBatchColumnSelectionCompletionActive", () => { + it("only accepts a currently active completion popup", () => { + expect(isBatchColumnSelectionCompletionActive("active")).toBe(true); + expect(isBatchColumnSelectionCompletionActive("pending")).toBe(false); + expect(isBatchColumnSelectionCompletionActive(null)).toBe(false); + }); +}); + describe("shouldResolveSqlColumnCompletion", () => { it("loads fields after SELECT space when a FROM table is already known", () => { expect(shouldResolveSqlColumnCompletion({ suggestColumns: true, hasReferencedTables: true, prefix: "", typedActivation: false, selectListColumnContext: true })).toBe(true); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index d332e1b76d..93d1666ebb 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -95,6 +95,7 @@ function createHarness(options: { }).outputText; const factory = new Function( "codeMirrorCompletionStatus", + "isBatchColumnSelectionCompletionActive", "codeMirrorAcceptCompletion", "codeMirrorSelectedCompletionIndex", "codeMirrorSelectFirstCompletion", @@ -114,6 +115,7 @@ function createHarness(options: { ); return factory( options.completionStatus, + (status: "active" | "pending" | null) => status === "active", options.acceptCompletion ?? (() => false), options.selectedCompletionIndex ?? (() => 0), options.selectFirstCompletion ?? (() => false), @@ -164,6 +166,9 @@ afterEach(() => { }); describe("QueryEditor completion Tab keymap", () => { + it("guards the batch INSERT snippet factory before applying", () => { + expect(queryEditorSource).toContain('if (session.mode === "insert" && !codeMirrorSnippetCompletion)'); + }); it("closes completion and suppresses its restart for the Enter newline", () => { const closeCompletion = vi.fn(() => true); let harness: TabHarness; @@ -197,6 +202,26 @@ describe("QueryEditor completion Tab keymap", () => { expect(harness.consumeSqlCompletionAutoStartSuppression()).toBe(false); }); + it("does not apply a stale batch selection after the completion popup closes", () => { + const applySelectedBatchColumnSelection = vi.fn(() => true); + const insertNewlineKeepIndent = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => null, applySelectedBatchColumnSelection, insertNewlineKeepIndent, acceptCompletionShortcut: "Tab" }); + const view = createView(); + + expect(harness.handleEnter(view)).toBe(true); + expect(harness.handleTab(view)).toBe(true); + expect(applySelectedBatchColumnSelection).not.toHaveBeenCalled(); + expect(insertNewlineKeepIndent).toHaveBeenCalled(); + }); + + it("applies a batch selection only while the completion popup is active", () => { + const applySelectedBatchColumnSelection = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", applySelectedBatchColumnSelection }); + + expect(harness.handleEnter(createView())).toBe(true); + expect(applySelectedBatchColumnSelection).toHaveBeenCalledOnce(); + }); + it("keeps CodeMirror prefix filtering enabled for SQL completion results", () => { const javascript = ts.transpileModule(extractFunction("buildCompletionResult"), { compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, diff --git a/apps/desktop/src/lib/editor/batchColumnSelection.ts b/apps/desktop/src/lib/editor/batchColumnSelection.ts index 2bcf7b985e..798d1c2a86 100644 --- a/apps/desktop/src/lib/editor/batchColumnSelection.ts +++ b/apps/desktop/src/lib/editor/batchColumnSelection.ts @@ -17,3 +17,18 @@ export function batchColumnSelectionReplaceTo(options: { to: number; mode: Batch const { to, mode, nextCharacter, replaceClosingQuote } = options; return replaceClosingQuote === nextCharacter || (mode === "insert" && nextCharacter === ")") ? to + 1 : to; } + +export function batchColumnSelectionInsertReplacement(options: { document: string; to: number; columns: string; valuesKeyword: "values" | "VALUES"; valueCount: number }): { replaceTo: number; insert: string } { + const suffix = options.document.slice(options.to); + const closingParenthesis = suffix.match(/^\s*\)/); + const replaceTo = closingParenthesis ? options.to + closingParenthesis[0].length : options.to; + const hasExistingValues = /^\s*\)\s*VALUES\b/i.test(suffix); + if (hasExistingValues) return { replaceTo, insert: `${options.columns})` }; + + const values = Array.from({ length: options.valueCount }, (_, index) => `\${${index + 1}:value}`).join(", "); + return { replaceTo, insert: `${options.columns}) ${options.valuesKeyword} (${values})` }; +} + +export function isBatchColumnSelectionCompletionActive(status: "active" | "pending" | null): boolean { + return status === "active"; +}