feat(editor): add goto line input with Ctrl+G and Cmd-Alt-G

This commit is contained in:
zipg authored and GitHub committed 2026-09-21 12:50:40 +08:00
1 parent 556c313880
commit f8ee34f937
24 files changed
+667 -2

No files matched your search

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowRightToLine } from "@lucide/vue";
import type { EditorView } from "@codemirror/view";
import { currentEditorLineNumber, resolveEditorGotoLine } from "@/lib/editor/editorGotoLine";
const props = defineProps<{
view: EditorView | null;
}>();
const emit = defineEmits<{
open: [];
close: [];
}>();
const { t } = useI18n();
const gotoLineVisible = ref(false);
const gotoLineInput = ref("");
const gotoLineError = ref("");
const gotoLineInputRef = ref<HTMLInputElement>();
function focusInput() {
void nextTick(() => {
const input = gotoLineInputRef.value;
if (!input) return;
input.focus();
input.select();
});
}
function openGotoLine(): boolean {
const view = props.view;
if (!view) return false;
gotoLineVisible.value = true;
gotoLineError.value = "";
// 预填当前行:直接回车即回到原处,符合 VS Code 的跳转框行为。
gotoLineInput.value = String(currentEditorLineNumber(view.state.doc, view.state.selection.main.head));
emit("open");
focusInput();
return true;
}
function closeGotoLine(): boolean {
if (!gotoLineVisible.value) return false;
gotoLineVisible.value = false;
gotoLineError.value = "";
emit("close");
props.view?.focus();
return true;
}
function gotoLine(): boolean {
const view = props.view;
if (!view) return false;
const resolution = resolveEditorGotoLine(gotoLineInput.value, view.state.doc);
if (!resolution.ok) {
gotoLineError.value = resolution.reason === "empty" ? t("editor.gotoLine.empty") : t("editor.gotoLine.outOfRange", { total: view.state.doc.lines });
gotoLineInputRef.value?.focus();
return false;
}
const line = view.state.doc.line(resolution.line);
view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true, userEvent: "select.gotoLine" });
closeGotoLine();
return true;
}
function onGotoLineKeydown(event: KeyboardEvent) {
// 输入法组合中的回车用于确认候选词,不能当作提交。
if (event.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
gotoLine();
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeGotoLine();
return;
}
if (gotoLineError.value) gotoLineError.value = "";
}
defineExpose({
openGotoLine,
closeGotoLine,
});
</script>
<template>
<Transition enter-active-class="transition-[transform,opacity] duration-150 will-change-[transform,opacity]" leave-active-class="transition-[transform,opacity] duration-100 will-change-[transform,opacity]" enter-from-class="opacity-0 -translate-y-1" leave-to-class="opacity-0 -translate-y-1">
<div v-if="gotoLineVisible" class="editor-goto-line-panel absolute right-4 top-3 z-[9999] isolate flex items-center gap-1 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-border/60">
<span class="flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground" :title="t('editor.gotoLine.placeholder')" aria-hidden="true">
<ArrowRightToLine class="h-4 w-4" />
</span>
<div class="flex h-8 w-40 items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring" :class="gotoLineError && 'border-destructive focus-within:border-destructive'">
<input
ref="gotoLineInputRef"
v-model="gotoLineInput"
inputmode="numeric"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-full min-w-0 flex-1 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
:placeholder="t('editor.gotoLine.placeholder')"
:aria-label="t('editor.gotoLine.placeholder')"
:aria-invalid="!!gotoLineError"
@keydown="onGotoLineKeydown"
/>
<span class="shrink-0 pr-2 text-xs text-muted-foreground">{{ t("editor.gotoLine.total", { total: view?.state.doc.lines ?? 0 }) }}</span>
</div>
<span v-if="gotoLineError" class="max-w-48 shrink-0 text-xs text-destructive" aria-live="polite">{{ gotoLineError }}</span>
</div>
</Transition>
</template>
@@ -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<InstanceType<typeof EditorSearchPanel>>();
const gotoLinePanelRef = ref<InstanceType<typeof EditorGotoLinePanel>>();
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({
/>
</CustomContextMenu>
<div v-show="queryEditorDropCaret" data-query-editor-drop-caret class="pointer-events-none absolute z-20 w-0.5 rounded-full bg-primary/70" :style="queryEditorDropCaretStyle" />
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<EditorSearchPanel ref="searchPanelRef" :view="view" @open="gotoLinePanelRef?.closeGotoLine()" />
<EditorGotoLinePanel ref="gotoLinePanelRef" :view="view" @open="searchPanelRef?.closeSearch()" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
@@ -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<typeof vi.fn>;
onClose: ReturnType<typeof vi.fn>;
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<HTMLElement>(".editor-goto-line-panel"),
input: () => {
const input = host.querySelector<HTMLInputElement>("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 <Transition> 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);
});
});
@@ -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('<EditorGotoLinePanel ref="gotoLinePanelRef" :view="view" @open="searchPanelRef?.closeSearch()" />');
expect(queryEditorSource).toContain('<EditorSearchPanel ref="searchPanelRef" :view="view" @open="gotoLinePanelRef?.closeGotoLine()" />');
});
});
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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: "補完を受け入れる",
+7
View File
@@ -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: "완성 수락",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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: "接受补全",
+7
View File
@@ -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: "接受補全",
@@ -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);
});
});
@@ -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);
@@ -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;
}
@@ -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<Record<ShortcutActionId, ReadonlySet<string>>> = {
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<Record<ShortcutActionId, ReadonlySet<s
addNextSelectionOccurrence: new Set(["Ctrl+G", "Alt+J"]),
selectAllSelectionOccurrences: new Set(["Ctrl+Mod+G", "Ctrl+Alt+Shift+J"]),
toggleAiPanel: new Set(["Ctrl+Mod+I", "Ctrl+Alt+I"]),
gotoLine: new Set(["Mod+G", "Mod+Alt+G"]),
};
const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W";
const LEGACY_COPY_CURRENT_ROW_DEFAULT = "Mod+D";
@@ -531,6 +541,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Mod+F",
},
{
id: "gotoLine",
labelKey: "settings.shortcutGotoLine",
scope: "editor",
defaultShortcut: gotoLineDefaultShortcut(),
},
{
id: "replace",
labelKey: "settings.shortcutReplace",
@@ -652,6 +668,7 @@ function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: st
if (definition.id === "addNextSelectionOccurrence") return selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", platform);
if (definition.id === "selectAllSelectionOccurrences") return selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", platform);
if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform);
if (definition.id === "gotoLine") return gotoLineDefaultShortcut(platform);
if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform);
if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform);
if (definition.id === "toggleAiPanel") return toggleAiPanelDefaultShortcut(platform);
@@ -698,7 +715,10 @@ export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>,
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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+15
View File
@@ -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);