diff --git a/src/App.vue b/src/App.vue index eb082c89e3..26da7da5a9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -450,7 +450,9 @@ function onAiRequestAutoExecuteSql(sql: string) { } function handleKeydown(e: KeyboardEvent) { - if (isFocusSearchShortcut(e)) { + const shortcuts = settingsStore.editorSettings.shortcuts; + + if (isFocusSearchShortcut(e, shortcuts)) { const focused = contentAreaRef.value?.focusSearch() || appSidebarRef.value?.focusSearch(); if (focused) { e.preventDefault(); @@ -458,7 +460,7 @@ function handleKeydown(e: KeyboardEvent) { } return; } - if (isCloseTabShortcut(e)) { + if (isCloseTabShortcut(e, shortcuts)) { e.preventDefault(); if (showDriverStore.value) { showDriverStore.value = false; @@ -467,10 +469,10 @@ function handleKeydown(e: KeyboardEvent) { } return; } - if (isSaveShortcut(e) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) { + if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) { return; } - if (activeTab.value?.mode === "query" && !showSaveSqlDialog.value && isSaveShortcut(e)) { + if (activeTab.value?.mode === "query" && !showSaveSqlDialog.value && isSaveShortcut(e, shortcuts)) { e.preventDefault(); e.stopPropagation(); void openSaveSqlDialog(); @@ -478,7 +480,7 @@ function handleKeydown(e: KeyboardEvent) { } if ( activeTab.value?.mode === "query" && - isExecuteSqlShortcut(e) && + isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]") ) { diff --git a/src/components/editor/EditorSettingsDialog.vue b/src/components/editor/EditorSettingsDialog.vue index 123ac072c6..925180a9ce 100644 --- a/src/components/editor/EditorSettingsDialog.vue +++ b/src/components/editor/EditorSettingsDialog.vue @@ -22,6 +22,14 @@ import { import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { aiTestConnection } from "@/lib/api"; +import { eventToShortcut } from "@/lib/keyboardShortcuts"; +import { + SHORTCUT_DEFINITIONS, + findShortcutConflict, + formatShortcut, + normalizeShortcutSettings, + type ShortcutActionId, +} from "@/lib/shortcutRegistry"; const { t } = useI18n(); const settingsStore = useSettingsStore(); @@ -44,6 +52,7 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize); +const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts)); const redisScanPageSizeOptions = [200, 1000, 5000, 10000]; // Sync from store when dialog opens @@ -58,10 +67,19 @@ watch( editWordWrap.value = settingsStore.editorSettings.wordWrap; editAppLayout.value = settingsStore.editorSettings.appLayout; editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize; + editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); } }, ); +const shortcutConflicts = computed(() => + SHORTCUT_DEFINITIONS.flatMap((definition) => { + const conflict = findShortcutConflict(definition.id, editShortcuts.value[definition.id], editShortcuts.value); + return conflict ? [definition.id] : []; + }), +); +const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0); + function hasChanges(): boolean { return ( editFontFamily.value !== settingsStore.editorSettings.fontFamily || @@ -70,11 +88,13 @@ function hasChanges(): boolean { editExecuteMode.value !== settingsStore.editorSettings.executeMode || editWordWrap.value !== settingsStore.editorSettings.wordWrap || editAppLayout.value !== settingsStore.editorSettings.appLayout || - editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize + editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize || + JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) ); } function applySettings() { + if (hasShortcutConflicts.value) return; settingsStore.updateEditorSettings({ fontFamily: editFontFamily.value, fontSize: editFontSize.value, @@ -83,6 +103,7 @@ function applySettings() { wordWrap: editWordWrap.value, appLayout: editAppLayout.value, redisScanPageSize: editRedisScanPageSize.value, + shortcuts: editShortcuts.value, }); emit("update:open", false); } @@ -95,6 +116,7 @@ function resetDefaults() { editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize; + editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts); } function onExecuteModeChange(v: any) { @@ -114,6 +136,27 @@ function onRedisScanPageSizeChange(v: any) { if (redisScanPageSizeOptions.includes(value)) editRedisScanPageSize.value = value; } +function onShortcutChange(actionId: ShortcutActionId, value: any) { + if (typeof value !== "string") return; + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + if (!definition) return; + editShortcuts.value = { ...editShortcuts.value, [actionId]: value }; +} + +function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + const shortcut = eventToShortcut(event); + if (!shortcut) return; + onShortcutChange(actionId, shortcut); +} + +function resetShortcut(actionId: ShortcutActionId) { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + if (!definition) return; + editShortcuts.value = { ...editShortcuts.value, [actionId]: definition.defaultShortcut }; +} + function setAppLayout(value: "separated" | "classic") { editAppLayout.value = value; } @@ -121,11 +164,12 @@ function setAppLayout(value: "separated" | "classic") { const activeSettingsTab = ref("editor"); const isWeb = !isTauriRuntime(); const displayedAppVersion = computed(() => (props.appVersion ? `v${props.appVersion}` : "")); -type SettingsCategory = "editor" | "appearance" | "redis" | "ai" | "security" | "about"; +type SettingsCategory = "editor" | "appearance" | "redis" | "shortcuts" | "ai" | "security" | "about"; const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [ { value: "editor", label: t("settings.editorTab") }, { value: "appearance", label: t("settings.appearanceTab") }, { value: "redis", label: t("settings.redisTab") }, + { value: "shortcuts", label: t("settings.shortcutsTab") }, { value: "ai", label: t("settings.aiTab") }, ...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []), { value: "about", label: t("settings.aboutTab") }, @@ -535,7 +579,7 @@ watch( {{ t("common.close") }} - + {{ t("settings.apply") }} @@ -580,7 +624,7 @@ watch( {{ t("common.close") }} - + {{ t("settings.apply") }} @@ -610,7 +654,55 @@ watch( {{ t("common.close") }} - + + {{ t("settings.apply") }} + + + + + + + + + {{ t(definition.labelKey) }} + + {{ t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`) }} + + + + + onShortcutKeydown(definition.id, event)" + /> + + {{ t("settings.reset") }} + + + + {{ t("settings.shortcutConflict") }} + + + + + + + + {{ t("settings.resetDefaults") }} + + + + {{ t("common.close") }} + + {{ t("settings.apply") }} diff --git a/src/components/editor/QueryEditor.vue b/src/components/editor/QueryEditor.vue index b06b0df65b..fd129b5db4 100644 --- a/src/components/editor/QueryEditor.vue +++ b/src/components/editor/QueryEditor.vue @@ -13,6 +13,7 @@ import { } from "@/lib/sqlCompletion"; import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; +import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; import type { SqlCompletionColumn } from "@/lib/sqlCompletion"; const props = defineProps<{ @@ -50,6 +51,7 @@ let fontThemeComp: import("@codemirror/state").Compartment | null = null; let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; let readOnlyComp: import("@codemirror/state").Compartment | null = null; +let runKeymapComp: import("@codemirror/state").Compartment | null = null; // Completion cache let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = []; @@ -87,6 +89,54 @@ function resetZoom() { setFontSize(13); } +function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) { + const shortcuts = settingsStore.editorSettings.shortcuts; + return codeMirrorKeymap.of([ + { + key: "Mod-=", + run: () => { + zoomIn(); + return true; + }, + }, + { + key: "Mod-+", + run: () => { + zoomIn(); + return true; + }, + }, + { + key: "Mod--", + run: () => { + zoomOut(); + return true; + }, + }, + { + key: "Mod-0", + run: () => { + resetZoom(); + return true; + }, + }, + { + key: shortcutToCodeMirrorKey(shortcuts.executeSql), + run: () => { + if (view.value) emit("execute", executableSqlFromView(view.value)); + return true; + }, + }, + { + key: shortcutToCodeMirrorKey(shortcuts.saveSql), + run: () => { + emit("save"); + return true; + }, + }, + ]); +} + function wordWrapExtension() { if (!editorViewModule) return []; return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : []; @@ -283,6 +333,7 @@ onMounted(async () => { codeMirrorTheme = new Compartment(); wordWrapComp = new Compartment(); readOnlyComp = new Compartment(); + runKeymapComp = new Compartment(); const ss = settingsStore.editorSettings; @@ -294,51 +345,6 @@ onMounted(async () => { keywords: (baseDialect.spec.keywords || "") + " " + extraKeywords, }); - const runKeymap = keymap.of([ - { - key: "Mod-=", - run: () => { - zoomIn(); - return true; - }, - }, - { - key: "Mod-+", - run: () => { - zoomIn(); - return true; - }, - }, - { - key: "Mod--", - run: () => { - zoomOut(); - return true; - }, - }, - { - key: "Mod-0", - run: () => { - resetZoom(); - return true; - }, - }, - { - key: "Mod-Enter", - run: () => { - if (view.value) emit("execute", executableSqlFromView(view.value)); - return true; - }, - }, - { - key: "Mod-s", - run: () => { - emit("save"); - return true; - }, - }, - ]); - const theme = await loadEditorTheme(ss.theme); const state = EditorState.create({ @@ -356,7 +362,7 @@ onMounted(async () => { closeBrackets(), bracketMatching(), Prec.highest(keymap.of([...closeBracketsKeymap, indentWithTab])), - runKeymap, + runKeymapComp.of(runKeymapExtension(keymap)), wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []), readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]), rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }), @@ -575,12 +581,15 @@ watch( watch( () => settingsStore.editorSettings, async (ss) => { - if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !editorViewModule) return; + if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !runKeymapComp || !editorViewModule) { + return; + } const themeExt = await loadEditorTheme(ss.theme); view.value.dispatch({ effects: [ codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), + runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), fontThemeComp.reconfigure( editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily, { fixedHeight: true, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c37b5507ab..58c3ad4b59 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1006,6 +1006,7 @@ export default { editorTab: "Editor", appearanceTab: "Appearance", redisTab: "Redis", + shortcutsTab: "Shortcuts", aiTab: "AI", jdbcTab: "JDBC Drivers", securityTab: "Security", @@ -1021,6 +1022,7 @@ export default { appLayoutClassic: "Classic compact", appLayoutClassicDescription: "Continuous tab bar with compact panel headers.", apply: "Apply", + reset: "Reset", resetDefaults: "Reset Defaults", executeMode: "Execute Mode (Cmd+Enter)", executeModeAll: "Execute all SQL", @@ -1030,6 +1032,16 @@ export default { redisScanPageSize: "Redis scan count", redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.", redisScanPageSizeOption: "{count} keys", + shortcutExecuteSql: "Execute SQL", + shortcutSaveSql: "Save SQL", + shortcutCloseTab: "Close tab", + shortcutFocusSearch: "Focus search", + shortcutCancelSearch: "Cancel search", + shortcutScopeGlobal: "Global", + shortcutScopeEditor: "SQL editor", + shortcutScopeSearch: "Search fields", + shortcutPressShortcut: "Press shortcut", + shortcutConflict: "This shortcut conflicts with another action in the same scope.", preview: "Live Preview", jdbcPlugin: "DBX JDBC plugin", jdbcPluginInstall: "Install JDBC plugin", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 2b1e59f0fe..ba995821b9 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -984,6 +984,7 @@ export default { editorTab: "Editor", appearanceTab: "Apariencia", redisTab: "Redis", + shortcutsTab: "Atajos", aiTab: "IA", jdbcTab: "Drivers JDBC", securityTab: "Seguridad", @@ -999,6 +1000,7 @@ export default { appLayoutClassic: "Clásico compacto", appLayoutClassicDescription: "Barra de pestañas continua con encabezados de panel compactos.", apply: "Aplicar", + reset: "Restablecer", resetDefaults: "Restablecer valores por defecto", executeMode: "Modo de ejecución (Cmd+Enter)", executeModeAll: "Ejecutar todo el SQL", @@ -1008,6 +1010,16 @@ export default { redisScanPageSize: "Cantidad de escaneo Redis", redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.", redisScanPageSizeOption: "{count} claves", + shortcutExecuteSql: "Ejecutar SQL", + shortcutSaveSql: "Guardar SQL", + shortcutCloseTab: "Cerrar pestaña", + shortcutFocusSearch: "Enfocar búsqueda", + shortcutCancelSearch: "Cancelar búsqueda", + shortcutScopeGlobal: "Global", + shortcutScopeEditor: "Editor SQL", + shortcutScopeSearch: "Campos de búsqueda", + shortcutPressShortcut: "Presiona un atajo", + shortcutConflict: "Este atajo entra en conflicto con otra acción del mismo ámbito.", preview: "Vista previa en tiempo real", jdbcPlugin: "Plugin JDBC de DBX", jdbcPluginInstall: "Instalar plugin JDBC", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 25e92b819c..1e595ebc85 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -988,6 +988,7 @@ export default { editorTab: "编辑器", appearanceTab: "外观", redisTab: "Redis", + shortcutsTab: "快捷键", aiTab: "AI", jdbcTab: "JDBC 驱动", securityTab: "安全", @@ -1003,6 +1004,7 @@ export default { appLayoutClassic: "经典紧凑", appLayoutClassicDescription: "使用连续标签栏和更紧凑的面板标题。", apply: "应用", + reset: "重置", resetDefaults: "恢复默认", executeMode: "执行模式 (Cmd+Enter)", executeModeAll: "执行全部 SQL", @@ -1012,6 +1014,16 @@ export default { redisScanPageSize: "Redis 扫描数量", redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。", redisScanPageSizeOption: "{count} 个 Key", + shortcutExecuteSql: "执行 SQL", + shortcutSaveSql: "保存 SQL", + shortcutCloseTab: "关闭标签页", + shortcutFocusSearch: "聚焦搜索", + shortcutCancelSearch: "取消搜索", + shortcutScopeGlobal: "全局", + shortcutScopeEditor: "SQL 编辑器", + shortcutScopeSearch: "搜索框", + shortcutPressShortcut: "按下快捷键", + shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。", preview: "实时预览", jdbcPlugin: "DBX JDBC 插件", jdbcPluginInstall: "安装 JDBC 插件", diff --git a/src/lib/keyboardShortcuts.ts b/src/lib/keyboardShortcuts.ts index 427b0ba41f..486cd87574 100644 --- a/src/lib/keyboardShortcuts.ts +++ b/src/lib/keyboardShortcuts.ts @@ -1,36 +1,87 @@ +import { + DEFAULT_SHORTCUT_SETTINGS, + normalizeShortcutSettings, + type ShortcutActionId, + type ShortcutSettings, +} from "@/lib/shortcutRegistry"; + export interface ShortcutLikeEvent { key: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; + shiftKey?: boolean; isComposing?: boolean; } -export function isExecuteSqlShortcut(event: ShortcutLikeEvent): boolean { - if (event.isComposing) return false; - if (event.altKey) return false; - if (!event.metaKey && !event.ctrlKey) return false; - return event.key === "Enter"; +function normalizeKey(key: string): string { + if (key === " ") return "Space"; + return key.length === 1 ? key.toLowerCase() : key; } -export function isCloseTabShortcut(event: ShortcutLikeEvent): boolean { - if (event.isComposing) return false; - if (!event.metaKey) return false; - return event.key.toLowerCase() === "w"; +function shortcutKeyName(key: string): string | null { + if (key === " ") return "Space"; + if (["Control", "Meta", "Shift", "Alt"].includes(key)) return null; + if (key.length === 1) return key.toUpperCase(); + return key; } -export function isFocusSearchShortcut(event: ShortcutLikeEvent): boolean { - if (event.isComposing) return false; - if (event.altKey) return false; - if (!event.metaKey && !event.ctrlKey) return false; - return event.key.toLowerCase() === "f"; +export function eventToShortcut(event: ShortcutLikeEvent): string | null { + if (event.isComposing) return null; + + const key = shortcutKeyName(event.key); + if (!key) return null; + + const hasModifier = !!event.metaKey || !!event.ctrlKey || !!event.altKey || !!event.shiftKey; + if (!hasModifier && key.length === 1) return null; + + const parts: string[] = []; + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey || event.ctrlKey) parts.push("Mod"); + if (event.altKey) parts.push("Alt"); + parts.push(key); + return parts.join("+"); } -export function isSaveShortcut(event: ShortcutLikeEvent): boolean { +export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean { if (event.isComposing) return false; - if (event.altKey) return false; - if (!event.metaKey && !event.ctrlKey) return false; - return event.key.toLowerCase() === "s"; + const parts = shortcut.split("+"); + const key = parts[parts.length - 1] ?? ""; + const modifiers = new Set(parts.slice(0, -1)); + const usesMod = modifiers.has("Mod"); + const usesMeta = modifiers.has("Meta"); + const usesCtrl = modifiers.has("Ctrl"); + + if (usesMod) { + if (!event.metaKey && !event.ctrlKey) return false; + } else { + if (!!event.metaKey !== usesMeta) return false; + if (!!event.ctrlKey !== usesCtrl) return false; + } + + if (!!event.altKey !== modifiers.has("Alt")) return false; + if (!!event.shiftKey !== modifiers.has("Shift")) return false; + return normalizeKey(event.key) === normalizeKey(key); +} + +function actionShortcut(actionId: ShortcutActionId, shortcuts?: Partial): string { + return normalizeShortcutSettings(shortcuts)[actionId] || DEFAULT_SHORTCUT_SETTINGS[actionId]; +} + +export function isExecuteSqlShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("executeSql", shortcuts)); +} + +export function isCloseTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("closeTab", shortcuts)); +} + +export function isFocusSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("focusSearch", shortcuts)); +} + +export function isSaveShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("saveSql", shortcuts)); } export function isObjectSourceSaveShortcutTarget( @@ -39,7 +90,6 @@ export function isObjectSourceSaveShortcutTarget( return !!target?.closest("[data-object-source-editor], [data-object-source-preview]"); } -export function isCancelSearchShortcut(event: ShortcutLikeEvent): boolean { - if (event.isComposing) return false; - return event.key === "Escape"; +export function isCancelSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("cancelSearch", shortcuts)); } diff --git a/src/lib/shortcutRegistry.ts b/src/lib/shortcutRegistry.ts new file mode 100644 index 0000000000..ff1fea9c4f --- /dev/null +++ b/src/lib/shortcutRegistry.ts @@ -0,0 +1,93 @@ +export type ShortcutActionId = "executeSql" | "saveSql" | "closeTab" | "focusSearch" | "cancelSearch"; + +export type ShortcutScope = "global" | "editor" | "search"; + +export interface ShortcutDefinition { + id: ShortcutActionId; + labelKey: string; + scope: ShortcutScope; + defaultShortcut: string; +} + +export type ShortcutSettings = Record; + +export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ + { + id: "executeSql", + labelKey: "settings.shortcutExecuteSql", + scope: "editor", + defaultShortcut: "Mod+Enter", + }, + { + id: "saveSql", + labelKey: "settings.shortcutSaveSql", + scope: "editor", + defaultShortcut: "Mod+S", + }, + { + id: "closeTab", + labelKey: "settings.shortcutCloseTab", + scope: "global", + defaultShortcut: "Meta+W", + }, + { + id: "focusSearch", + labelKey: "settings.shortcutFocusSearch", + scope: "global", + defaultShortcut: "Mod+F", + }, + { + id: "cancelSearch", + labelKey: "settings.shortcutCancelSearch", + scope: "search", + defaultShortcut: "Escape", + }, +]; + +export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries( + SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut]), +) as ShortcutSettings; + +export function normalizeShortcutSettings(settings?: Partial): ShortcutSettings { + return Object.fromEntries( + SHORTCUT_DEFINITIONS.map((definition) => [ + definition.id, + typeof settings?.[definition.id] === "string" && settings[definition.id]?.trim() + ? settings[definition.id] + : definition.defaultShortcut, + ]), + ) as ShortcutSettings; +} + +export function shortcutToCodeMirrorKey(shortcut: string): string { + return shortcut + .split("+") + .map((part) => (part.length === 1 ? part.toLowerCase() : part)) + .join("-"); +} + +export function formatShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): string { + const isMac = platform.toLowerCase().includes("mac"); + return shortcut + .split("+") + .map((part) => { + if (part === "Mod") return isMac ? "Cmd" : "Ctrl"; + if (part === "Meta") return isMac ? "Cmd" : "Meta"; + return part; + }) + .join("+"); +} + +export function findShortcutConflict( + actionId: ShortcutActionId, + shortcut: string, + shortcuts: ShortcutSettings, +): ShortcutActionId | null { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + if (!definition) return null; + + const conflict = SHORTCUT_DEFINITIONS.find( + (item) => item.id !== actionId && item.scope === definition.scope && shortcuts[item.id] === shortcut, + ); + return conflict?.id ?? null; +} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index f583d017ae..1513c744e4 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import * as api from "@/lib/api"; +import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/shortcutRegistry"; export type AiProvider = "claude" | "openai" | "custom"; export type AiApiStyle = "completions" | "responses"; @@ -52,6 +53,7 @@ export interface EditorSettings { appLayout: "separated" | "classic"; pageSize: number; redisScanPageSize: number; + shortcuts: ShortcutSettings; } export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [ @@ -85,6 +87,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { appLayout: "classic", pageSize: 100, redisScanPageSize: 1000, + shortcuts: normalizeShortcutSettings(), }; export const STORAGE_KEY = "dbx-editor-settings"; @@ -100,6 +103,7 @@ export function normalizeEditorSettings(settings: Partial): Edit appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout, pageSize: settings.pageSize ?? DEFAULT_EDITOR_SETTINGS.pageSize, redisScanPageSize: settings.redisScanPageSize ?? DEFAULT_EDITOR_SETTINGS.redisScanPageSize, + shortcuts: normalizeShortcutSettings(settings.shortcuts), }; } diff --git a/tests/keyboardShortcuts.test.ts b/tests/keyboardShortcuts.test.ts index 6efc393886..e22b02d79e 100644 --- a/tests/keyboardShortcuts.test.ts +++ b/tests/keyboardShortcuts.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; import test from "node:test"; import { + eventToShortcut, isCancelSearchShortcut, isCloseTabShortcut, isExecuteSqlShortcut, @@ -8,11 +9,36 @@ import { isObjectSourceSaveShortcutTarget, isSaveShortcut, } from "../src/lib/keyboardShortcuts.ts"; +import { shortcutToCodeMirrorKey } from "../src/lib/shortcutRegistry.ts"; test("matches Cmd+Enter for SQL execution", () => { assert.equal(isExecuteSqlShortcut({ key: "Enter", metaKey: true }), true); }); +test("matches custom shortcut settings for SQL execution", () => { + assert.equal( + isExecuteSqlShortcut({ key: "Enter", metaKey: true }, { executeSql: "Shift+Mod+Enter" } as any), + false, + ); + assert.equal( + isExecuteSqlShortcut({ key: "Enter", metaKey: true, shiftKey: true } as any, { + executeSql: "Shift+Mod+Enter", + } as any), + true, + ); +}); + +test("records custom shortcuts from keydown events", () => { + assert.equal(eventToShortcut({ key: "r", metaKey: true, shiftKey: true } as any), "Shift+Mod+R"); + assert.equal(eventToShortcut({ key: "F2" } as any), "F2"); + assert.equal(eventToShortcut({ key: "Control", ctrlKey: true } as any), null); +}); + +test("converts custom shortcuts to CodeMirror key names", () => { + assert.equal(shortcutToCodeMirrorKey("Shift+Mod+R"), "Shift-Mod-r"); + assert.equal(shortcutToCodeMirrorKey("Mod+/"), "Mod-/"); +}); + test("matches Ctrl+Enter for SQL execution", () => { assert.equal(isExecuteSqlShortcut({ key: "Enter", ctrlKey: true }), true); }); diff --git a/tests/settingsDialogLayout.test.ts b/tests/settingsDialogLayout.test.ts index 26b57149e0..ee169e8b39 100644 --- a/tests/settingsDialogLayout.test.ts +++ b/tests/settingsDialogLayout.test.ts @@ -25,3 +25,15 @@ test("settings action footer stays at the bottom of the content pane", () => { assert.match(source, /class="[^"]*min-h-full[^"]*flex-col[^"]*"/); assert.match(source, / { + assert.match(source, /value: "shortcuts"/); + assert.match(source, /activeSettingsTab === ['"]shortcuts['"]/); + assert.match(source, /SHORTCUT_DEFINITIONS/); +}); + +test("shortcut settings capture custom keydown input instead of fixed select options", () => { + assert.match(source, /onShortcutKeydown/); + assert.match(source, /@keydown="\(event: KeyboardEvent\) => onShortcutKeydown/); + assert.doesNotMatch(source, /definition\.options/); +}); diff --git a/tests/settingsStore.test.ts b/tests/settingsStore.test.ts index 7ac9be551a..4ad81595b5 100644 --- a/tests/settingsStore.test.ts +++ b/tests/settingsStore.test.ts @@ -10,3 +10,18 @@ test("defaults Redis scan page size to 1000 keys", () => { test("keeps a saved Redis scan page size", () => { assert.equal(normalizeEditorSettings({ redisScanPageSize: 5000 }).redisScanPageSize, 5000); }); + +test("defaults shortcut settings", () => { + const settings = normalizeEditorSettings({}); + + assert.equal(settings.shortcuts.executeSql, "Mod+Enter"); + assert.equal(settings.shortcuts.saveSql, "Mod+S"); + assert.equal(settings.shortcuts.focusSearch, "Mod+F"); +}); + +test("keeps saved shortcut overrides", () => { + const settings = normalizeEditorSettings({ shortcuts: { executeSql: "Shift+Mod+Enter" } as any }); + + assert.equal(settings.shortcuts.executeSql, "Shift+Mod+Enter"); + assert.equal(settings.shortcuts.saveSql, "Mod+S"); +});
+ {{ t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`) }} +
+ {{ t("settings.shortcutConflict") }} +