diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 1911d8809..6efedd910 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -60,6 +60,7 @@ import { } from "@/lib/editor/queryEditorTableDrop"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; +import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; @@ -3680,6 +3681,11 @@ onMounted(async () => { buildResultSourceRangeExtension(), Prec.highest( keymap.of([ + ...createQueryEditorSearchKeymap({ + openSearch, + openReplace, + isReadOnly: () => !!props.readOnly, + }), { key: "'", run: handleSqlSingleQuote }, { key: "Tab", run: handleTab }, { @@ -4305,6 +4311,7 @@ function openSearch(): boolean { } function openReplace(): boolean { + if (props.readOnly) return false; return searchPanelRef.value?.openReplace() ?? false; } diff --git a/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts index 881437181..ae5630035 100644 --- a/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts @@ -1,10 +1,51 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; const editorSearchPanelSource = readFileSync(new URL("../EditorSearchPanel.vue", import.meta.url), "utf8"); +const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8"); +const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8"); describe("EditorSearchPanel corner style", () => { it("uses the configurable five-pixel radius token for editor inputs", () => { expect(editorSearchPanelSource).toContain("border-radius: var(--dbx-radius-fixed-5);"); }); }); + +describe("QueryEditor search shortcuts", () => { + it("opens search and replace in editable editors", () => { + const openSearch = vi.fn(() => true); + const openReplace = vi.fn(() => true); + const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => false }); + + expect(bindings.map(({ key, preventDefault }) => ({ key, preventDefault }))).toEqual([ + { key: "Mod-f", preventDefault: true }, + { key: "Mod-h", preventDefault: true }, + ]); + expect(bindings[0]?.run?.({} as never)).toBe(true); + expect(bindings[1]?.run?.({} as never)).toBe(true); + expect(openSearch).toHaveBeenCalledOnce(); + expect(openReplace).toHaveBeenCalledOnce(); + }); + + it("allows search but consumes replace without opening it in read-only editors", () => { + const openSearch = vi.fn(() => true); + const openReplace = vi.fn(() => true); + const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => true }); + + expect(bindings[0]?.run?.({} as never)).toBe(true); + expect(bindings[1]?.run?.({} as never)).toBe(true); + expect(openSearch).toHaveBeenCalledOnce(); + expect(openReplace).not.toHaveBeenCalled(); + }); + + it("keeps the existing search navigation and read-only replace guards", () => { + expect(queryEditorSource).toMatch(/Prec\.highest\(\s*keymap\.of\(\[\s*\.\.\.createQueryEditorSearchKeymap/); + expect(queryEditorSource).toMatch(/function openReplace\(\): boolean \{\s*if \(props\.readOnly\) return false;/); + expect(contentAreaSource).toContain('if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;'); + expect(contentAreaSource).toContain("return queryEditorRef.value?.openReplace() ?? false;"); + expect(queryEditorSource).toMatch(/key:\s*"Escape"/); + expect(editorSearchPanelSource).toContain('e.key === "Enter" && !e.shiftKey'); + expect(editorSearchPanelSource).toContain('e.key === "Enter" && e.shiftKey'); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts new file mode 100644 index 000000000..69c0d8da3 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts @@ -0,0 +1,24 @@ +// @vitest-environment happy-dom + +import { EditorState, Prec } from "@codemirror/state"; +import { EditorView, keymap, runScopeHandlers } from "@codemirror/view"; +import { describe, expect, it, vi } from "vitest"; +import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; + +describe("QueryEditor search keymap precedence", () => { + it("runs the custom search binding before lower-priority CodeMirror bindings", () => { + const openSearch = vi.fn(() => true); + const lowerPrioritySearch = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [keymap.of([{ key: "Mod-f", run: lowerPrioritySearch }]), Prec.highest(keymap.of(createQueryEditorSearchKeymap({ openSearch, openReplace: () => true, isReadOnly: () => false })))], + }), + }); + + expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "f", ctrlKey: true }), "editor")).toBe(true); + expect(openSearch).toHaveBeenCalledOnce(); + expect(lowerPrioritySearch).not.toHaveBeenCalled(); + view.destroy(); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts b/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts new file mode 100644 index 000000000..baad5e88b --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts @@ -0,0 +1,23 @@ +import type { KeyBinding } from "@codemirror/view"; + +interface QueryEditorSearchKeymapOptions { + openSearch: () => boolean; + openReplace: () => boolean; + isReadOnly: () => boolean; +} + +export function createQueryEditorSearchKeymap(options: QueryEditorSearchKeymapOptions): KeyBinding[] { + return [ + { + key: "Mod-f", + preventDefault: true, + run: options.openSearch, + }, + { + key: "Mod-h", + preventDefault: true, + // Consume the shortcut in previews without exposing mutation controls. + run: () => options.isReadOnly() || options.openReplace(), + }, + ]; +}