diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index acc0f4d518..a8f3a1a165 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -29,6 +29,7 @@ import { } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { setupBadgesSubscription } from "./badge"; +import { reregisterGlobalKeys } from "./keymodel"; import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -65,6 +66,7 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { handler: (event) => { // console.log("config wave event handler", event); globalStore.set(atoms.fullConfigAtom, event.data.fullconfig); + reregisterGlobalKeys(); }, }); waveEventSubscribeSingle({ diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..d89168f062 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -500,274 +500,224 @@ function countTermBlocks(): number { return count; } -function registerGlobalKeys() { - globalKeyMap.set("Cmd:]", () => { - switchTab(1); - return true; - }); - globalKeyMap.set("Shift:Cmd:]", () => { - switchTab(1); - return true; - }); - globalKeyMap.set("Cmd:[", () => { - switchTab(-1); - return true; - }); - globalKeyMap.set("Shift:Cmd:[", () => { - switchTab(-1); - return true; - }); - globalKeyMap.set("Cmd:n", () => { - handleCmdN(); - return true; - }); - globalKeyMap.set("Cmd:d", () => { - handleSplitHorizontal("after"); - return true; - }); - globalKeyMap.set("Shift:Cmd:d", () => { - handleSplitVertical("after"); - return true; - }); - globalKeyMap.set("Cmd:i", () => { - handleCmdI(); - return true; - }); - globalKeyMap.set("Cmd:t", () => { - createTab(); - return true; - }); - globalKeyMap.set("Cmd:w", () => { - genericClose(); - return true; - }); - globalKeyMap.set("Cmd:Shift:w", () => { - simpleCloseStaticTab(); - return true; - }); - globalKeyMap.set("Cmd:m", () => { - const layoutModel = getLayoutModelForStaticTab(); - const focusedNode = globalStore.get(layoutModel.focusedNode); - if (focusedNode != null) { - const ephemeralNode = globalStore.get(layoutModel.ephemeralNode); - if (ephemeralNode?.id === focusedNode.id) { - layoutModel.addEphemeralNodeToLayout(); - } else { - layoutModel.magnifyNodeToggle(focusedNode.id); - } - } - return true; - }); - globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Up); - return true; - }); - globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Down); - return true; - }); - globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Left); - return true; - }); - globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Right); - return true; - }); - // Vim-style aliases for block focus navigation. - globalKeyMap.set("Ctrl:Shift:h", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Left); - return true; - }); - globalKeyMap.set("Ctrl:Shift:j", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Down); - return true; - }); - globalKeyMap.set("Ctrl:Shift:k", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; +const DirectionMap: Record = { + up: NavigateDirection.Up, + down: NavigateDirection.Down, + left: NavigateDirection.Left, + right: NavigateDirection.Right, +}; + +function activateSearch(event: WaveKeyboardEvent): boolean { + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); + if (event.control && bcm.viewModel.viewType == "term") { + return false; + } + if (bcm.viewModel.searchAtoms) { + if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { + const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number; + globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1); + } else { + globalStore.set(bcm.viewModel.searchAtoms.isOpen, true); } - switchBlockInDirection(NavigateDirection.Up); return true; - }); - globalKeyMap.set("Ctrl:Shift:l", () => { - const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); - if (disableCtrlShiftArrows) { - return false; - } - switchBlockInDirection(NavigateDirection.Right); + } + return false; +} + +function deactivateSearch(): boolean { + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); + if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { + globalStore.set(bcm.viewModel.searchAtoms.isOpen, false); return true; - }); - globalKeyMap.set("Ctrl:Shift:x", () => { - const blockId = getFocusedBlockId(); - if (blockId == null) { + } + return false; +} + +type CommandHandler = (commandStr?: string) => KeyHandler; + +function getCommandHandlers(): Record { + return { + "tab:next": () => () => { + switchTab(1); return true; - } - replaceBlock( - blockId, - { - meta: { - view: "launcher", - }, - }, - true - ); - return true; - }); - globalKeyMap.set("F2", () => { - const tabModel = getActiveTabModel(); - if (tabModel?.startRenameCallback != null) { - tabModel.startRenameCallback(); + }, + "tab:prev": () => () => { + switchTab(-1); return true; - } - return false; - }); - globalKeyMap.set("Cmd:g", () => { - const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); - if (bcm.openSwitchConnection != null) { - recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" }); - bcm.openSwitchConnection(); + }, + "tab:new": () => () => { + createTab(); return true; - } - }); - globalKeyMap.set("Ctrl:Shift:i", () => { - const tabModel = getActiveTabModel(); - if (tabModel == null) { + }, + "tab:close": () => () => { + simpleCloseStaticTab(); return true; - } - const curMI = globalStore.get(tabModel.isTermMultiInput); - if (!curMI && countTermBlocks() <= 1) { - // don't turn on multi-input unless there are 2 or more basic term blocks + }, + "tab:switch-num": (commandStr) => () => { + switchTabAbs(parseInt(commandStr)); return true; - } - globalStore.set(tabModel.isTermMultiInput, !curMI); - return true; - }); - for (let idx = 1; idx <= 9; idx++) { - globalKeyMap.set(`Cmd:${idx}`, () => { - switchTabAbs(idx); + }, + "block:new": () => () => { + handleCmdN(); return true; - }); - globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => { - switchBlockByBlockNum(idx); + }, + "block:close": () => () => { + genericClose(); return true; - }); - globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => { - switchBlockByBlockNum(idx); + }, + "block:split-right": () => () => { + handleSplitHorizontal("after"); return true; - }); - } - if (isWindows()) { - globalKeyMap.set("Alt:c{Digit0}", () => { - WaveAIModel.getInstance().focusInput(); + }, + "block:split-down": () => () => { + handleSplitVertical("after"); return true; - }); - globalKeyMap.set("Alt:c{Numpad0}", () => { - WaveAIModel.getInstance().focusInput(); + }, + "block:magnify": () => () => { + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode != null) { + const ephemeralNode = globalStore.get(layoutModel.ephemeralNode); + if (ephemeralNode?.id === focusedNode.id) { + layoutModel.addEphemeralNodeToLayout(); + } else { + layoutModel.magnifyNodeToggle(focusedNode.id); + } + } return true; - }); - } else { - globalKeyMap.set("Ctrl:Shift:c{Digit0}", () => { - WaveAIModel.getInstance().focusInput(); + }, + "block:refocus": () => () => { + handleCmdI(); return true; - }); - globalKeyMap.set("Ctrl:Shift:c{Numpad0}", () => { - WaveAIModel.getInstance().focusInput(); + }, + "block:focus": (commandStr) => () => { + const direction = DirectionMap[commandStr]; + if (direction == null) { + return false; + } + switchBlockInDirection(direction); return true; - }); - } - function activateSearch(event: WaveKeyboardEvent): boolean { - const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); - // Ctrl+f is reserved in most shells - if (event.control && bcm.viewModel.viewType == "term") { + }, + "block:focus-num": (commandStr) => () => { + switchBlockByBlockNum(parseInt(commandStr)); + return true; + }, + "block:replace-launcher": () => () => { + const blockId = getFocusedBlockId(); + if (blockId == null) { + return true; + } + replaceBlock(blockId, { meta: { view: "launcher" } }, true); + return true; + }, + "block:rename": () => () => { + const tabModel = getActiveTabModel(); + if (tabModel?.startRenameCallback != null) { + tabModel.startRenameCallback(); + return true; + } return false; - } - if (bcm.viewModel.searchAtoms) { - if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { - // Already open — increment the focusInput counter so this block's - // SearchComponent focuses its own input (avoids a global DOM query - // that could target the wrong block when multiple searches are open). - const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number; - globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1); - } else { - globalStore.set(bcm.viewModel.searchAtoms.isOpen, true); + }, + "block:connection": () => () => { + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); + if (bcm.openSwitchConnection != null) { + recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" }); + bcm.openSwitchConnection(); + return true; + } + return false; + }, + "block:search": () => activateSearch, + "generic:escape": () => () => { + if (modalsModel.hasOpenModals()) { + modalsModel.popModal(); + return true; + } + if (deactivateSearch()) { + return true; } + return false; + }, + "ai:toggle": () => () => { + const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); + WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); return true; - } - return false; - } - function deactivateSearch(): boolean { - const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); - if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { - globalStore.set(bcm.viewModel.searchAtoms.isOpen, false); + }, + "ai:focus": () => () => { + WaveAIModel.getInstance().focusInput(); return true; - } - return false; - } - globalKeyMap.set("Cmd:f", activateSearch); - globalKeyMap.set("Escape", () => { - if (modalsModel.hasOpenModals()) { - modalsModel.popModal(); + }, + "ai:focus-windows": () => () => { + WaveAIModel.getInstance().focusInput(); return true; - } - if (deactivateSearch()) { + }, + "term:multi-input": () => () => { + const tabModel = getActiveTabModel(); + if (tabModel == null) { + return true; + } + const curMI = globalStore.get(tabModel.isTermMultiInput); + if (!curMI && countTermBlocks() <= 1) { + return true; + } + globalStore.set(tabModel.isTermMultiInput, !curMI); return true; + }, + "block:split-chord": (commandStr) => () => { + const direction = commandStr; + if (direction === "up") { + handleSplitVertical("before"); + } else if (direction === "down") { + handleSplitVertical("after"); + } else if (direction === "left") { + handleSplitHorizontal("before"); + } else if (direction === "right") { + handleSplitHorizontal("after"); + } + return true; + }, + }; +} + +function registerGlobalKeys() { + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const keybindings: KeybindingConfigType[] = fullConfig?.keybindings ?? []; + const commandHandlers = getCommandHandlers(); + + for (const kb of keybindings) { + const handlerFactory = commandHandlers[kb.command]; + if (handlerFactory == null) { + continue; + } + if (isWindows() && kb.command === "ai:focus") { + continue; + } + if (!isWindows() && kb.command === "ai:focus-windows") { + continue; + } + const handler = handlerFactory(kb.commandstr); + for (const keyStr of kb.keys) { + if (keyStr.includes(" ")) { + const [chordKey, secondKey] = keyStr.split(" ", 2); + if (!globalChordMap.has(chordKey)) { + globalChordMap.set(chordKey, new Map()); + } + globalChordMap.get(chordKey).set(secondKey, handler); + } else { + globalKeyMap.set(keyStr, handler); + } } - return false; - }); - globalKeyMap.set("Cmd:Shift:a", () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); - return true; - }); + } + const allKeys = Array.from(globalKeyMap.keys()); - // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); getApi().registerGlobalWebviewKeys(allKeys); +} - const splitBlockKeys = new Map(); - splitBlockKeys.set("ArrowUp", () => { - handleSplitVertical("before"); - return true; - }); - splitBlockKeys.set("ArrowDown", () => { - handleSplitVertical("after"); - return true; - }); - splitBlockKeys.set("ArrowLeft", () => { - handleSplitHorizontal("before"); - return true; - }); - splitBlockKeys.set("ArrowRight", () => { - handleSplitHorizontal("after"); - return true; - }); - globalChordMap.set("Ctrl:Shift:s", splitBlockKeys); +function reregisterGlobalKeys() { + globalKeyMap.clear(); + globalChordMap.clear(); + registerGlobalKeys(); } function registerBuilderGlobalKeys() { @@ -795,6 +745,7 @@ export { registerControlShiftStateUpdateHandler, registerElectronReinjectKeyHandler, registerGlobalKeys, + reregisterGlobalKeys, tryReinjectKey, unsetControlShift, uxCloseBlock, diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index 73703c2e87..51cfaae2f0 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -96,6 +96,13 @@ function makeConfigFiles(isWindows: boolean): ConfigFile[] { docsUrl: "https://docs.waveterm.dev/tab-backgrounds", hasJsonView: true, }, + { + name: "Keybindings", + path: "keybindings.json", + language: "json", + description: "Custom keyboard shortcuts", + hasJsonView: true, + }, { name: "Secrets", path: "secrets", @@ -359,7 +366,8 @@ export class WaveConfigViewModel implements ViewModel { try { const parsed = JSON.parse(fileContent); - if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { + const isArray = Array.isArray(parsed); + if (typeof parsed !== "object" || parsed == null || (isArray && selectedFile.path !== "keybindings.json")) { globalStore.set(this.validationErrorAtom, "JSON must be an object, not an array, primitive, or null"); return; } diff --git a/frontend/preview/mock/defaultconfig.ts b/frontend/preview/mock/defaultconfig.ts index 415630b2b6..3a5ff9bc77 100644 --- a/frontend/preview/mock/defaultconfig.ts +++ b/frontend/preview/mock/defaultconfig.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import backgroundsJson from "../../../pkg/wconfig/defaultconfig/backgrounds.json"; +import keybindingsJson from "../../../pkg/wconfig/defaultconfig/keybindings.json"; import mimetypesJson from "../../../pkg/wconfig/defaultconfig/mimetypes.json"; import presetsJson from "../../../pkg/wconfig/defaultconfig/presets.json"; import settingsJson from "../../../pkg/wconfig/defaultconfig/settings.json"; @@ -20,5 +21,8 @@ export const DefaultFullConfig: FullConfigType = { bookmarks: {}, waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType }, backgrounds: backgroundsJson as { [key: string]: BackgroundConfigType }, + keybindings: keybindingsJson as unknown as KeybindingConfigType[], configerrors: [], + version: "", + buildtime: "", }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..fe2dc1635b 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1019,6 +1019,7 @@ declare global { connections: {[key: string]: ConnKeywords}; bookmarks: {[key: string]: WebBookmark}; waveai: {[key: string]: AIModeConfigType}; + keybindings: KeybindingConfigType[]; configerrors: ConfigError[]; version: string; buildtime: string; @@ -1057,6 +1058,13 @@ declare global { jobmanagerstatus: string; }; + // wconfig.KeybindingConfigType + type KeybindingConfigType = { + command: string; + keys: string[]; + commandstr?: string; + }; + // waveobj.LayoutActionData type LayoutActionData = { actiontype: string; diff --git a/frontend/wave.ts b/frontend/wave.ts index 20ee2ba97a..11fac503a8 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -187,13 +187,13 @@ async function initWave(initOpts: WaveInitOpts) { console.error("Failed initialization error", e); getApi().sendLog("Error in initialization (wave.ts, loading required objects) " + e.message + "\n" + e.stack); } - registerGlobalKeys(); registerElectronReinjectKeyHandler(); registerControlShiftStateUpdateHandler(); await loadMonaco(); const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); + registerGlobalKeys(); const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); diff --git a/pkg/wconfig/defaultconfig/keybindings.json b/pkg/wconfig/defaultconfig/keybindings.json new file mode 100644 index 0000000000..c41ca249bf --- /dev/null +++ b/pkg/wconfig/defaultconfig/keybindings.json @@ -0,0 +1,47 @@ +[ + { "command": "tab:next", "keys": ["Cmd:]", "Shift:Cmd:]"] }, + { "command": "tab:prev", "keys": ["Cmd:[", "Shift:Cmd:["] }, + { "command": "tab:new", "keys": ["Cmd:t"] }, + { "command": "tab:close", "keys": ["Cmd:Shift:w"] }, + { "command": "tab:switch-num", "keys": ["Cmd:1"], "commandstr": "1" }, + { "command": "tab:switch-num", "keys": ["Cmd:2"], "commandstr": "2" }, + { "command": "tab:switch-num", "keys": ["Cmd:3"], "commandstr": "3" }, + { "command": "tab:switch-num", "keys": ["Cmd:4"], "commandstr": "4" }, + { "command": "tab:switch-num", "keys": ["Cmd:5"], "commandstr": "5" }, + { "command": "tab:switch-num", "keys": ["Cmd:6"], "commandstr": "6" }, + { "command": "tab:switch-num", "keys": ["Cmd:7"], "commandstr": "7" }, + { "command": "tab:switch-num", "keys": ["Cmd:8"], "commandstr": "8" }, + { "command": "tab:switch-num", "keys": ["Cmd:9"], "commandstr": "9" }, + { "command": "block:new", "keys": ["Cmd:n"] }, + { "command": "block:close", "keys": ["Cmd:w"] }, + { "command": "block:split-right", "keys": ["Cmd:d"] }, + { "command": "block:split-down", "keys": ["Shift:Cmd:d"] }, + { "command": "block:magnify", "keys": ["Cmd:m"] }, + { "command": "block:refocus", "keys": ["Cmd:i"] }, + { "command": "block:focus", "keys": ["Ctrl:Shift:ArrowUp", "Ctrl:Shift:k"], "commandstr": "up" }, + { "command": "block:focus", "keys": ["Ctrl:Shift:ArrowDown", "Ctrl:Shift:j"], "commandstr": "down" }, + { "command": "block:focus", "keys": ["Ctrl:Shift:ArrowLeft", "Ctrl:Shift:h"], "commandstr": "left" }, + { "command": "block:focus", "keys": ["Ctrl:Shift:ArrowRight", "Ctrl:Shift:l"], "commandstr": "right" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit1}", "Ctrl:Shift:c{Numpad1}"], "commandstr": "1" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit2}", "Ctrl:Shift:c{Numpad2}"], "commandstr": "2" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit3}", "Ctrl:Shift:c{Numpad3}"], "commandstr": "3" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit4}", "Ctrl:Shift:c{Numpad4}"], "commandstr": "4" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit5}", "Ctrl:Shift:c{Numpad5}"], "commandstr": "5" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit6}", "Ctrl:Shift:c{Numpad6}"], "commandstr": "6" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit7}", "Ctrl:Shift:c{Numpad7}"], "commandstr": "7" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit8}", "Ctrl:Shift:c{Numpad8}"], "commandstr": "8" }, + { "command": "block:focus-num", "keys": ["Ctrl:Shift:c{Digit9}", "Ctrl:Shift:c{Numpad9}"], "commandstr": "9" }, + { "command": "block:replace-launcher","keys": ["Ctrl:Shift:x"] }, + { "command": "block:rename", "keys": ["F2"] }, + { "command": "block:connection", "keys": ["Cmd:g"] }, + { "command": "block:search", "keys": ["Cmd:f"] }, + { "command": "generic:escape", "keys": ["Escape"] }, + { "command": "ai:toggle", "keys": ["Cmd:Shift:a"] }, + { "command": "ai:focus", "keys": ["Ctrl:Shift:c{Digit0}", "Ctrl:Shift:c{Numpad0}"] }, + { "command": "ai:focus-windows", "keys": ["Alt:c{Digit0}", "Alt:c{Numpad0}"] }, + { "command": "term:multi-input", "keys": ["Ctrl:Shift:i"] }, + { "command": "block:split-chord", "keys": ["Ctrl:Shift:s ArrowUp"], "commandstr": "up" }, + { "command": "block:split-chord", "keys": ["Ctrl:Shift:s ArrowDown"], "commandstr": "down" }, + { "command": "block:split-chord", "keys": ["Ctrl:Shift:s ArrowLeft"], "commandstr": "left" }, + { "command": "block:split-chord", "keys": ["Ctrl:Shift:s ArrowRight"], "commandstr": "right" } +] diff --git a/pkg/wconfig/keybindings_test.go b/pkg/wconfig/keybindings_test.go new file mode 100644 index 0000000000..98d05e51e9 --- /dev/null +++ b/pkg/wconfig/keybindings_test.go @@ -0,0 +1,168 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wconfig + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +func TestKeybindingKey(t *testing.T) { + tests := []struct { + input KeybindingConfigType + expected string + }{ + {KeybindingConfigType{Command: "tab:new"}, "tab:new"}, + {KeybindingConfigType{Command: "block:focus", CommandStr: "up"}, "block:focus:up"}, + {KeybindingConfigType{Command: "block:focus", CommandStr: ""}, "block:focus"}, + } + for _, tt := range tests { + result := keybindingKey(tt.input) + if result != tt.expected { + t.Errorf("keybindingKey(%+v) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestMergeKeybindings_NoUserOverrides(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:t"}}, + {Command: "tab:close", Keys: []string{"Cmd:w"}}, + } + result := mergeKeybindings(defaults, nil) + if len(result) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(result)) + } + if result[0].Keys[0] != "Cmd:t" { + t.Errorf("expected Cmd:t, got %s", result[0].Keys[0]) + } +} + +func TestMergeKeybindings_OverrideExisting(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:t"}}, + {Command: "block:focus", Keys: []string{"Ctrl:Shift:ArrowUp"}, CommandStr: "up"}, + } + user := []KeybindingConfigType{ + {Command: "block:focus", Keys: []string{"Cmd:Shift:ArrowUp"}, CommandStr: "up"}, + } + result := mergeKeybindings(defaults, user) + if len(result) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(result)) + } + if result[0].Keys[0] != "Cmd:t" { + t.Errorf("tab:new should be unchanged, got %s", result[0].Keys[0]) + } + if result[1].Keys[0] != "Cmd:Shift:ArrowUp" { + t.Errorf("block:focus:up should be overridden, got %s", result[1].Keys[0]) + } +} + +func TestMergeKeybindings_AddNew(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:t"}}, + } + user := []KeybindingConfigType{ + {Command: "custom:action", Keys: []string{"Cmd:Shift:x"}}, + } + result := mergeKeybindings(defaults, user) + if len(result) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(result)) + } + if result[1].Command != "custom:action" { + t.Errorf("expected custom:action appended, got %s", result[1].Command) + } +} + +func TestMergeKeybindings_DisableWithEmptyKeys(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:t"}}, + {Command: "block:rename", Keys: []string{"F2"}}, + } + user := []KeybindingConfigType{ + {Command: "block:rename", Keys: []string{}}, + } + result := mergeKeybindings(defaults, user) + if len(result) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(result)) + } + if len(result[1].Keys) != 0 { + t.Errorf("block:rename should have empty keys, got %v", result[1].Keys) + } +} + +func TestMergeKeybindings_CommandStrDistinction(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "block:focus", Keys: []string{"Ctrl:Shift:ArrowUp"}, CommandStr: "up"}, + {Command: "block:focus", Keys: []string{"Ctrl:Shift:ArrowDown"}, CommandStr: "down"}, + } + user := []KeybindingConfigType{ + {Command: "block:focus", Keys: []string{"Alt:ArrowUp"}, CommandStr: "up"}, + } + result := mergeKeybindings(defaults, user) + if len(result) != 2 { + t.Fatalf("expected 2 keybindings, got %d", len(result)) + } + if result[0].Keys[0] != "Alt:ArrowUp" { + t.Errorf("block:focus:up should be overridden, got %s", result[0].Keys[0]) + } + if result[1].Keys[0] != "Ctrl:Shift:ArrowDown" { + t.Errorf("block:focus:down should be unchanged, got %s", result[1].Keys[0]) + } +} + +func TestMergeKeybindings_DoesNotMutateDefaults(t *testing.T) { + defaults := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:t"}}, + } + user := []KeybindingConfigType{ + {Command: "tab:new", Keys: []string{"Cmd:n"}}, + } + mergeKeybindings(defaults, user) + if defaults[0].Keys[0] != "Cmd:t" { + t.Errorf("original defaults should not be mutated, got %s", defaults[0].Keys[0]) + } +} + +func TestReadKeybindingsFile_ValidJSON(t *testing.T) { + fsys := fstest.MapFS{ + "keybindings.json": &fstest.MapFile{ + Data: []byte(`[{"command":"tab:new","keys":["Cmd:t"]}]`), + }, + } + kbs, errs := readKeybindingsFile(fsys, "keybindings.json") + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(kbs) != 1 || kbs[0].Command != "tab:new" { + t.Errorf("unexpected result: %+v", kbs) + } +} + +func TestReadKeybindingsFile_InvalidJSON(t *testing.T) { + fsys := fstest.MapFS{ + "keybindings.json": &fstest.MapFile{ + Data: []byte(`[{broken`), + }, + } + kbs, errs := readKeybindingsFile(fsys, "keybindings.json") + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } + if kbs != nil { + t.Errorf("expected nil keybindings on error, got %+v", kbs) + } +} + +func TestReadKeybindingsFile_MissingFile(t *testing.T) { + fsys := fstest.MapFS{} + kbs, errs := readKeybindingsFile(fs.FS(fsys), "keybindings.json") + if len(errs) != 0 { + t.Fatalf("missing file should not produce errors, got %v", errs) + } + if kbs != nil { + t.Errorf("expected nil keybindings for missing file, got %+v", kbs) + } +} diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 67118b1670..941c49f7ff 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -26,6 +26,7 @@ import ( const SettingsFile = "settings.json" const ConnectionsFile = "connections.json" const ProfilesFile = "profiles.json" +const KeybindingsFile = "keybindings.json" var configWriteLock sync.Mutex @@ -365,6 +366,12 @@ type TermThemeType struct { Cursor string `json:"cursor"` } +type KeybindingConfigType struct { + Command string `json:"command"` + Keys []string `json:"keys"` + CommandStr string `json:"commandstr,omitempty"` +} + type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` @@ -376,6 +383,7 @@ type FullConfigType struct { Connections map[string]ConnKeywords `json:"connections"` Bookmarks map[string]WebBookmark `json:"bookmarks"` WaveAIModes map[string]AIModeConfigType `json:"waveai"` + Keybindings []KeybindingConfigType `json:"keybindings" configfile:"-"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` Version string `json:"version" configfile:"-"` BuildTime string `json:"buildtime" configfile:"-"` @@ -699,11 +707,70 @@ func ReadFullConfig() FullConfigType { utilfn.ReUnmarshal(fieldPtr, configPart) } } + keybindings, kbErrs := readKeybindingsConfig() + fullConfig.Keybindings = keybindings + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, kbErrs...) fullConfig.Version = wavebase.WaveVersion fullConfig.BuildTime = wavebase.BuildTime return fullConfig } +func readKeybindingsFile(fsys fs.FS, fileName string) ([]KeybindingConfigType, []ConfigError) { + barr, err := fs.ReadFile(fsys, fileName) + if err != nil { + barr, err = fs.ReadFile(fsys, filepath.ToSlash(fileName)) + } + if err != nil { + return nil, nil + } + var keybindings []KeybindingConfigType + if jsonErr := json.Unmarshal(barr, &keybindings); jsonErr != nil { + return nil, []ConfigError{{ + File: fileName, + Err: jsonErr.Error(), + }} + } + return keybindings, nil +} + +func keybindingKey(kb KeybindingConfigType) string { + if kb.CommandStr != "" { + return kb.Command + ":" + kb.CommandStr + } + return kb.Command +} + +func mergeKeybindings(defaultKBs []KeybindingConfigType, userKBs []KeybindingConfigType) []KeybindingConfigType { + if len(userKBs) == 0 { + return defaultKBs + } + result := make([]KeybindingConfigType, len(defaultKBs)) + copy(result, defaultKBs) + kbMap := make(map[string]int) + for i, kb := range result { + kbMap[keybindingKey(kb)] = i + } + for _, userKB := range userKBs { + key := keybindingKey(userKB) + if idx, ok := kbMap[key]; ok { + result[idx].Keys = userKB.Keys + } else { + kbMap[key] = len(result) + result = append(result, userKB) + } + } + return result +} + +func readKeybindingsConfig() ([]KeybindingConfigType, []ConfigError) { + defaultKBs, cerrs := readKeybindingsFile(defaultconfig.ConfigFS, KeybindingsFile) + configDirAbsPath := wavebase.GetWaveConfigDir() + configDirFsys := os.DirFS(configDirAbsPath) + userKBs, cerrs2 := readKeybindingsFile(configDirFsys, KeybindingsFile) + allErrs := append(cerrs, cerrs2...) + return mergeKeybindings(defaultKBs, userKBs), allErrs +} + func GetConfigSubdirs() []string { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig)