fix(editor): keep Enter and Tab with the IME during composition
This commit is contained in:
1 parent
b9ea54350d
commit
16491cc3dd
5 files changed
+216
No files matched your search
@@ -2210,6 +2210,10 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEnter(view: EditorViewType): boolean {
|
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();
|
clearPendingCompletionEnter();
|
||||||
if (isBatchColumnSelectionCompletionActive(codeMirrorCompletionStatus?.(view.state) ?? null) && 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
|
// CodeMirror's default completion keymap is disabled so batch selection can
|
||||||
@@ -2224,6 +2228,7 @@ function handleEnter(view: EditorViewType): boolean {
|
|||||||
selectFirstCompletion: codeMirrorSelectFirstCompletion,
|
selectFirstCompletion: codeMirrorSelectFirstCompletion,
|
||||||
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
|
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
|
||||||
maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
|
maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
|
||||||
|
isComposing: () => isEditorComposing(view),
|
||||||
onUnavailable: () => insertNewlineWithoutCompletion(view),
|
onUnavailable: () => insertNewlineWithoutCompletion(view),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
if (cancelPendingCompletionEnter === cancelRetry) cancelPendingCompletionEnter = null;
|
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,
|
// 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
|
// 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.
|
// 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)) {
|
if (view.state.selection.ranges.every((range) => range.empty)) {
|
||||||
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
|
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
|
||||||
if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;
|
if (isBatchColumnSelectionCompletionActive(completionStatus) && applySelectedBatchColumnSelection(view)) return true;
|
||||||
@@ -2299,6 +2305,10 @@ function waitForCompletionTab(view: EditorViewType): boolean {
|
|||||||
|
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
pendingCompletionTabTimer = null;
|
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;
|
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;
|
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;
|
||||||
|
|
||||||
|
|||||||
+51
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,7 @@ function createHarness(options: {
|
|||||||
indentMore?: (view: MockView) => boolean;
|
indentMore?: (view: MockView) => boolean;
|
||||||
acceptCompletionShortcut?: string;
|
acceptCompletionShortcut?: string;
|
||||||
selectFirstCompletionOnOpen?: boolean;
|
selectFirstCompletionOnOpen?: boolean;
|
||||||
|
imeComposing?: (view: MockView) => boolean;
|
||||||
}): TabHarness {
|
}): TabHarness {
|
||||||
const source = [
|
const source = [
|
||||||
extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"),
|
extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"),
|
||||||
@@ -111,6 +112,7 @@ function createHarness(options: {
|
|||||||
"normalizeShortcutSettings",
|
"normalizeShortcutSettings",
|
||||||
"shortcutToCodeMirrorKey",
|
"shortcutToCodeMirrorKey",
|
||||||
"props",
|
"props",
|
||||||
|
"isEditorComposing",
|
||||||
`${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`,
|
`${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`,
|
||||||
);
|
);
|
||||||
return factory(
|
return factory(
|
||||||
@@ -137,6 +139,7 @@ function createHarness(options: {
|
|||||||
normalizeShortcutSettings,
|
normalizeShortcutSettings,
|
||||||
shortcutToCodeMirrorKey,
|
shortcutToCodeMirrorKey,
|
||||||
{ databaseType: "mysql" },
|
{ databaseType: "mysql" },
|
||||||
|
options.imeComposing ?? ((view: MockView) => (view as MockView & { composing?: boolean }).composing === true || (view as MockView & { compositionStarted?: boolean }).compositionStarted === true),
|
||||||
) as TabHarness;
|
) as TabHarness;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +198,42 @@ describe("QueryEditor completion Tab keymap", () => {
|
|||||||
expect(insertNewlineKeepIndent).not.toHaveBeenCalled();
|
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", () => {
|
it("does not suppress later completion when Enter cannot insert a newline", () => {
|
||||||
const harness = createHarness({ completionStatus: () => null, insertNewlineKeepIndent: () => false });
|
const harness = createHarness({ completionStatus: () => null, insertNewlineKeepIndent: () => false });
|
||||||
|
|
||||||
|
|||||||
+109
@@ -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;
|
maxWaitMs: number;
|
||||||
onUnavailable: () => void;
|
onUnavailable: () => void;
|
||||||
onSettled?: () => 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 {
|
interface CompletionAcceptanceAttempt {
|
||||||
@@ -52,6 +54,11 @@ export function acceptSelectedCompletionWithRetry(view: EditorView, options: Ret
|
|||||||
timer = null;
|
timer = null;
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
|
||||||
|
if (options.isComposing?.()) {
|
||||||
|
settle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectionRanges = view.state.selection.ranges;
|
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)) {
|
if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) {
|
||||||
settle();
|
settle();
|
||||||
|
|||||||
Reference in new issue
Block a user