From 16491cc3dddc6c8b0d95d7adf0b768ccbe76e2a9 Mon Sep 17 00:00:00 2001 From: dienaso <89768641@qq.com> Date: Sun, 6 Sep 2026 15:00:00 +0800 Subject: [PATCH] fix(editor): keep Enter and Tab with the IME during composition --- .../src/components/editor/QueryEditor.vue | 10 ++ ...ueryEditor.imeCompletionAcceptance.spec.ts | 51 ++++++++ .../queryEditorCompletionKeymap.spec.ts | 39 +++++++ ...queryEditorCompletionAcceptanceIme.spec.ts | 109 ++++++++++++++++++ .../editor/queryEditorCompletionAcceptance.ts | 7 ++ 5 files changed, 216 insertions(+) create mode 100644 apps/desktop/src/components/editor/__tests__/QueryEditor.imeCompletionAcceptance.spec.ts create mode 100644 apps/desktop/src/lib/editor/__tests__/queryEditorCompletionAcceptanceIme.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 95be8e7856..94754c2fb8 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -2210,6 +2210,10 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") } 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(codeMirrorCompletionStatus?.(view.state) ?? null) && applySelectedBatchColumnSelection(view)) return true; // CodeMirror's default completion keymap is disabled so batch selection can @@ -2224,6 +2228,7 @@ function handleEnter(view: EditorViewType): boolean { selectFirstCompletion: 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; @@ -2276,6 +2281,7 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { // Any non-empty selection range means Tab is being used for block indent, // 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 (isEditorComposing(view)) return false; if (view.state.selection.ranges.every((range) => range.empty)) { const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true; @@ -2299,6 +2305,10 @@ function waitForCompletionTab(view: EditorViewType): boolean { 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; diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.imeCompletionAcceptance.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.imeCompletionAcceptance.spec.ts new file mode 100644 index 0000000000..34bdd5386f --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.imeCompletionAcceptance.spec.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const editorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8"); + +function functionSource(name: string, nextName: string) { + const start = editorSource.indexOf(`function ${name}(`); + const end = editorSource.indexOf(`function ${nextName}(`, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return editorSource.slice(start, end); +} + +// Regression test for #8029: while an IME composition is active, Enter and the +// completion-accept shortcut belong to the IME candidate list. The acceptance +// handlers gate on the same isEditorComposing helper the completion providers +// already use, and the delayed retries must drop their queued acceptance once +// 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 composingGuard = source.indexOf("if (isEditorComposing(view)) return false;"); + const acceptCall = source.indexOf("codeMirrorAcceptCompletion?.(view)"); + + expect(composingGuard).toBeGreaterThanOrEqual(0); + expect(acceptCall).toBeGreaterThan(composingGuard); + }); + + it("gates the Tab completion-accept handler on IME composition", () => { + const source = functionSource("acceptCompletionOrNextSnippetField", "clearPendingCompletionTab"); + const composingGuard = source.indexOf("if (isEditorComposing(view)) return false;"); + const selectionCheck = source.indexOf("view.state.selection.ranges.every"); + + expect(composingGuard).toBeGreaterThanOrEqual(0); + expect(selectionCheck).toBeGreaterThan(composingGuard); + }); + + it("drops the pending Tab acceptance retry when an IME composition starts", () => { + const source = functionSource("waitForCompletionTab", "wordWrapExtension"); + const retry = source.indexOf("const retry = () => {"); + const composingGuard = source.indexOf("if (isEditorComposing(view)) return;", retry); + + expect(retry).toBeGreaterThanOrEqual(0); + expect(composingGuard).toBeGreaterThan(retry); + }); + + it("passes an IME guard to the Enter acceptance retry helper", () => { + const source = functionSource("handleEnter", "clearPendingCompletionEnter"); + expect(source.includes("isComposing: () => isEditorComposing(view)")).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 93d1666ebb..aaecf91a4b 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -67,6 +67,7 @@ function createHarness(options: { indentMore?: (view: MockView) => boolean; acceptCompletionShortcut?: string; selectFirstCompletionOnOpen?: boolean; + imeComposing?: (view: MockView) => boolean; }): TabHarness { const source = [ extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"), @@ -111,6 +112,7 @@ function createHarness(options: { "normalizeShortcutSettings", "shortcutToCodeMirrorKey", "props", + "isEditorComposing", `${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`, ); return factory( @@ -137,6 +139,7 @@ function createHarness(options: { normalizeShortcutSettings, shortcutToCodeMirrorKey, { databaseType: "mysql" }, + options.imeComposing ?? ((view: MockView) => (view as MockView & { composing?: boolean }).composing === true || (view as MockView & { compositionStarted?: boolean }).compositionStarted === true), ) as TabHarness; } @@ -195,6 +198,42 @@ describe("QueryEditor completion Tab keymap", () => { expect(insertNewlineKeepIndent).not.toHaveBeenCalled(); }); + it("lets the IME keep Enter while a composition is active instead of accepting a completion (#8029)", () => { + const acceptCompletion = vi.fn(() => true); + const closeCompletion = vi.fn(() => true); + const insertNewlineKeepIndent = vi.fn(() => true); + const harness = createHarness({ + completionStatus: () => "active", + acceptCompletion, + closeCompletion, + insertNewlineKeepIndent, + selectFirstCompletionOnOpen: true, + imeComposing: () => true, + }); + const view = createView(); + + expect(harness.handleEnter(view)).toBe(false); + expect(acceptCompletion).not.toHaveBeenCalled(); + expect(closeCompletion).not.toHaveBeenCalled(); + expect(insertNewlineKeepIndent).not.toHaveBeenCalled(); + }); + + it("lets the IME keep Tab while a composition is active instead of accepting a completion (#8029)", () => { + const acceptCompletion = vi.fn(() => true); + const nextSnippetField = vi.fn(() => true); + const harness = createHarness({ + completionStatus: () => "active", + acceptCompletion, + nextSnippetField, + imeComposing: () => true, + }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(false); + expect(acceptCompletion).not.toHaveBeenCalled(); + expect(nextSnippetField).not.toHaveBeenCalled(); + }); + it("does not suppress later completion when Enter cannot insert a newline", () => { const harness = createHarness({ completionStatus: () => null, insertNewlineKeepIndent: () => false }); diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorCompletionAcceptanceIme.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorCompletionAcceptanceIme.spec.ts new file mode 100644 index 0000000000..ff719d36d9 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorCompletionAcceptanceIme.spec.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { acceptSelectedCompletionWithRetry, type RetryCompletionAcceptanceOptions } from "../queryEditorCompletionAcceptance"; + +function createFakeView({ composing = false } = {}) { + return { + composing, + compositionStarted: composing, + state: { + selection: { ranges: [{ anchor: 0, head: 0 }] }, + }, + }; +} + +type FakeView = ReturnType; + +function createOptions(view: FakeView, overrides: Partial = {}) { + const status: { value: "active" | "pending" | null } = { value: "active" }; + const options = { + completionStatus: () => status.value, + acceptCompletion: vi.fn(() => false), + selectedCompletionIndex: () => null, + selectFirstCompletion: vi.fn(() => true), + retryDelayMs: 1, + maxWaitMs: 10, + onUnavailable: vi.fn(), + onSettled: vi.fn(), + isComposing: () => view.composing, + ...overrides, + } satisfies RetryCompletionAcceptanceOptions; + return { options, status }; +} + +describe("acceptSelectedCompletionWithRetry IME guard", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("accepts immediately when the completion is active and no retry is needed", () => { + const view = createFakeView(); + const { options } = createOptions(view, { + acceptCompletion: vi.fn(() => true), + }); + + const result = acceptSelectedCompletionWithRetry(view as never, options); + + expect(result.handled).toBe(true); + expect(options.acceptCompletion).toHaveBeenCalledTimes(1); + expect(options.onUnavailable).not.toHaveBeenCalled(); + }); + + it("drops the queued acceptance without calling onUnavailable once an IME composition starts while waiting", () => { + const view = createFakeView(); + const { options } = createOptions(view); + + const result = acceptSelectedCompletionWithRetry(view as never, options); + expect(result.handled).toBe(true); + + // The user started an IME composition before the retry fired: the queued + // Enter belongs to the IME candidate list now, so the retry must drop the + // acceptance silently instead of accepting a completion over the + // composition or falling back to onUnavailable. The only accept attempt is + // the initial synchronous one that happened before the composition began. + view.composing = true; + view.compositionStarted = true; + vi.advanceTimersByTime(5); + + expect(options.acceptCompletion).toHaveBeenCalledTimes(1); + expect(options.onUnavailable).not.toHaveBeenCalled(); + expect(options.onSettled).toHaveBeenCalledTimes(1); + }); + + it("keeps retrying while waiting when no IME composition starts", () => { + const view = createFakeView(); + const { options } = createOptions(view); + + acceptSelectedCompletionWithRetry(view as never, options); + // One synchronous attempt on entry. + expect(options.acceptCompletion).toHaveBeenCalledTimes(1); + + // First retry still sees an unacceptable completion and keeps waiting. + vi.advanceTimersByTime(1); + expect(options.acceptCompletion).toHaveBeenCalledTimes(2); + expect(options.onUnavailable).not.toHaveBeenCalled(); + + // No composition started, so the retry keeps its original semantics and + // accepts as soon as the completion becomes acceptable. + options.acceptCompletion.mockReturnValue(true); + vi.advanceTimersByTime(1); + expect(options.acceptCompletion).toHaveBeenCalledTimes(3); + expect(options.onUnavailable).not.toHaveBeenCalled(); + }); + + it("still calls onUnavailable when the completion never becomes acceptable and no composition starts", () => { + const view = createFakeView(); + const { options } = createOptions(view, { + isComposing: () => false, + maxWaitMs: 10, + }); + + acceptSelectedCompletionWithRetry(view as never, options); + vi.advanceTimersByTime(50); + + expect(options.onUnavailable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts b/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts index f67abacd78..54fd95068c 100644 --- a/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts +++ b/apps/desktop/src/lib/editor/queryEditorCompletionAcceptance.ts @@ -13,6 +13,8 @@ interface RetryCompletionAcceptanceOptions { maxWaitMs: number; onUnavailable: () => void; onSettled?: () => void; + /** Called before each retry; when an IME composition started while waiting, the queued acceptance must be dropped instead of fighting the IME. */ + isComposing?: () => boolean; } interface CompletionAcceptanceAttempt { @@ -52,6 +54,11 @@ export function acceptSelectedCompletionWithRetry(view: EditorView, options: Ret timer = null; if (settled) return; + if (options.isComposing?.()) { + settle(); + return; + } + const selectionRanges = view.state.selection.ranges; if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) { settle();