fix(editor): preserve snippet navigation after dismissing completion
This commit is contained in:
6 files changed
+468
-10
No files matched your search
@@ -317,6 +317,10 @@ function openReplace(): boolean {
|
||||
|
||||
function closeSearch() {
|
||||
const wasVisible = searchVisible.value;
|
||||
// Escape also reaches this command while editing. A hidden panel must not
|
||||
// dispatch a selection reset, which would dismiss completion before its
|
||||
// Escape handler can consume the key and preserve snippet navigation.
|
||||
if (!wasVisible) return false;
|
||||
searchVisible.value = false;
|
||||
showReplace.value = false;
|
||||
clearDocumentSearchUpdate();
|
||||
|
||||
@@ -136,6 +136,7 @@ import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticC
|
||||
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
||||
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
|
||||
import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
|
||||
import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape";
|
||||
import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignmentExtension } from "@/lib/editor/queryEditorLineNumbers";
|
||||
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
|
||||
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
|
||||
@@ -892,7 +893,12 @@ function editorIndentUnit(): string {
|
||||
}
|
||||
|
||||
function handleTab(view: EditorViewType): boolean {
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return codeMirrorIndentMore?.(view) ?? false;
|
||||
if (isEditorComposing(view)) return false;
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) {
|
||||
// Snippet navigation selects the whole field, including after Shift+Tab.
|
||||
// Give that active session priority over ordinary selected-text indentation.
|
||||
return (codeMirrorNextSnippetField?.(view) ?? false) || (codeMirrorIndentMore?.(view) ?? false);
|
||||
}
|
||||
if (tabKeyAcceptsCompletion()) {
|
||||
return acceptCompletionOrNextSnippetField(view) || performNormalTab(view);
|
||||
}
|
||||
@@ -2393,9 +2399,8 @@ function selectAllSelectionOccurrencesFromContextMenu() {
|
||||
}
|
||||
|
||||
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.
|
||||
// A non-empty selection belongs to snippet navigation or block indentation,
|
||||
// not word completion. A popup opened by an indent edit must not hijack Tab.
|
||||
if (isEditorComposing(view)) return false;
|
||||
if (view.state.selection.ranges.every((range) => range.empty)) {
|
||||
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
|
||||
@@ -6384,10 +6389,15 @@ onMounted(async () => {
|
||||
{ key: "Tab", run: handleTab },
|
||||
{
|
||||
key: "Escape",
|
||||
run: () => {
|
||||
clearBatchColumnSelectionSession();
|
||||
return searchPanelRef.value?.closeSearch() ?? false;
|
||||
},
|
||||
run: createQueryEditorEscapeHandler({
|
||||
clearBatchSelection: clearBatchColumnSelectionSession,
|
||||
cancelPendingAcceptance: () => {
|
||||
clearPendingCompletionEnter();
|
||||
clearPendingCompletionTab();
|
||||
},
|
||||
closeSearch: () => searchPanelRef.value?.closeSearch() ?? false,
|
||||
closeCompletion: (currentView) => codeMirrorCloseCompletion?.(currentView) ?? false,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, h, reactive, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { completionStatus, hasNextSnippetField, hasPrevSnippetField, snippet, startCompletion } from "@codemirror/autocomplete";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import QueryEditor from "../QueryEditor.vue";
|
||||
|
||||
// Keep the real autocomplete state, keymaps, editor, and search child. Only
|
||||
// replace SQL candidate retrieval so this regression needs no database.
|
||||
vi.mock("@codemirror/autocomplete", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@codemirror/autocomplete")>();
|
||||
return {
|
||||
...actual,
|
||||
autocompletion: (config: Parameters<typeof actual.autocompletion>[0]) =>
|
||||
actual.autocompletion({
|
||||
...config,
|
||||
override: [(context) => ({ from: context.pos, options: [{ label: "candidate" }] })],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup();
|
||||
});
|
||||
|
||||
async function mountEditor() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const state = reactive({ sql: "" });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app: App = createApp({
|
||||
render: () =>
|
||||
h(QueryEditor, {
|
||||
modelValue: state.sql,
|
||||
tabId: "snippet-escape-integration",
|
||||
databaseType: "mysql",
|
||||
dialect: "mysql",
|
||||
autoFocus: false,
|
||||
"onUpdate:modelValue": (value: string) => {
|
||||
state.sql = value;
|
||||
},
|
||||
}),
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
|
||||
return EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
|
||||
}
|
||||
|
||||
function press(view: EditorView, key: string, shiftKey = false) {
|
||||
const event = new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true });
|
||||
view.contentDOM.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
}
|
||||
function selectedText(view: EditorView) {
|
||||
return view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
|
||||
}
|
||||
|
||||
describe("mounted QueryEditor snippet Escape", () => {
|
||||
it.each([false, true])("returns to the first field with Shift+Tab after Escape (second field edited: %s)", async (editSecondField) => {
|
||||
const view = await mountEditor();
|
||||
snippet("CREATE TABLE ${table} (${column} ${type});")(view, { label: "create table" }, 0, 0);
|
||||
view.dispatch(view.state.replaceSelection("demo"));
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
|
||||
press(view, "Escape");
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("column");
|
||||
if (editSecondField) view.dispatch(view.state.replaceSelection("id"));
|
||||
const beforeBack = view.state.doc.toString();
|
||||
expect(hasPrevSnippetField(view.state)).toBe(true);
|
||||
|
||||
press(view, "Tab", true);
|
||||
expect(selectedText(view)).toBe("demo");
|
||||
expect(view.state.selection.main.from).toBe("CREATE TABLE ".length);
|
||||
expect(view.state.doc.toString()).toBe(beforeBack);
|
||||
expect(hasPrevSnippetField(view.state)).toBe(false);
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe(editSecondField ? "id" : "column");
|
||||
expect(view.state.doc.toString()).toBe(beforeBack);
|
||||
});
|
||||
|
||||
it("navigates untouched fields with Tab, Shift+Tab, Tab without indenting", async () => {
|
||||
const view = await mountEditor();
|
||||
snippet("CREATE TABLE ${table} (${column} ${type});")(view, { label: "create table" }, 0, 0);
|
||||
const original = view.state.doc.toString();
|
||||
expect(selectedText(view)).toBe("table");
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("column");
|
||||
press(view, "Tab", true);
|
||||
expect(selectedText(view)).toBe("table");
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("column");
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("type");
|
||||
expect(view.state.doc.toString()).toBe(original);
|
||||
expect(hasNextSnippetField(view.state)).toBe(false);
|
||||
expect(hasPrevSnippetField(view.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("still indents an ordinary selected range outside a snippet", async () => {
|
||||
const view = await mountEditor();
|
||||
const sql = "SELECT id\nFROM users;";
|
||||
view.dispatch({ changes: { from: 0, insert: sql }, selection: { anchor: 0, head: sql.length } });
|
||||
press(view, "Tab");
|
||||
expect(view.state.doc.toString()).toMatch(/^[ \t]+SELECT id\n[ \t]+FROM users;$/);
|
||||
expect(hasNextSnippetField(view.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("still accepts completion after typing into a snippet field", async () => {
|
||||
const view = await mountEditor();
|
||||
snippet("CREATE TABLE ${table} (${column} ${type});")(view, { label: "create table" }, 0, 0);
|
||||
view.dispatch(view.state.replaceSelection("demo"));
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
|
||||
press(view, "Tab");
|
||||
await vi.waitFor(() => expect(view.state.doc.toString()).toContain("candidate"));
|
||||
expect(selectedText(view)).not.toBe("column");
|
||||
});
|
||||
|
||||
it("dismisses completion through the real hidden search panel and keeps later fields", async () => {
|
||||
const view = await mountEditor();
|
||||
snippet("CREATE TABLE ${table} (${column} ${type});")(view, { label: "create table" }, 0, 0);
|
||||
view.dispatch(view.state.replaceSelection("demo"));
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
|
||||
|
||||
press(view, "Escape");
|
||||
expect(completionStatus(view.state)).toBeNull();
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("column");
|
||||
view.dispatch(view.state.replaceSelection("id"));
|
||||
press(view, "Tab");
|
||||
expect(selectedText(view)).toBe("type");
|
||||
expect(view.state.doc.toString()).toBe("CREATE TABLE demo (id type);");
|
||||
// CodeMirror ends the snippet session on entry into its final field.
|
||||
expect(hasNextSnippetField(view.state)).toBe(false);
|
||||
expect(hasPrevSnippetField(view.state)).toBe(false);
|
||||
});
|
||||
});
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import componentSource from "../QueryEditor.vue?raw";
|
||||
import ts from "typescript";
|
||||
import { indentMore } from "@codemirror/commands";
|
||||
import { autocompletion, nextSnippetField, closeCompletion, completionKeymap, completionStatus, hasNextSnippetField, snippet, startCompletion, type CompletionResult } from "@codemirror/autocomplete";
|
||||
import { EditorState, Prec } from "@codemirror/state";
|
||||
import { getSearchQuery, SearchQuery, setSearchQuery } from "@codemirror/search";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { createApp, h, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape";
|
||||
import EditorSearchPanel from "../EditorSearchPanel.vue";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { regexMaxMatchCount: 1000 } }) }));
|
||||
|
||||
const views: EditorView[] = [];
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
// Exercise the component's highest-priority Tab handler with real selections,
|
||||
// including typing into each field before advancing to the next one.
|
||||
const componentAst = ts.createSourceFile("QueryEditor.ts", componentSource.match(/^<script setup[^>]*>([\s\S]*?)<\/script>/m)![1], ts.ScriptTarget.Latest, true);
|
||||
const tabFunctions = new Set(["handleTab", "tabKeyAcceptsCompletion", "handleTabWithoutAcceptingCompletion", "performNormalTab", "editorIndentUnit", "acceptCompletionOrNextSnippetField"]);
|
||||
const tabSource = componentAst.statements
|
||||
.filter((statement) => ts.isFunctionDeclaration(statement) && statement.name && tabFunctions.has(statement.name.text))
|
||||
.map((statement) => statement.getText(componentAst))
|
||||
.join("\n");
|
||||
const handleTab = new Function(
|
||||
"codeMirrorCompletionStatus",
|
||||
"codeMirrorNextSnippetField",
|
||||
"codeMirrorIndentMore",
|
||||
"settingsStore",
|
||||
"normalizeShortcutSettings",
|
||||
"shortcutToCodeMirrorKey",
|
||||
"isEditorComposing",
|
||||
"isBatchColumnSelectionCompletionActive",
|
||||
ts.transpileModule(tabSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + "\nreturn handleTab;",
|
||||
)(
|
||||
completionStatus,
|
||||
nextSnippetField,
|
||||
indentMore,
|
||||
{ editorSettings: { sqlFormatter: { useTabs: false, tabWidth: 2 }, shortcuts: { acceptCompletion: "Tab" } } },
|
||||
(value: unknown) => value,
|
||||
(value: unknown) => value,
|
||||
() => false,
|
||||
() => false,
|
||||
) as (view: EditorView) => boolean;
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
for (const view of views.splice(0)) view.destroy();
|
||||
});
|
||||
|
||||
function createEditor(pending = false, searchOpen = false) {
|
||||
let resolveCompletion: ((result: CompletionResult) => void) | undefined;
|
||||
let searchPanel: { openSearch: () => boolean; closeSearch: () => boolean };
|
||||
const closeSearch = vi.fn(() => searchPanel.closeSearch());
|
||||
const clearBatchSelection = vi.fn();
|
||||
const cancelPendingAcceptance = vi.fn();
|
||||
const view = new EditorView({
|
||||
parent: document.createElement("div"),
|
||||
state: EditorState.create({
|
||||
extensions: [
|
||||
keymap.of(completionKeymap),
|
||||
autocompletion({
|
||||
defaultKeymap: false,
|
||||
activateOnTyping: false,
|
||||
override: [
|
||||
() =>
|
||||
pending
|
||||
? new Promise<CompletionResult>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
})
|
||||
: { from: 13, options: [{ label: "aaa" }] },
|
||||
],
|
||||
}),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{ key: "Tab", run: handleTab },
|
||||
{ key: "Escape", run: createQueryEditorEscapeHandler({ closeSearch, clearBatchSelection, cancelPendingAcceptance, closeCompletion }) },
|
||||
]),
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h(EditorSearchPanel, {
|
||||
view,
|
||||
ref: (instance) => {
|
||||
searchPanel = instance as unknown as typeof searchPanel;
|
||||
},
|
||||
}),
|
||||
});
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
snippet("CREATE TABLE ${table} (${column} ${type});")(view, { label: "create table" }, 0, 0);
|
||||
view.dispatch(view.state.replaceSelection("aa"));
|
||||
if (searchOpen) searchPanel!.openSearch();
|
||||
return { view, closeSearch, clearBatchSelection, cancelPendingAcceptance, isQueryRunning: () => !!resolveCompletion, resolveCompletion: () => resolveCompletion?.({ from: 13, options: [{ label: "aaa" }] }) };
|
||||
}
|
||||
|
||||
function press(view: EditorView, key: string) {
|
||||
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
view.contentDOM.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
function selectedText(view: EditorView) {
|
||||
return view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
|
||||
}
|
||||
|
||||
describe("QueryEditor Escape within snippet fields", () => {
|
||||
it.each([false, true])("does not mutate editor state when dismissing a hidden search panel (pending: %s)", async (pending) => {
|
||||
const { view, closeSearch, isQueryRunning } = createEditor(pending);
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe(pending ? "pending" : "active"));
|
||||
if (pending) await vi.waitFor(() => expect(isQueryRunning()).toBe(true));
|
||||
const before = view.state;
|
||||
const focus = vi.spyOn(view, "focus");
|
||||
|
||||
expect(closeSearch()).toBe(false);
|
||||
expect(view.state).toBe(before);
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
expect(completionStatus(view.state)).toBe(pending ? "pending" : "active");
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("dismisses completion and preserves both later Tab stops (pending: %s)", async (pending) => {
|
||||
const { view, clearBatchSelection, cancelPendingAcceptance, isQueryRunning, resolveCompletion } = createEditor(pending);
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe(pending ? "pending" : "active"));
|
||||
if (pending) await vi.waitFor(() => expect(isQueryRunning()).toBe(true));
|
||||
|
||||
expect(press(view, "Escape")).toBe(true);
|
||||
expect(completionStatus(view.state)).toBeNull();
|
||||
expect(clearBatchSelection).toHaveBeenCalledOnce();
|
||||
expect(cancelPendingAcceptance).toHaveBeenCalledOnce();
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
const doc = view.state.doc.toString();
|
||||
expect(press(view, "Tab")).toBe(true);
|
||||
expect(selectedText(view)).toBe("column");
|
||||
view.dispatch(view.state.replaceSelection("id"));
|
||||
expect(press(view, "Tab")).toBe(true);
|
||||
expect(selectedText(view)).toBe("type");
|
||||
expect(view.state.doc.toString()).toBe(doc.replace("column", "id"));
|
||||
|
||||
resolveCompletion();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(completionStatus(view.state)).toBeNull();
|
||||
expect(selectedText(view)).toBe("type");
|
||||
});
|
||||
|
||||
it("still exits the snippet when there is no completion", () => {
|
||||
const { view } = createEditor();
|
||||
expect(press(view, "Escape")).toBe(true);
|
||||
expect(hasNextSnippetField(view.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a second Escape to exit the snippet after closing completion", async () => {
|
||||
const { view } = createEditor();
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
|
||||
press(view, "Escape");
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
press(view, "Escape");
|
||||
expect(hasNextSnippetField(view.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves search dismissal priority", async () => {
|
||||
const { view, closeSearch } = createEditor(false, true);
|
||||
startCompletion(view);
|
||||
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
|
||||
expect(press(view, "Escape")).toBe(true);
|
||||
expect(closeSearch).toHaveBeenCalledOnce();
|
||||
expect(completionStatus(view.state)).toBeNull();
|
||||
expect(hasNextSnippetField(view.state)).toBe(true);
|
||||
});
|
||||
|
||||
it("still clears the query and collapses the selection when closing a visible search panel", () => {
|
||||
const { view, closeSearch } = createEditor(false, true);
|
||||
view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: "aa" })), selection: { anchor: 13, head: 15 } });
|
||||
expect(closeSearch()).toBe(true);
|
||||
expect(getSearchQuery(view.state).search).toBe("");
|
||||
expect(view.state.selection.main.empty).toBe(true);
|
||||
expect(view.state.selection.main.head).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -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