fix(editor): keep Enter and Tab with the IME during composition

This commit is contained in:
dienaso authored and GitHub committed 2026-09-06 15:00:00 +08:00
1 parent b9ea54350d
commit 16491cc3dd
5 files changed
+216

No files matched your search

@@ -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 });
@@ -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<typeof createFakeView>;
function createOptions(view: FakeView, overrides: Partial<RetryCompletionAcceptanceOptions> = {}) {
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);
});
});
@@ -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();