fix(editor): preserve snippet navigation after dismissing completion

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-11 23:25:18 +08:00
1 parent 2d9521898f
commit 8b1379ff4f
6 files changed
+468 -10

No files matched your search

@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import ts from "typescript";
import { afterEach, describe, expect, it, vi } from "vitest";
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape";
import { DEFAULT_SHORTCUT_SETTINGS, normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
@@ -318,6 +319,59 @@ describe("QueryEditor completion Tab keymap", () => {
expect(view.dispatch).not.toHaveBeenCalled();
});
it.each(["Tab", "Enter"])("advances the snippet after Escape dismisses completion with %s acceptance", (acceptCompletionShortcut) => {
let status: "active" | null = "active";
const nextSnippetField = vi.fn(() => true);
const acceptCompletion = vi.fn(() => true);
const harness = createHarness({ completionStatus: () => status, nextSnippetField, acceptCompletion, acceptCompletionShortcut });
const closeCompletion = vi.fn(() => {
status = null;
return true;
});
const escape = createQueryEditorEscapeHandler({
clearBatchSelection: vi.fn(),
cancelPendingAcceptance: harness.clearPendingCompletionTab,
closeSearch: () => false,
closeCompletion,
});
const view = createView();
expect(escape(view as unknown as Parameters<typeof escape>[0])).toBe(true);
expect(harness.handleTab(view)).toBe(true);
expect(nextSnippetField).toHaveBeenCalledWith(view);
expect(acceptCompletion).not.toHaveBeenCalled();
expect(view.dispatch).not.toHaveBeenCalled();
});
it("cancels a queued Tab on Escape without advancing until the next explicit Tab", async () => {
vi.useFakeTimers();
let status: "pending" | null = "pending";
const nextSnippetField = vi.fn(() => true);
const harness = createHarness({ completionStatus: () => status, nextSnippetField });
const escape = createQueryEditorEscapeHandler({
clearBatchSelection: vi.fn(),
cancelPendingAcceptance: harness.clearPendingCompletionTab,
closeSearch: () => false,
closeCompletion: () => {
status = null;
return true;
},
});
const view = createView();
expect(harness.handleTab(view)).toBe(true);
expect(vi.getTimerCount()).toBe(1);
expect(escape(view as unknown as Parameters<typeof escape>[0])).toBe(true);
await vi.advanceTimersByTimeAsync(100);
expect(nextSnippetField).not.toHaveBeenCalled();
expect(view.dispatch).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
expect(harness.handleTab(view)).toBe(true);
expect(nextSnippetField).toHaveBeenCalledOnce();
expect(view.dispatch).not.toHaveBeenCalled();
});
it("waits for pending completion before advancing a snippet field", async () => {
vi.useFakeTimers();
let status: "active" | "pending" | null = "pending";
@@ -417,7 +471,7 @@ describe("QueryEditor completion Tab keymap", () => {
it("indents mixed multi-range selections instead of accepting an active completion", () => {
const completionStatus = vi.fn(() => "active" as const);
const acceptCompletion = vi.fn(() => true);
const nextSnippetField = vi.fn(() => true);
const nextSnippetField = vi.fn(() => false);
const indentMore = vi.fn(() => true);
const harness = createHarness({ completionStatus, acceptCompletion, nextSnippetField, indentMore });
const view = createMixedMultiRangeView();
@@ -426,10 +480,33 @@ describe("QueryEditor completion Tab keymap", () => {
expect(indentMore).toHaveBeenCalledWith(view);
expect(completionStatus).not.toHaveBeenCalled();
expect(acceptCompletion).not.toHaveBeenCalled();
expect(nextSnippetField).not.toHaveBeenCalled();
expect(nextSnippetField).toHaveBeenCalledWith(view);
expect(view.state.replaceSelection).not.toHaveBeenCalled();
});
it("navigates a selected snippet field even when completion acceptance is remapped", () => {
const nextSnippetField = vi.fn(() => true);
const indentMore = vi.fn(() => true);
const acceptCompletion = vi.fn(() => true);
const harness = createHarness({ completionStatus: () => "active", nextSnippetField, indentMore, acceptCompletion, acceptCompletionShortcut: "Enter" });
const view = createMultiLineSelectionView();
expect(harness.handleTab(view)).toBe(true);
expect(nextSnippetField).toHaveBeenCalledWith(view);
expect(indentMore).not.toHaveBeenCalled();
expect(acceptCompletion).not.toHaveBeenCalled();
});
it("does not navigate or indent a selected field during IME composition", () => {
const nextSnippetField = vi.fn(() => true);
const indentMore = vi.fn(() => true);
const harness = createHarness({ completionStatus: () => "active", nextSnippetField, indentMore, imeComposing: () => true });
expect(harness.handleTab(createMultiLineSelectionView())).toBe(false);
expect(nextSnippetField).not.toHaveBeenCalled();
expect(indentMore).not.toHaveBeenCalled();
});
it("does not accept or wait for completion when a mixed multi-range selection is active", async () => {
vi.useFakeTimers();
let status: "active" | "pending" | null = "pending";
@@ -0,0 +1,19 @@
import type { Command } from "@codemirror/view";
interface QueryEditorEscapeOptions {
clearBatchSelection: () => void;
cancelPendingAcceptance: () => void;
closeSearch: () => boolean;
closeCompletion: Command;
}
/** Run at highest precedence, before the dynamically installed snippet keymap. */
export function createQueryEditorEscapeHandler(options: QueryEditorEscapeOptions): Command {
return (view) => {
options.clearBatchSelection();
options.cancelPendingAcceptance();
// Keep search dismissal first. Consume completion dismissal so Escape does
// not reach clearSnippet and discard the remaining placeholder navigation.
return options.closeSearch() || options.closeCompletion(view);
};
}