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

@@ -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,
}),
},
]),
),
@@ -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);
});
});
@@ -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);
});
});