Skip to content

Commit e2c04f3

Browse files
mswiszczclaude
andcommitted
refactor: replace Map-based keybinding storage with arrays for VSCode-style last-wins resolution
globalKeyMap and globalChordMap were Maps but checkKeyMap already iterated linearly over all entries (no O(1) lookup benefit). Switching to arrays with reverse iteration gives natural "last entry wins" semantics — user overrides appended after defaults automatically shadow them without explicit merge logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e196f4a commit e2c04f3

1 file changed

Lines changed: 78 additions & 97 deletions

File tree

frontend/app/store/keymodel.ts

Lines changed: 78 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ type ChordActionDef = {
5151
handler: KeyHandler;
5252
};
5353

54+
type KeyMapEntry<T = KeyHandler> = { key: string; handler: T };
55+
type ChordEntry = { key: string; subKeys: KeyMapEntry[] };
56+
5457
const simpleControlShiftAtom = jotai.atom(false);
55-
const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();
56-
const globalChordMap = new Map<string, Map<string, KeyHandler>>();
58+
let globalKeyBindings: KeyMapEntry[] = [];
59+
let globalChordBindings: ChordEntry[] = [];
5760
let globalKeybindingsDisabled = false;
5861

5962
// track current chord state and timeout (for resetting)
@@ -420,12 +423,12 @@ async function handleSplitVertical(position: "before" | "after") {
420423

421424
let lastHandledEvent: KeyboardEvent | null = null;
422425

423-
// returns [keymatch, T]
424-
function checkKeyMap<T>(waveEvent: WaveKeyboardEvent, keyMap: Map<string, T>): [string, T] {
425-
for (const key of keyMap.keys()) {
426-
if (keyutil.checkKeyPressed(waveEvent, key)) {
427-
const val = keyMap.get(key);
428-
return [key, val];
426+
// returns [keymatch, T] — iterates in reverse so later entries (user overrides) win
427+
function checkKeyArray<T>(waveEvent: WaveKeyboardEvent, entries: KeyMapEntry<T>[]): [string, T] {
428+
for (let i = entries.length - 1; i >= 0; i--) {
429+
const entry = entries[i];
430+
if (keyutil.checkKeyPressed(waveEvent, entry.key)) {
431+
return [entry.key, entry.handler];
429432
}
430433
}
431434
return [null, null];
@@ -442,25 +445,28 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
442445
lastHandledEvent = nativeEvent;
443446
if (activeChord) {
444447
console.log("handle activeChord", activeChord);
445-
// If we're in chord mode, look for the second key.
446-
const chordBindings = globalChordMap.get(activeChord);
447-
const [, handler] = checkKeyMap(waveEvent, chordBindings);
448-
if (handler) {
449-
resetChord();
450-
return handler(waveEvent);
451-
} else {
452-
// invalid chord; reset state and consume key
453-
resetChord();
454-
return true;
448+
// If we're in chord mode, look for the second key in the matching chord's sub-keys.
449+
const chordEntry = globalChordBindings.find((c) => c.key === activeChord);
450+
if (chordEntry) {
451+
const [, handler] = checkKeyArray(waveEvent, chordEntry.subKeys);
452+
if (handler) {
453+
resetChord();
454+
return handler(waveEvent);
455+
}
455456
}
456-
}
457-
const [chordKeyMatch] = checkKeyMap(waveEvent, globalChordMap);
458-
if (chordKeyMatch) {
459-
setActiveChord(chordKeyMatch);
457+
// invalid chord; reset state and consume key
458+
resetChord();
460459
return true;
461460
}
461+
// Check if this key initiates a chord
462+
for (const chord of globalChordBindings) {
463+
if (keyutil.checkKeyPressed(waveEvent, chord.key)) {
464+
setActiveChord(chord.key);
465+
return true;
466+
}
467+
}
462468

463-
const [, globalHandler] = checkKeyMap(waveEvent, globalKeyMap);
469+
const [, globalHandler] = checkKeyArray(waveEvent, globalKeyBindings);
464470
if (globalHandler) {
465471
const handled = globalHandler(waveEvent);
466472
if (handled) {
@@ -832,13 +838,35 @@ const defaultChordActions: ChordActionDef[] = [
832838
];
833839

834840
function buildKeyMaps(userOverrides: KeybindingEntry[]): void {
835-
// 1. Build resolved map: actionId -> { keys, handler }
836-
const resolvedActions = new Map<string, { keys: string[]; handler: KeyHandler }>();
841+
// 1. Start with default bindings as array entries (key -> handler)
842+
const bindings: KeyMapEntry[] = [];
843+
const chordBindings: ChordEntry[] = [];
844+
845+
// Track which action IDs map to which handler (for user overrides)
846+
const actionHandlers = new Map<string, KeyHandler>();
837847
for (const action of defaultActions) {
838-
resolvedActions.set(action.id, { keys: [...action.defaultKeys], handler: action.handler });
848+
actionHandlers.set(action.id, action.handler);
849+
for (const key of action.defaultKeys) {
850+
bindings.push({ key, handler: action.handler });
851+
}
839852
}
840853

841-
// 2. Apply user overrides in order (last wins)
854+
// 2. Build chord bindings from defaults
855+
const chordInitiatorAction = defaultActions.find((a) => a.id === "block:splitChord");
856+
if (chordInitiatorAction) {
857+
const subKeys: KeyMapEntry[] = [];
858+
for (const chordDef of defaultChordActions) {
859+
if (chordDef.parentId === "block:splitChord") {
860+
actionHandlers.set(chordDef.id, chordDef.handler);
861+
subKeys.push({ key: chordDef.defaultKey, handler: chordDef.handler });
862+
}
863+
}
864+
for (const key of chordInitiatorAction.defaultKeys) {
865+
chordBindings.push({ key, subKeys: [...subKeys] });
866+
}
867+
}
868+
869+
// 3. Apply user overrides — append to array (last wins via reverse iteration)
842870
for (const override of userOverrides) {
843871
if (!override.command || typeof override.command !== "string") {
844872
console.warn("Skipping keybinding entry with missing/invalid command");
@@ -848,75 +876,26 @@ function buildKeyMaps(userOverrides: KeybindingEntry[]): void {
848876
console.warn(`Skipping keybinding entry with invalid key type for command: ${override.command}`);
849877
continue;
850878
}
851-
const commandId = override.command.startsWith("-") ? override.command.substring(1) : override.command;
852-
if (override.command.startsWith("-") || override.key == null) {
853-
// Unbind: remove the action
854-
resolvedActions.delete(commandId);
855-
} else {
856-
// Override: replace that action's keys
857-
const existing = resolvedActions.get(commandId);
858-
if (existing) {
859-
existing.keys = [override.key];
860-
} else {
861-
console.warn(`Unknown keybinding action: ${commandId}`);
862-
}
863-
}
864-
}
865-
866-
// 3. Clear and rebuild maps
867-
globalKeyMap.clear();
868-
globalChordMap.clear();
869-
870-
// 4. Find chord initiator keys
871-
const chordInitiatorKeys = new Map<string, string[]>(); // parentId -> keys
872-
const chordAction = resolvedActions.get("block:splitChord");
873-
if (chordAction) {
874-
chordInitiatorKeys.set("block:splitChord", chordAction.keys);
875-
resolvedActions.delete("block:splitChord"); // don't add to globalKeyMap
876-
}
877-
878-
// 5. Build chord sub-key maps
879-
for (const [parentId, initiatorKeys] of chordInitiatorKeys) {
880-
const subKeyMap = new Map<string, KeyHandler>();
881-
for (const chordDef of defaultChordActions) {
882-
if (chordDef.parentId === parentId) {
883-
// Check if user overrode this chord sub-action
884-
let subKey: string | null = chordDef.defaultKey;
885-
for (const override of userOverrides) {
886-
const cmdId = override.command.startsWith("-")
887-
? override.command.substring(1)
888-
: override.command;
889-
if (cmdId === chordDef.id) {
890-
if (override.command.startsWith("-") || override.key == null) {
891-
subKey = null; // unbind
892-
} else {
893-
subKey = override.key;
894-
}
895-
}
896-
}
897-
if (subKey != null) {
898-
subKeyMap.set(subKey, chordDef.handler);
899-
}
900-
}
879+
const commandId = override.command;
880+
if (override.key == null) {
881+
continue; // null key = no binding, handled by reverse iteration skipping
901882
}
902-
if (subKeyMap.size > 0) {
903-
for (const key of initiatorKeys) {
904-
globalChordMap.set(key, subKeyMap);
905-
}
883+
const handler = actionHandlers.get(commandId);
884+
if (handler) {
885+
bindings.push({ key: override.key, handler });
886+
} else {
887+
console.warn(`Unknown keybinding action: ${commandId}`);
906888
}
907889
}
908890

909-
// 6. Populate globalKeyMap from resolved simple actions
910-
for (const [, actionData] of resolvedActions) {
911-
for (const key of actionData.keys) {
912-
globalKeyMap.set(key, actionData.handler);
913-
}
914-
}
891+
// 4. Assign to globals
892+
globalKeyBindings = bindings;
893+
globalChordBindings = chordBindings;
915894

916-
// 7. Re-register with Electron
917-
const allKeys = Array.from(globalKeyMap.keys());
918-
for (const keys of chordInitiatorKeys.values()) {
919-
allKeys.push(...keys);
895+
// 5. Re-register with Electron
896+
const allKeys = globalKeyBindings.map((e) => e.key);
897+
for (const chord of globalChordBindings) {
898+
allKeys.push(chord.key);
920899
}
921900
// Special web view keys
922901
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o");
@@ -934,17 +913,19 @@ function initKeybindingsWatcher() {
934913
}
935914

936915
function registerBuilderGlobalKeys() {
937-
globalKeyMap.set("Cmd:w", () => {
938-
getApi().closeBuilderWindow();
939-
return true;
916+
globalKeyBindings.push({
917+
key: "Cmd:w",
918+
handler: () => {
919+
getApi().closeBuilderWindow();
920+
return true;
921+
},
940922
});
941-
const allKeys = Array.from(globalKeyMap.keys());
923+
const allKeys = globalKeyBindings.map((e) => e.key);
942924
getApi().registerGlobalWebviewKeys(allKeys);
943925
}
944926

945927
function getAllGlobalKeyBindings(): string[] {
946-
const allKeys = Array.from(globalKeyMap.keys());
947-
return allKeys;
928+
return globalKeyBindings.map((e) => e.key);
948929
}
949930

950931
export {

0 commit comments

Comments
 (0)