diff --git a/apps/desktop/src/components/editor/EditorSearchPanel.vue b/apps/desktop/src/components/editor/EditorSearchPanel.vue index 9ed4127775..2f5a2b625d 100644 --- a/apps/desktop/src/components/editor/EditorSearchPanel.vue +++ b/apps/desktop/src/components/editor/EditorSearchPanel.vue @@ -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(); diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 17f44d4cdf..58941ecf9c 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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, + }), }, ]), ), diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.integration.spec.ts new file mode 100644 index 0000000000..4af9bce152 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.integration.spec.ts @@ -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(); + return { + ...actual, + autocompletion: (config: Parameters[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); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.spec.ts new file mode 100644 index 0000000000..8190fe7550 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.snippetEscape.spec.ts @@ -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(/^