diff --git a/apps/desktop/src/components/editor/EditorGotoLinePanel.vue b/apps/desktop/src/components/editor/EditorGotoLinePanel.vue new file mode 100644 index 0000000000..9ea8276a1e --- /dev/null +++ b/apps/desktop/src/components/editor/EditorGotoLinePanel.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index ee9b2a708c..69f2829257 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -23,6 +23,7 @@ import type { EditorState, Text } from "@codemirror/state"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { search as cmSearch } from "@codemirror/search"; import EditorSearchPanel from "./EditorSearchPanel.vue"; +import EditorGotoLinePanel from "./EditorGotoLinePanel.vue"; import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue"; import DelimitedListDialog from "./DelimitedListDialog.vue"; import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue"; @@ -525,6 +526,7 @@ const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize); const isGestureZooming = ref(false); const searchPanelRef = ref>(); +const gotoLinePanelRef = ref>(); const selectedSql = ref(""); const executableSql = ref(""); const previewContextSql = ref(""); @@ -2560,6 +2562,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") run: toggleSelectedBatchColumnSelection, }, ...binding(shortcuts.find, openSearch), + ...binding(shortcuts.gotoLine, openGotoLine), ...replaceShortcutBindings, ...executeInNewResultTabBindings, ...executeBindings, @@ -8248,6 +8251,10 @@ function openSearch(): boolean { return searchPanelRef.value?.openSearch() ?? false; } +function openGotoLine(): boolean { + return gotoLinePanelRef.value?.openGotoLine() ?? false; +} + function openReplace(): boolean { if (props.readOnly) return false; return searchPanelRef.value?.openReplace() ?? false; @@ -8335,7 +8342,8 @@ defineExpose({ />
- + + diff --git a/apps/desktop/src/components/editor/__tests__/EditorGotoLinePanel.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorGotoLinePanel.spec.ts new file mode 100644 index 0000000000..bd0298981c --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorGotoLinePanel.spec.ts @@ -0,0 +1,279 @@ +// @vitest-environment happy-dom + +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +import EditorGotoLinePanel from "@/components/editor/EditorGotoLinePanel.vue"; + +interface PanelHandle { + openGotoLine: () => boolean; + closeGotoLine: () => boolean; +} + +interface MountedPanel { + view: EditorView; + handle: () => PanelHandle; + onOpen: ReturnType; + onClose: ReturnType; + panel: () => HTMLElement | null; + input: () => HTMLInputElement; + errorText: () => string; +} + +const mounted: Array<{ app: App; host: HTMLElement; view?: EditorView }> = []; + +function mountPanel(docLines = 20): MountedPanel { + let instance: PanelHandle | null = null; + const onOpen = vi.fn(); + const onClose = vi.fn(); + // The editor has to be attached to the document: CodeMirror's focus() is a no-op + // on detached content (mirrors what QueryEditor.vue always renders). + const editorHost = document.createElement("div"); + document.body.append(editorHost); + const view = new EditorView({ + parent: editorHost, + state: EditorState.create({ doc: Array.from({ length: docLines }, (_, index) => `line ${index + 1}`).join("\n") }), + }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(EditorGotoLinePanel, { + view, + onOpen, + onClose, + ref: (el) => { + if (el) instance = el as unknown as PanelHandle; + }, + }); + }, + }), + ); + app.mount(host); + mounted.push({ app, host, view }); + return { + view, + handle: () => { + if (!instance) throw new Error("panel not mounted"); + return instance; + }, + onOpen, + onClose, + panel: () => host.querySelector(".editor-goto-line-panel"), + input: () => { + const input = host.querySelector("input"); + if (!input) throw new Error("goto-line input not rendered"); + return input; + }, + errorText: () => host.querySelector('[aria-live="polite"]')?.textContent?.trim() ?? "", + }; +} + +function setInputValue(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +/** Vue's keeps the leaving node in the DOM until the leave hook settles. */ +async function settleLeave(panel: () => HTMLElement | null, timeoutMs = 1000) { + const deadline = Date.now() + timeoutMs; + while (panel() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await nextTick(); + } +} + +function pressKey(input: HTMLInputElement, key: string) { + input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const { app, host, view } of mounted.splice(0)) { + app.unmount(); + host.remove(); + view?.dom.parentElement?.remove(); + view?.destroy(); + } +}); + +describe("EditorGotoLinePanel", () => { + it("stays closed until opened and prefills the caret line", async () => { + const { view, handle, onOpen, panel, input } = mountPanel(20); + view.dispatch({ selection: { anchor: view.state.doc.line(5).from + 3 } }); + + expect(panel()).toBeNull(); + + expect(handle().openGotoLine()).toBe(true); + await nextTick(); + + expect(panel()).not.toBeNull(); + expect(input().value).toBe("5"); + expect(document.activeElement).toBe(input()); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it("jumps to the typed line, scrolls it into view, and closes", async () => { + const { view, handle, onClose, panel, input } = mountPanel(20); + handle().openGotoLine(); + await nextTick(); + + setInputValue(input(), "12"); + await nextTick(); + pressKey(input(), "Enter"); + await settleLeave(panel); + + const target = view.state.doc.line(12); + expect(view.state.selection.main.head).toBe(target.from); + expect(view.state.selection.main.empty).toBe(true); + expect(panel()).toBeNull(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("keeps the caret in place and shows a hint for an out-of-range line", async () => { + const { view, handle, panel, input, errorText } = mountPanel(20); + const before = view.state.selection.main.head; + handle().openGotoLine(); + await nextTick(); + + setInputValue(input(), "999"); + await nextTick(); + pressKey(input(), "Enter"); + await nextTick(); + + expect(view.state.selection.main.head).toBe(before); + expect(panel()).not.toBeNull(); + expect(errorText()).toBe("editor.gotoLine.outOfRange"); + expect(document.activeElement).toBe(input()); + }); + + it("rejects non-numeric input, zero, and negative numbers", async () => { + const { view, handle, panel, input, errorText } = mountPanel(20); + const before = view.state.selection.main.head; + handle().openGotoLine(); + await nextTick(); + + for (const value of ["abc", "1.5", "1e3", "+5", "-5"]) { + setInputValue(input(), value); + await nextTick(); + pressKey(input(), "Enter"); + await nextTick(); + expect(view.state.selection.main.head).toBe(before); + expect(panel()).not.toBeNull(); + expect(errorText()).toBe("editor.gotoLine.outOfRange"); + } + }); + + it("asks for a line number when the field is cleared", async () => { + const { view, handle, panel, input, errorText } = mountPanel(20); + const before = view.state.selection.main.head; + handle().openGotoLine(); + await nextTick(); + + setInputValue(input(), " "); + await nextTick(); + pressKey(input(), "Enter"); + await nextTick(); + + expect(view.state.selection.main.head).toBe(before); + expect(panel()).not.toBeNull(); + expect(errorText()).toBe("editor.gotoLine.empty"); + }); + + it("clears a previous hint as soon as the input changes", async () => { + const { handle, input, errorText } = mountPanel(20); + handle().openGotoLine(); + await nextTick(); + + setInputValue(input(), "999"); + await nextTick(); + pressKey(input(), "Enter"); + await nextTick(); + expect(errorText()).toBe("editor.gotoLine.outOfRange"); + + pressKey(input(), "9"); + await nextTick(); + expect(errorText()).toBe(""); + }); + + it("cancels on Escape and returns focus to the editor", async () => { + const { view, handle, panel, onClose, onOpen, input } = mountPanel(20); + const before = view.state.selection.main.head; + handle().openGotoLine(); + await nextTick(); + + pressKey(input(), "Escape"); + await settleLeave(panel); + + expect(panel()).toBeNull(); + expect(view.state.selection.main.head).toBe(before); + expect(onClose).toHaveBeenCalledOnce(); + expect(onOpen).toHaveBeenCalledOnce(); + expect(view.dom.contains(document.activeElement)).toBe(true); + }); + + it("ignores the Enter that confirms an IME composition", async () => { + const { view, handle, panel, input } = mountPanel(20); + const before = view.state.selection.main.head; + handle().openGotoLine(); + await nextTick(); + + setInputValue(input(), "12"); + await nextTick(); + input().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, isComposing: true } as KeyboardEventInit)); + await nextTick(); + + expect(view.state.selection.main.head).toBe(before); + expect(panel()).not.toBeNull(); + }); + + it("reports failure instead of opening when there is no editor view", async () => { + const onOpen = vi.fn(); + let instance: PanelHandle | null = null; + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(EditorGotoLinePanel, { + view: null, + onOpen, + ref: (el) => { + if (el) instance = el as unknown as PanelHandle; + }, + }); + }, + }), + ); + app.mount(host); + mounted.push({ app, host }); + + expect((instance as unknown as PanelHandle).openGotoLine()).toBe(false); + expect(onOpen).not.toHaveBeenCalled(); + expect(host.querySelector(".editor-goto-line-panel")).toBeNull(); + }); + + it("closes a previously opened panel from the exposed handle", async () => { + const { handle, panel } = mountPanel(20); + handle().openGotoLine(); + await nextTick(); + expect(panel()).not.toBeNull(); + + expect(handle().closeGotoLine()).toBe(true); + await settleLeave(panel); + expect(panel()).toBeNull(); + expect(handle().closeGotoLine()).toBe(false); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorGotoLineWiring.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorGotoLineWiring.spec.ts new file mode 100644 index 0000000000..3aa8f3bea6 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorGotoLineWiring.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8"); + +describe("QueryEditor goto-line wiring", () => { + it("binds the platform goto-line shortcut next to find, before formatSql", () => { + const gotoLineIndex = queryEditorSource.indexOf("...binding(shortcuts.gotoLine, openGotoLine),"); + const findIndex = queryEditorSource.indexOf("...binding(shortcuts.find, openSearch),"); + const formatIndex = queryEditorSource.indexOf("...binding(shortcuts.formatSql,"); + + expect(findIndex).toBeGreaterThan(-1); + expect(gotoLineIndex).toBeGreaterThan(findIndex); + // 非 mac 上 Ctrl+G 同时是 CodeMirror 内置的 find-next,运行时的 Prec.high + // keymap 先匹配先执行,因此跳转绑定必须注册在搜索键位之前。 + expect(formatIndex).toBeGreaterThan(gotoLineIndex); + }); + + it("routes the panel toggles through the exposed handles", () => { + expect(queryEditorSource).toContain("function openGotoLine(): boolean {\n return gotoLinePanelRef.value?.openGotoLine() ?? false;\n}"); + expect(queryEditorSource).toContain(''); + expect(queryEditorSource).toContain(''); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 2ce174fd8d..260159f779 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1521,6 +1521,12 @@ export default withEnglishFallback({ confirm: "Təsdiqlə", selectFirst: "Siyahı mətnini seçin", }, + gotoLine: { + placeholder: "Sətir nömrəsi", + total: "{total} sətir", + outOfRange: "1–{total} aralığında sətir nömrəsi daxil edin", + empty: "Sətir nömrəsi daxil edin", + }, search: { find: "Tap", replace: "Əvəz et", @@ -7570,6 +7576,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "SQL-i icra et", shortcutExecuteSqlInNewResultTab: "SQL-i yeni nəticə vərəqində icra et", shortcutFind: "Tap", + shortcutGotoLine: "Sətirə keç", shortcutReplace: "Əvəz et", shortcutSaveSql: "SQL-i yadda saxla", shortcutAcceptCompletion: "Tamamlamanı qəbul et", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b7272372da..793000d6be 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1696,6 +1696,12 @@ export default { confirm: "Confirm", selectFirst: "Please select list text", }, + gotoLine: { + placeholder: "Line number", + total: "{total} lines", + outOfRange: "Enter a line number between 1 and {total}", + empty: "Enter a line number", + }, search: { find: "Find", replace: "Replace", @@ -7873,6 +7879,7 @@ export default { shortcutExecuteSql: "Execute SQL", shortcutExecuteSqlInNewResultTab: "Execute SQL in new result tab", shortcutFind: "Find", + shortcutGotoLine: "Go to Line", shortcutReplace: "Replace", shortcutSaveSql: "Save SQL", shortcutAcceptCompletion: "Accept completion", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 10a87c13e3..1c935de75d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1647,6 +1647,12 @@ export default withEnglishFallback({ pasteClipboardReadFailed: "Error al leer el portapapeles: {message}", convertNamingStyle: "Cambiar estilo de nomenclatura", }, + gotoLine: { + placeholder: "Número de línea", + total: "{total} líneas", + outOfRange: "Introduce un número de línea entre 1 y {total}", + empty: "Introduce un número de línea", + }, search: { find: "Buscar", replace: "Reemplazar", @@ -7365,6 +7371,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "Ejecutar SQL", shortcutExecuteSqlInNewResultTab: "Ejecutar SQL en una nueva pestaña de resultados", shortcutFind: "Buscar", + shortcutGotoLine: "Ir a la línea", shortcutReplace: "Reemplazar", shortcutSaveSql: "Guardar SQL", shortcutAcceptCompletion: "Aceptar completado", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d78a05b62f..7af605187a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1644,6 +1644,12 @@ export default withEnglishFallback({ pasteClipboardReadFailed: "Impossibile leggere gli appunti: {message}", convertNamingStyle: "Cambia stile di denominazione", }, + gotoLine: { + placeholder: "Numero di riga", + total: "{total} righe", + outOfRange: "Inserisci un numero di riga tra 1 e {total}", + empty: "Inserisci un numero di riga", + }, search: { find: "Trova", replace: "Sostituisci", @@ -7364,6 +7370,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "Esegui SQL", shortcutExecuteSqlInNewResultTab: "Esegui SQL in una nuova scheda dei risultati", shortcutFind: "Trova", + shortcutGotoLine: "Vai alla riga", shortcutReplace: "Sostituisci", shortcutSaveSql: "Salva SQL", shortcutAcceptCompletion: "Accetta completamento", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3ca4f5dc38..d04567dea0 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1664,6 +1664,12 @@ export default withEnglishFallback({ pasteClipboardReadFailed: "クリップボードの読み取りに失敗しました:{message}", convertNamingStyle: "命名スタイルの切り替え", }, + gotoLine: { + placeholder: "行番号", + total: "全 {total} 行", + outOfRange: "1〜{total} の行番号を入力してください", + empty: "行番号を入力してください", + }, search: { find: "検索", replace: "置換", @@ -7369,6 +7375,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "SQLを実行", shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行", shortcutFind: "検索", + shortcutGotoLine: "指定行へ移動", shortcutReplace: "置換", shortcutSaveSql: "SQLを保存", shortcutAcceptCompletion: "補完を受け入れる", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3b8f229f35..4b188e611f 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1577,6 +1577,12 @@ export default withEnglishFallback({ confirm: "확인", selectFirst: "목록 텍스트를 먼저 선택하세요", }, + gotoLine: { + placeholder: "줄 번호", + total: "총 {total}행", + outOfRange: "1~{total} 사이의 줄 번호를 입력하세요", + empty: "줄 번호를 입력하세요", + }, search: { find: "찾기", replace: "바꾸기", @@ -7147,6 +7153,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "SQL 실행", shortcutExecuteSqlInNewResultTab: "새 결과 탭에서 SQL 실행", shortcutFind: "찾기", + shortcutGotoLine: "줄로 이동", shortcutReplace: "바꾸기", shortcutSaveSql: "SQL 저장", shortcutAcceptCompletion: "완성 수락", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 10b6b02b7c..2c2fc9c982 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1645,6 +1645,12 @@ export default withEnglishFallback({ pasteClipboardReadFailed: "Falha ao ler a área de transferência: {message}", convertNamingStyle: "Alternar estilo de nomenclatura", }, + gotoLine: { + placeholder: "Número da linha", + total: "{total} linhas", + outOfRange: "Informe um número de linha entre 1 e {total}", + empty: "Informe um número de linha", + }, search: { find: "Localizar", replace: "Substituir", @@ -7366,6 +7372,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "Executar SQL", shortcutExecuteSqlInNewResultTab: "Executar SQL em uma nova aba de resultados", shortcutFind: "Localizar", + shortcutGotoLine: "Ir para a linha", shortcutReplace: "Substituir", shortcutSaveSql: "Salvar SQL", shortcutAcceptCompletion: "Aceitar autocompletar", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 5e1740cb0b..5eee239f8c 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1522,6 +1522,12 @@ export default withEnglishFallback({ confirm: "Onayla", selectFirst: "Lütfen liste metnini seçin", }, + gotoLine: { + placeholder: "Satır numarası", + total: "{total} satır", + outOfRange: "1 ile {total} arasında bir satır numarası girin", + empty: "Bir satır numarası girin", + }, search: { find: "Bul", replace: "Değiştir", @@ -7469,6 +7475,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "SQL'i çalıştır", shortcutExecuteSqlInNewResultTab: "SQL'i yeni sonuç sekmesinde çalıştır", shortcutFind: "Bul", + shortcutGotoLine: "Satıra git", shortcutReplace: "Değiştir", shortcutSaveSql: "SQL'i Kaydet", shortcutAcceptCompletion: "Tamamlamayı kabul et", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 227aef95c8..637bab0bdf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1610,6 +1610,12 @@ export default withEnglishFallback({ confirm: "确定", selectFirst: "请选择列表文本", }, + gotoLine: { + placeholder: "行号", + total: "共 {total} 行", + outOfRange: "请输入 1–{total} 之间的行号", + empty: "请输入行号", + }, search: { find: "查找", replace: "替换", @@ -7843,6 +7849,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "执行 SQL", shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL", shortcutFind: "查找", + shortcutGotoLine: "跳转到行", shortcutReplace: "替换", shortcutSaveSql: "保存 SQL", shortcutAcceptCompletion: "接受补全", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c0e7c4dcc7..b9024a4c4c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1646,6 +1646,12 @@ export default withEnglishFallback({ pasteClipboardReadFailed: "讀取剪貼簿失敗:{message}", convertNamingStyle: "切換命名風格", }, + gotoLine: { + placeholder: "行號", + total: "共 {total} 行", + outOfRange: "請輸入 1–{total} 之間的行號", + empty: "請輸入行號", + }, search: { find: "尋找", replace: "取代", @@ -6666,6 +6672,7 @@ export default withEnglishFallback({ shortcutExecuteSql: "執行 SQL", shortcutExecuteSqlInNewResultTab: "在新結果分頁中執行 SQL", shortcutFind: "尋找", + shortcutGotoLine: "跳至行", shortcutReplace: "取代", shortcutSaveSql: "儲存 SQL", shortcutAcceptCompletion: "接受補全", diff --git a/apps/desktop/src/lib/__tests__/editor/editorGotoLine.spec.ts b/apps/desktop/src/lib/__tests__/editor/editorGotoLine.spec.ts new file mode 100644 index 0000000000..940c7ece6d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/editorGotoLine.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { currentEditorLineNumber, resolveEditorGotoLine, type EditorGotoLineDocument } from "@/lib/editor/editorGotoLine"; + +/** 用固定行宽(10 字符/行)的桩对象代替 CodeMirror 的 Text。 */ +function makeDoc(lines: number): EditorGotoLineDocument & { lineAt(position: number): { number: number } } { + return { + lines, + line: (number: number) => ({ number, from: (number - 1) * 10 }), + lineAt: (position: number) => ({ number: Math.floor(position / 10) + 1 }), + }; +} + +describe("resolveEditorGotoLine", () => { + it("解析合法行号并给出该行起始偏移", () => { + const doc = makeDoc(150); + expect(resolveEditorGotoLine("1", doc)).toEqual({ ok: true, line: 1, from: 0 }); + expect(resolveEditorGotoLine("42", doc)).toEqual({ ok: true, line: 42, from: 410 }); + expect(resolveEditorGotoLine("150", doc)).toEqual({ ok: true, line: 150, from: 1490 }); + }); + + it("忽略首尾空白", () => { + expect(resolveEditorGotoLine(" 7 ", makeDoc(10))).toEqual({ ok: true, line: 7, from: 60 }); + }); + + it("空输入按 empty 处理", () => { + expect(resolveEditorGotoLine("", makeDoc(10))).toEqual({ ok: false, reason: "empty" }); + expect(resolveEditorGotoLine(" ", makeDoc(10))).toEqual({ ok: false, reason: "empty" }); + }); + + it("非十进制数字按 invalid 处理", () => { + for (const value of ["abc", "1.5", "1e3", "+1", "-1", "12", "1 2", "0x10"]) { + expect(resolveEditorGotoLine(value, makeDoc(10))).toEqual({ ok: false, reason: "invalid" }); + } + }); + + it("越界行号按 out-of-range 处理", () => { + const doc = makeDoc(10); + expect(resolveEditorGotoLine("0", doc)).toEqual({ ok: false, reason: "out-of-range" }); + expect(resolveEditorGotoLine("11", doc)).toEqual({ ok: false, reason: "out-of-range" }); + expect(resolveEditorGotoLine("999999999999999999999", doc)).toEqual({ ok: false, reason: "out-of-range" }); + }); + + it("单行文档只接受第 1 行", () => { + const doc = makeDoc(1); + expect(resolveEditorGotoLine("1", doc)).toEqual({ ok: true, line: 1, from: 0 }); + expect(resolveEditorGotoLine("2", doc)).toEqual({ ok: false, reason: "out-of-range" }); + }); +}); + +describe("currentEditorLineNumber", () => { + it("返回光标所在行号", () => { + const doc = makeDoc(20); + expect(currentEditorLineNumber(doc, 0)).toBe(1); + expect(currentEditorLineNumber(doc, 95)).toBe(10); + expect(currentEditorLineNumber(doc, 199)).toBe(20); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 7268eee747..bb1b3262f2 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -6,6 +6,7 @@ import { findCrossScopeShortcutConflicts, findShortcutConflict, formatShortcut, + gotoLineDefaultShortcut, isReservedShortcut, MACOS_RESERVED_SHORTCUTS, normalizeModifierOnlyShortcut, @@ -483,6 +484,48 @@ describe("shortcutRegistry editor actions", () => { }); }); + describe("gotoLine", () => { + it("resolves the platform default and maps it to the CodeMirror key", () => { + // macOS 的 ⌃G 已分配给「选中下一个相同词」(JetBrains 风格),沿用 + // CodeMirror 搜索键位里原本就指向 gotoLine 的 ⌘⌥G;Windows/Linux 用 + // VS Code / SSMS 惯例的 Ctrl+G。 + expect(gotoLineDefaultShortcut("MacIntel")).toBe("Mod+Alt+G"); + expect(gotoLineDefaultShortcut("Win32")).toBe("Mod+G"); + expect(gotoLineDefaultShortcut("Linux x86_64")).toBe("Mod+G"); + expect(shortcutToCodeMirrorKey(gotoLineDefaultShortcut("MacIntel"))).toBe("Mod-Alt-g"); + expect(shortcutToCodeMirrorKey(gotoLineDefaultShortcut("Win32"))).toBe("Mod-g"); + }); + + it("registers an editor-scope definition with the settings label", () => { + expect(SHORTCUT_DEFINITIONS.find((item) => item.id === "gotoLine")).toMatchObject({ + id: "gotoLine", + labelKey: "settings.shortcutGotoLine", + scope: "editor", + }); + expect(DEFAULT_SHORTCUT_SETTINGS.gotoLine).toBe(gotoLineDefaultShortcut()); + }); + + it("re-resolves a cloud-synced default from the other platform", () => { + // 云同步会把另一平台的默认值当成显式配置带过来,解析后必须回到本机平台默认。 + expect(normalizeShortcutSettings({ gotoLine: "Mod+Alt+G" }, "Win32").gotoLine).toBe("Mod+G"); + expect(normalizeShortcutSettings({ gotoLine: "Mod+G" }, "MacIntel").gotoLine).toBe("Mod+Alt+G"); + // 用户真正自定义的组合原样保留,清空仍然表示「不绑定」。 + expect(normalizeShortcutSettings({ gotoLine: "Alt+Mod+Shift+L" }, "Win32").gotoLine).toBe("Alt+Mod+Shift+L"); + expect(normalizeShortcutSettings({ gotoLine: "" }, "Win32").gotoLine).toBe(""); + }); + + it("never collides with the same-scope selection-occurrence shortcuts", () => { + // 非 mac 的 Mod+G 展开后是 Ctrl+G,恰好等于 mac 上 + // addNextSelectionOccurrence 的默认键;两者分属不同平台,不能互相判为冲突。 + for (const platform of ["MacIntel", "Win32", "Linux x86_64"]) { + const shortcuts = normalizeShortcutSettings(undefined, platform); + expect(shortcuts.gotoLine).toBe(gotoLineDefaultShortcut(platform)); + expect(findShortcutConflict("gotoLine", shortcuts.gotoLine, shortcuts, platform)).toBeNull(); + expect(findShortcutConflict("addNextSelectionOccurrence", shortcuts.addNextSelectionOccurrence, shortcuts, platform)).toBeNull(); + } + }); + }); + describe("countShortcutConflictPairs", () => { it("counts an unordered pair once even though the map is bidirectional", () => { expect(countShortcutConflictPairs({ find: "focusSearch", focusSearch: "find" })).toBe(1); diff --git a/apps/desktop/src/lib/editor/editorGotoLine.ts b/apps/desktop/src/lib/editor/editorGotoLine.ts new file mode 100644 index 0000000000..c5c1109daa --- /dev/null +++ b/apps/desktop/src/lib/editor/editorGotoLine.ts @@ -0,0 +1,33 @@ +/** + * 「跳转到行」输入解析。 + * + * 只做两件事:把用户在跳转框里输入的内容解析成行号,并给出该行在文档中的起始 + * 偏移。与 CodeMirror 解耦(只依赖 `lines` / `line()` 这两个成员),这样既能直 + * 接接收 `Text`,也能在单测里用最简桩对象覆盖边界情况。 + */ + +export interface EditorGotoLineDocument { + readonly lines: number; + line(number: number): { readonly number: number; readonly from: number }; +} + +export type EditorGotoLineFailure = "empty" | "invalid" | "out-of-range"; + +export type EditorGotoLineResolution = { ok: true; line: number; from: number } | { ok: false; reason: EditorGotoLineFailure }; + +/** 只接受十进制数字:`+1`、`1.5`、`1e3`、全角数字等一律视为无效输入。 */ +const DECIMAL_DIGITS = /^\d+$/; + +export function resolveEditorGotoLine(input: string, doc: EditorGotoLineDocument): EditorGotoLineResolution { + const trimmed = input.trim(); + if (!trimmed) return { ok: false, reason: "empty" }; + if (!DECIMAL_DIGITS.test(trimmed)) return { ok: false, reason: "invalid" }; + const line = Number.parseInt(trimmed, 10); + if (!Number.isSafeInteger(line) || line < 1 || line > doc.lines) return { ok: false, reason: "out-of-range" }; + return { ok: true, line, from: doc.line(line).from }; +} + +/** 跳转框的预填值:当前光标所在行(VS Code 同样的行为,直接回车即回到原处)。 */ +export function currentEditorLineNumber(doc: EditorGotoLineDocument & { lineAt(position: number): { readonly number: number } }, position: number): number { + return doc.lineAt(position).number; +} diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index cea1ec8112..4538654d8a 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -66,6 +66,7 @@ export type ShortcutActionId = | "zoomOutUi" | "resetUiZoom" | "find" + | "gotoLine" | "replace" | "refreshData" | "toggleResultsPane" @@ -127,6 +128,14 @@ export function toggleAiPanelDefaultShortcut(platform = globalThis.navigator?.pl return isMacShortcutPlatform(platform) ? "Ctrl+Mod+I" : "Ctrl+Alt+I"; } +// 「跳转到行」的平台默认键。Windows/Linux 上用 Ctrl+G,与 VS Code / SSMS / +// DataGrip 的惯例一致(编辑器内查找下一个仍有 F3 与搜索面板回车); +// macOS 的 Ctrl+G 已分配给「选中下一个相同词」(JetBrains 风格),因此沿用 +// CodeMirror 搜索键位里原本就指向 gotoLine 的 ⌘⌥G。 +export function gotoLineDefaultShortcut(platform = globalThis.navigator?.platform || ""): string { + return isMacShortcutPlatform(platform) ? "Mod+Alt+G" : "Mod+G"; +} + const PLATFORM_DEFAULT_SHORTCUTS: Partial>> = { closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]), navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]), @@ -134,6 +143,7 @@ const PLATFORM_DEFAULT_SHORTCUTS: Partial, normalized.copyCurrentRow = LEGACY_COPY_CURRENT_ROW_DEFAULT; } - for (const actionId of TAB_NAVIGATION_HISTORY_ACTIONS) { + // gotoLine 的平台默认键(Win/Linux 为 Ctrl+G)在该动作引入前是其他动作可合法 + // 显式绑定的自由键位;无显式配置的默认值与同作用域显式绑定同键时同样让位, + // 否则运行时 keymap 先注册的 gotoLine 会遮蔽用户的显式配置。 + for (const actionId of [...TAB_NAVIGATION_HISTORY_ACTIONS, "gotoLine"] as ShortcutActionId[]) { if (hasExplicitShortcut(settings, actionId)) continue; const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) continue; diff --git a/docs/screenshots/issue-9678-mac-cmd-alt-g-panel.png b/docs/screenshots/issue-9678-mac-cmd-alt-g-panel.png new file mode 100644 index 0000000000..3054ebd5cc Binary files /dev/null and b/docs/screenshots/issue-9678-mac-cmd-alt-g-panel.png differ diff --git a/docs/screenshots/issue-9678-win-ctrl-g-before-no-panel.png b/docs/screenshots/issue-9678-win-ctrl-g-before-no-panel.png new file mode 100644 index 0000000000..845c255bfa Binary files /dev/null and b/docs/screenshots/issue-9678-win-ctrl-g-before-no-panel.png differ diff --git a/docs/screenshots/issue-9678-win-ctrl-g-jumped-line-18.png b/docs/screenshots/issue-9678-win-ctrl-g-jumped-line-18.png new file mode 100644 index 0000000000..b18abf0b4f Binary files /dev/null and b/docs/screenshots/issue-9678-win-ctrl-g-jumped-line-18.png differ diff --git a/docs/screenshots/issue-9678-win-ctrl-g-panel.png b/docs/screenshots/issue-9678-win-ctrl-g-panel.png new file mode 100644 index 0000000000..0e453da635 Binary files /dev/null and b/docs/screenshots/issue-9678-win-ctrl-g-panel.png differ diff --git a/docs/screenshots/issue-9678-win-invalid-line-hint.png b/docs/screenshots/issue-9678-win-invalid-line-hint.png new file mode 100644 index 0000000000..f615e1c1c1 Binary files /dev/null and b/docs/screenshots/issue-9678-win-invalid-line-hint.png differ diff --git a/packages/app-tests/keyboardShortcuts.test.ts b/packages/app-tests/keyboardShortcuts.test.ts index 9253b78204..f04dcc92ce 100644 --- a/packages/app-tests/keyboardShortcuts.test.ts +++ b/packages/app-tests/keyboardShortcuts.test.ts @@ -217,6 +217,21 @@ test("preserves explicitly configured tab history shortcuts during migration", ( assert.equal(normalized.switchToPreviousTab, backShortcut); }); +test("clears the gotoLine default when an explicit editor binding already owns its key", () => { + // Ctrl+G was a legal explicit binding slot before gotoLine adopted it on + // Windows/Linux; the user's explicit choice must keep winning over the new + // default (which registers earlier in the runtime keymap). + const windows = normalizeShortcutSettings({ formatSql: "Ctrl+G" }, "Win32"); + assert.equal(windows.gotoLine, ""); + assert.equal(windows.formatSql, "Ctrl+G"); + + // macOS defaults (⌘⌥G) never collide, and an explicit gotoLine config wins. + const mac = normalizeShortcutSettings({ formatSql: "Ctrl+G" }, "MacIntel"); + assert.equal(mac.gotoLine, "Mod+Alt+G"); + const explicit = normalizeShortcutSettings({ gotoLine: "Ctrl+G" }, "Win32"); + assert.equal(explicit.gotoLine, "Ctrl+G"); +}); + test("matches Mod+number for switching to numbered tabs", () => { assert.equal(switchToTabIndexFromShortcut({ key: "1", metaKey: true }), 0); assert.equal(switchToTabIndexFromShortcut({ key: "5", ctrlKey: true }), 4);