Skip to content

Commit b034201

Browse files
authored
Add hotkey settings editor and keybinding reset support (#417)
- Centralize keybinding parsing and encoding in shared utilities - Add settings UI to edit, add, and restore built-in hotkeys - Expose server/API support to replace all rules for a command
1 parent 83a6bce commit b034201

16 files changed

Lines changed: 1290 additions & 250 deletions

apps/server/src/keybindings.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,52 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
348348
}).pipe(Effect.provide(makeKeybindingsLayer())),
349349
);
350350

351+
it.effect("replaces every rule for a command and restores defaults when cleared", () =>
352+
Effect.gen(function* () {
353+
const { keybindingsConfigPath } = yield* ServerConfig;
354+
yield* writeKeybindingsConfig(keybindingsConfigPath, [
355+
{ key: "mod+j", command: "terminal.toggle" },
356+
{ key: "ctrl+`", command: "terminal.toggle" },
357+
{ key: "mod+r", command: "script.run-tests.run" },
358+
]);
359+
360+
const keybindings = yield* Keybindings;
361+
const replaced = yield* keybindings.replaceKeybindingRules("terminal.toggle", [
362+
{ key: "mod+shift+t", command: "terminal.toggle" },
363+
{ key: "ctrl+shift+`", command: "terminal.toggle" },
364+
]);
365+
366+
const persistedAfterReplace = yield* readKeybindingsConfig(keybindingsConfigPath);
367+
assert.deepEqual(
368+
persistedAfterReplace.map(({ key, command }) => ({ key, command })),
369+
[
370+
{ key: "mod+r", command: "script.run-tests.run" },
371+
{ key: "mod+shift+t", command: "terminal.toggle" },
372+
{ key: "ctrl+shift+`", command: "terminal.toggle" },
373+
],
374+
);
375+
assert.deepEqual(
376+
replaced
377+
.filter((entry) => entry.command === "terminal.toggle")
378+
.map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key),
379+
["mod+shift+t", "ctrl+shift+`"],
380+
);
381+
382+
const restored = yield* keybindings.replaceKeybindingRules("terminal.toggle", []);
383+
const persistedAfterRestore = yield* readKeybindingsConfig(keybindingsConfigPath);
384+
assert.deepEqual(
385+
persistedAfterRestore.map(({ key, command }) => ({ key, command })),
386+
[{ key: "mod+r", command: "script.run-tests.run" }],
387+
);
388+
assert.deepEqual(
389+
restored
390+
.filter((entry) => entry.command === "terminal.toggle")
391+
.map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key),
392+
["mod+j", "ctrl+`"],
393+
);
394+
}).pipe(Effect.provide(makeKeybindingsLayer())),
395+
);
396+
351397
it.effect("refuses to overwrite malformed keybindings config", () =>
352398
Effect.gen(function* () {
353399
const fs = yield* FileSystem.FileSystem;

apps/server/src/keybindings.ts

Lines changed: 61 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
ResolvedKeybindingsConfig,
1818
type ServerConfigIssue,
1919
} from "@okcode/contracts";
20+
import {
21+
DEFAULT_KEYBINDINGS as SHARED_DEFAULT_KEYBINDINGS,
22+
encodeKeybindingShortcut,
23+
parseKeybindingShortcut as parseSharedKeybindingShortcut,
24+
} from "@okcode/shared/keybindings";
2025
import { Mutable } from "effect/Types";
2126
import {
2227
Array,
@@ -64,90 +69,10 @@ type WhenToken =
6469
| { type: "lparen" }
6570
| { type: "rparen" };
6671

67-
export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
68-
{ key: "mod+j", command: "terminal.toggle" },
69-
{ key: "ctrl+`", command: "terminal.toggle" },
70-
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
71-
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
72-
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
73-
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
74-
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
75-
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
76-
{ key: "mod+down", command: "git.pullRequest", when: "!terminalFocus" },
77-
{ key: "mod+shift+p", command: "git.pullRequest", when: "!terminalFocus" },
78-
{ key: "mod+o", command: "editor.openFavorite" },
79-
];
80-
81-
function normalizeKeyToken(token: string): string {
82-
if (token === "space") return " ";
83-
if (token === "esc") return "escape";
84-
return token;
85-
}
72+
export const DEFAULT_KEYBINDINGS = SHARED_DEFAULT_KEYBINDINGS;
8673

8774
/** @internal - Exported for testing */
88-
export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
89-
const rawTokens = value
90-
.toLowerCase()
91-
.split("+")
92-
.map((token) => token.trim());
93-
const tokens = [...rawTokens];
94-
let trailingEmptyCount = 0;
95-
while (tokens[tokens.length - 1] === "") {
96-
trailingEmptyCount += 1;
97-
tokens.pop();
98-
}
99-
if (trailingEmptyCount > 0) {
100-
tokens.push("+");
101-
}
102-
if (tokens.some((token) => token.length === 0)) {
103-
return null;
104-
}
105-
if (tokens.length === 0) return null;
106-
107-
let key: string | null = null;
108-
let metaKey = false;
109-
let ctrlKey = false;
110-
let shiftKey = false;
111-
let altKey = false;
112-
let modKey = false;
113-
114-
for (const token of tokens) {
115-
switch (token) {
116-
case "cmd":
117-
case "meta":
118-
metaKey = true;
119-
break;
120-
case "ctrl":
121-
case "control":
122-
ctrlKey = true;
123-
break;
124-
case "shift":
125-
shiftKey = true;
126-
break;
127-
case "alt":
128-
case "option":
129-
altKey = true;
130-
break;
131-
case "mod":
132-
modKey = true;
133-
break;
134-
default: {
135-
if (key !== null) return null;
136-
key = normalizeKeyToken(token);
137-
}
138-
}
139-
}
140-
141-
if (key === null) return null;
142-
return {
143-
key,
144-
metaKey,
145-
ctrlKey,
146-
shiftKey,
147-
altKey,
148-
modKey,
149-
};
150-
}
75+
export const parseKeybindingShortcut = parseSharedKeybindingShortcut;
15176

15277
function tokenizeWhenExpression(expression: string): WhenToken[] | null {
15378
const tokens: WhenToken[] = [];
@@ -383,16 +308,7 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo
383308
}
384309

385310
function encodeShortcut(shortcut: KeybindingShortcut): string | null {
386-
const modifiers: string[] = [];
387-
if (shortcut.modKey) modifiers.push("mod");
388-
if (shortcut.metaKey) modifiers.push("meta");
389-
if (shortcut.ctrlKey) modifiers.push("ctrl");
390-
if (shortcut.altKey) modifiers.push("alt");
391-
if (shortcut.shiftKey) modifiers.push("shift");
392-
if (!shortcut.key) return null;
393-
if (shortcut.key !== "+" && shortcut.key.includes("+")) return null;
394-
const key = shortcut.key === " " ? "space" : shortcut.key;
395-
return [...modifiers, key].join("+");
311+
return encodeKeybindingShortcut(shortcut);
396312
}
397313

398314
function encodeWhenAst(node: KeybindingWhenNode): string {
@@ -521,6 +437,17 @@ export interface KeybindingsShape {
521437
readonly upsertKeybindingRule: (
522438
rule: KeybindingRule,
523439
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
440+
441+
/**
442+
* Replace every persisted rule for a command with the provided rules.
443+
*
444+
* Passing an empty array removes custom rules for the command so defaults
445+
* can flow through again for built-in commands.
446+
*/
447+
readonly replaceKeybindingRules: (
448+
command: KeybindingRule["command"],
449+
rules: readonly KeybindingRule[],
450+
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
524451
}
525452

526453
/**
@@ -854,6 +781,46 @@ const makeKeybindings = Effect.gen(function* () {
854781
yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);
855782
});
856783

784+
const replaceKeybindingRules = (
785+
command: KeybindingRule["command"],
786+
rules: readonly KeybindingRule[],
787+
) =>
788+
upsertSemaphore.withPermits(1)(
789+
Effect.gen(function* () {
790+
if (rules.some((rule) => rule.command !== command)) {
791+
return yield* new KeybindingsConfigError({
792+
configPath: keybindingsConfigPath,
793+
detail: `received mismatched command rules for ${command}`,
794+
});
795+
}
796+
const customConfig = yield* loadWritableCustomKeybindingsConfig();
797+
const nextConfig = [...customConfig.filter((entry) => entry.command !== command), ...rules];
798+
const cappedConfig =
799+
nextConfig.length > MAX_KEYBINDINGS_COUNT
800+
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
801+
: nextConfig;
802+
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
803+
yield* Effect.logWarning("truncating keybindings config to max entries", {
804+
path: keybindingsConfigPath,
805+
maxEntries: MAX_KEYBINDINGS_COUNT,
806+
});
807+
}
808+
yield* writeConfigAtomically(cappedConfig);
809+
const nextResolved = mergeWithDefaultKeybindings(
810+
compileResolvedKeybindingsConfig(cappedConfig),
811+
);
812+
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
813+
keybindings: nextResolved,
814+
issues: [],
815+
});
816+
yield* emitChange({
817+
keybindings: nextResolved,
818+
issues: [],
819+
});
820+
return nextResolved;
821+
}),
822+
);
823+
857824
return {
858825
start,
859826
ready: Deferred.await(startedDeferred),
@@ -863,39 +830,8 @@ const makeKeybindings = Effect.gen(function* () {
863830
get streamChanges() {
864831
return Stream.fromPubSub(changesPubSub);
865832
},
866-
upsertKeybindingRule: (rule) =>
867-
upsertSemaphore.withPermits(1)(
868-
Effect.gen(function* () {
869-
const customConfig = yield* loadWritableCustomKeybindingsConfig();
870-
const nextConfig = [
871-
...customConfig.filter((entry) => entry.command !== rule.command),
872-
rule,
873-
];
874-
const cappedConfig =
875-
nextConfig.length > MAX_KEYBINDINGS_COUNT
876-
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
877-
: nextConfig;
878-
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
879-
yield* Effect.logWarning("truncating keybindings config to max entries", {
880-
path: keybindingsConfigPath,
881-
maxEntries: MAX_KEYBINDINGS_COUNT,
882-
});
883-
}
884-
yield* writeConfigAtomically(cappedConfig);
885-
const nextResolved = mergeWithDefaultKeybindings(
886-
compileResolvedKeybindingsConfig(cappedConfig),
887-
);
888-
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
889-
keybindings: nextResolved,
890-
issues: [],
891-
});
892-
yield* emitChange({
893-
keybindings: nextResolved,
894-
issues: [],
895-
});
896-
return nextResolved;
897-
}),
898-
),
833+
replaceKeybindingRules,
834+
upsertKeybindingRule: (rule) => replaceKeybindingRules(rule.command, [rule]),
899835
} satisfies KeybindingsShape;
900836
});
901837

apps/server/src/wsServer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
16201620
return { keybindings: keybindingsConfig, issues: [] };
16211621
}
16221622

1623+
case WS_METHODS.serverReplaceKeybindingRules: {
1624+
const body = stripRequestTag(request.body);
1625+
const keybindingsConfig = yield* keybindingsManager.replaceKeybindingRules(
1626+
body.command,
1627+
body.rules,
1628+
);
1629+
return { keybindings: keybindingsConfig, issues: [] };
1630+
}
1631+
16231632
case WS_METHODS.serverGetGlobalEnvironmentVariables:
16241633
return yield* environmentVariables.getGlobal();
16251634

apps/web/src/components/ChatView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,8 +1979,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
19791979
})
19801980
: null;
19811981

1982-
if (isElectron && keybindingRule) {
1983-
await api.server.upsertKeybinding(keybindingRule);
1982+
if (isElectron && input.keybindingCommand) {
1983+
await api.server.replaceKeybindingRules(
1984+
keybindingRule
1985+
? { command: input.keybindingCommand, rules: [keybindingRule] }
1986+
: { command: input.keybindingCommand, rules: [] },
1987+
);
19841988
await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
19851989
}
19861990
},

0 commit comments

Comments
 (0)