Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,52 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("replaces every rule for a command and restores defaults when cleared", () =>
Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig;
yield* writeKeybindingsConfig(keybindingsConfigPath, [
{ key: "mod+j", command: "terminal.toggle" },
{ key: "ctrl+`", command: "terminal.toggle" },
{ key: "mod+r", command: "script.run-tests.run" },
]);

const keybindings = yield* Keybindings;
const replaced = yield* keybindings.replaceKeybindingRules("terminal.toggle", [
{ key: "mod+shift+t", command: "terminal.toggle" },
{ key: "ctrl+shift+`", command: "terminal.toggle" },
]);

const persistedAfterReplace = yield* readKeybindingsConfig(keybindingsConfigPath);
assert.deepEqual(
persistedAfterReplace.map(({ key, command }) => ({ key, command })),
[
{ key: "mod+r", command: "script.run-tests.run" },
{ key: "mod+shift+t", command: "terminal.toggle" },
{ key: "ctrl+shift+`", command: "terminal.toggle" },
],
);
assert.deepEqual(
replaced
.filter((entry) => entry.command === "terminal.toggle")
.map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key),
["mod+shift+t", "ctrl+shift+`"],
);

const restored = yield* keybindings.replaceKeybindingRules("terminal.toggle", []);
const persistedAfterRestore = yield* readKeybindingsConfig(keybindingsConfigPath);
assert.deepEqual(
persistedAfterRestore.map(({ key, command }) => ({ key, command })),
[{ key: "mod+r", command: "script.run-tests.run" }],
);
assert.deepEqual(
restored
.filter((entry) => entry.command === "terminal.toggle")
.map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key),
["mod+j", "ctrl+`"],
);
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("refuses to overwrite malformed keybindings config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
186 changes: 61 additions & 125 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
ResolvedKeybindingsConfig,
type ServerConfigIssue,
} from "@okcode/contracts";
import {
DEFAULT_KEYBINDINGS as SHARED_DEFAULT_KEYBINDINGS,
encodeKeybindingShortcut,
parseKeybindingShortcut as parseSharedKeybindingShortcut,
} from "@okcode/shared/keybindings";
import { Mutable } from "effect/Types";
import {
Array,
Expand Down Expand Up @@ -64,90 +69,10 @@ type WhenToken =
| { type: "lparen" }
| { type: "rparen" };

export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+j", command: "terminal.toggle" },
{ key: "ctrl+`", command: "terminal.toggle" },
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
{ key: "mod+down", command: "git.pullRequest", when: "!terminalFocus" },
{ key: "mod+shift+p", command: "git.pullRequest", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
];

function normalizeKeyToken(token: string): string {
if (token === "space") return " ";
if (token === "esc") return "escape";
return token;
}
export const DEFAULT_KEYBINDINGS = SHARED_DEFAULT_KEYBINDINGS;

/** @internal - Exported for testing */
export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
const rawTokens = value
.toLowerCase()
.split("+")
.map((token) => token.trim());
const tokens = [...rawTokens];
let trailingEmptyCount = 0;
while (tokens[tokens.length - 1] === "") {
trailingEmptyCount += 1;
tokens.pop();
}
if (trailingEmptyCount > 0) {
tokens.push("+");
}
if (tokens.some((token) => token.length === 0)) {
return null;
}
if (tokens.length === 0) return null;

let key: string | null = null;
let metaKey = false;
let ctrlKey = false;
let shiftKey = false;
let altKey = false;
let modKey = false;

for (const token of tokens) {
switch (token) {
case "cmd":
case "meta":
metaKey = true;
break;
case "ctrl":
case "control":
ctrlKey = true;
break;
case "shift":
shiftKey = true;
break;
case "alt":
case "option":
altKey = true;
break;
case "mod":
modKey = true;
break;
default: {
if (key !== null) return null;
key = normalizeKeyToken(token);
}
}
}

if (key === null) return null;
return {
key,
metaKey,
ctrlKey,
shiftKey,
altKey,
modKey,
};
}
export const parseKeybindingShortcut = parseSharedKeybindingShortcut;

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

