fix(editor): preserve snippet navigation after dismissing completion
This commit is contained in:
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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user