feat(editor): add goto line input with Ctrl+G and Cmd-Alt-G
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
+24
@@ -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()" />');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "補完を受け入れる",
|
||||
|
||||
@@ -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: "완성 수락",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "接受补全",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user