function encodeShortcut(shortcut: KeybindingShortcut): string | null {
const modifiers: string[] = [];
if (shortcut.modKey) modifiers.push("mod");
if (shortcut.metaKey) modifiers.push("meta");
if (shortcut.ctrlKey) modifiers.push("ctrl");
if (shortcut.altKey) modifiers.push("alt");
if (shortcut.shiftKey) modifiers.push("shift");
if (!shortcut.key) return null;
if (shortcut.key !== "+" && shortcut.key.includes("+")) return null;
const key = shortcut.key === " " ? "space" : shortcut.key;
return [...modifiers, key].join("+");
return encodeKeybindingShortcut(shortcut);
}

function encodeWhenAst(node: KeybindingWhenNode): string {
Expand Down Expand Up @@ -521,6 +437,17 @@ export interface KeybindingsShape {
readonly upsertKeybindingRule: (
rule: KeybindingRule,
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;

/**
* Replace every persisted rule for a command with the provided rules.
*
* Passing an empty array removes custom rules for the command so defaults
* can flow through again for built-in commands.
*/
readonly replaceKeybindingRules: (
command: KeybindingRule["command"],
rules: readonly KeybindingRule[],
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
}

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

const replaceKeybindingRules = (
command: KeybindingRule["command"],
rules: readonly KeybindingRule[],
) =>
upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
if (rules.some((rule) => rule.command !== command)) {
return yield* new KeybindingsConfigError({
configPath: keybindingsConfigPath,
detail: `received mismatched command rules for ${command}`,
});
}
const customConfig = yield* loadWritableCustomKeybindingsConfig();
const nextConfig = [...customConfig.filter((entry) => entry.command !== command), ...rules];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
: nextConfig;
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
yield* Effect.logWarning("truncating keybindings config to max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
});
}
yield* writeConfigAtomically(cappedConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(cappedConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
issues: [],
});
yield* emitChange({
keybindings: nextResolved,
issues: [],
});
return nextResolved;
}),
);

return {
start,
ready: Deferred.await(startedDeferred),
Expand All @@ -863,39 +830,8 @@ const makeKeybindings = Effect.gen(function* () {
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
},
upsertKeybindingRule: (rule) =>
upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
const customConfig = yield* loadWritableCustomKeybindingsConfig();
const nextConfig = [
...customConfig.filter((entry) => entry.command !== rule.command),
rule,
];
const cappedConfig =
nextConfig.length > MAX_KEYBINDINGS_COUNT
? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
: nextConfig;
if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
yield* Effect.logWarning("truncating keybindings config to max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
});
}
yield* writeConfigAtomically(cappedConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(cappedConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
issues: [],
});
yield* emitChange({
keybindings: nextResolved,
issues: [],
});
return nextResolved;
}),
),
replaceKeybindingRules,
upsertKeybindingRule: (rule) => replaceKeybindingRules(rule.command, [rule]),
} satisfies KeybindingsShape;
});

Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return { keybindings: keybindingsConfig, issues: [] };
}

case WS_METHODS.serverReplaceKeybindingRules: {
const body = stripRequestTag(request.body);
const keybindingsConfig = yield* keybindingsManager.replaceKeybindingRules(
body.command,
body.rules,
);
return { keybindings: keybindingsConfig, issues: [] };
}

case WS_METHODS.serverGetGlobalEnvironmentVariables:
return yield* environmentVariables.getGlobal();

Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1979,8 +1979,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
})
: null;

if (isElectron && keybindingRule) {
await api.server.upsertKeybinding(keybindingRule);
if (isElectron && input.keybindingCommand) {
await api.server.replaceKeybindingRules(
keybindingRule
? { command: input.keybindingCommand, rules: [keybindingRule] }
: { command: input.keybindingCommand, rules: [] },
);
await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
}
},
Expand Down
Loading
Loading