Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = {
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
favorites: [],
keyboardEditingMode: "emacs",
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {

assert.deepEqual(compiled, {
command: "terminal.split",
source: "user",
shortcut: {
key: "d",
metaKey: false,
Expand Down Expand Up @@ -187,6 +188,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("marks startup-written defaults as defaults in runtime snapshots", () =>
Effect.gen(function* () {
const snapshot = yield* Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
yield* keybindings.syncDefaultKeybindingsOnStartup;
return yield* keybindings.loadConfigState;
});

assert.equal(snapshot.keybindings.length, Keybindings.DEFAULT_KEYBINDINGS.length);
assert.isTrue(snapshot.keybindings.every((binding) => binding.source === "default"));
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("ships configurable thread navigation defaults", () =>
Effect.sync(() => {
const defaultsByCommand = new Map(
Expand Down Expand Up @@ -219,7 +233,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {

assert.deepEqual(
configState.keybindings,
Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS),
Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS, "default"),
);
assert.deepEqual(configState.issues, [
{
Expand Down
22 changes: 19 additions & 3 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool
);
}

function compileRuntimeKeybindingsConfig(
config: ReadonlyArray<KeybindingRule>,
): ResolvedKeybindingsConfig {
const compiled: ResolvedKeybindingRule[] = [];
for (const rule of config) {
const source = DEFAULT_KEYBINDINGS.some((defaultRule) =>
isSameKeybindingRule(rule, defaultRule),
)
? "default"
: "user";
const resolved = compileResolvedKeybindingRule(rule, source);
if (resolved) compiled.push(resolved);
}
return compiled.slice(-MAX_KEYBINDINGS_COUNT);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identical user bindings marked default

Medium Severity

compileRuntimeKeybindingsConfig tags any on-disk rule that matches a shipped default as source: "default". Explicit user entries that intentionally reuse a default chord therefore fail resolveCustomShortcutCommand, so Emacs mode will not yield to them in text fields.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cdb8647. Configure here.


function keybindingShortcutContext(rule: KeybindingRule): string | null {
const parsed = parseKeybindingShortcut(rule.key);
if (!parsed) return null;
Expand Down Expand Up @@ -448,7 +464,7 @@ const make = Effect.gen(function* () {

const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(
Effect.map(({ keybindings, issues }) => ({
keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),
keybindings: mergeWithDefaultKeybindings(compileRuntimeKeybindingsConfig(keybindings)),
issues,
})),
);
Expand Down Expand Up @@ -664,7 +680,7 @@ const make = Effect.gen(function* () {
}
yield* writeConfigAtomically(cappedConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(cappedConfig),
compileRuntimeKeybindingsConfig(cappedConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
Expand All @@ -685,7 +701,7 @@ const make = Effect.gen(function* () {
const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target));
yield* writeConfigAtomically(nextConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(nextConfig),
compileRuntimeKeybindingsConfig(nextConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@ import { RouterProvider } from "@tanstack/react-router";
import { describe, expect, it } from "vite-plus/test";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { EmacsReadlineBindings } from "./components/EmacsReadlineBindings";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
import { AppRoot } from "./AppRoot";

describe("AppRoot", () => {
it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => {
const root = AppRoot({ router: {} as AppRouter });
const router = {} as AppRouter;
const root = AppRoot({ router });

expect(root.type).toBe(AppAtomRegistryProvider);
const children = Children.toArray(
(root as ReactElement<{ readonly children: ReactNode }>).props.children,
);
expect(children).toHaveLength(3);
expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
expect(children).toHaveLength(4);
expect(isValidElement(children[0]) && children[0].type).toBe(EmacsReadlineBindings);
expect(
isValidElement<{ readonly router: AppRouter }>(children[0]) && children[0].props.router,
).toBe(router);
expect(isValidElement(children[1]) && children[1].type).toBe(RouterProvider);
expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost);
});
});
2 changes: 2 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { EmacsReadlineBindings } from "./components/EmacsReadlineBindings";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
Expand All @@ -13,6 +14,7 @@ import type { AppRouter } from "./router";
export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
<AppAtomRegistryProvider>
<EmacsReadlineBindings router={router} />
<RouterProvider router={router} />
<PreviewAutomationHosts />
<ElectronBrowserHost />
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,74 @@ import {
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
createEditor,
PASTE_COMMAND,
} from "lexical";

import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
import { $replaceComposerReadlineSelection } from "../composerEmacsReadline";

describe("$replaceComposerReadlineSelection", () => {
it.each([
{
boundary: "at EOF",
serializedToken: "[file.ts](src/file.ts)",
suffix: "",
tokenType: "mention",
},
{
boundary: "before punctuation",
serializedToken: "[file.ts](src/file.ts)",
suffix: ".",
tokenType: "mention",
},
{
boundary: "at EOF",
serializedToken: "$review",
suffix: "",
tokenType: "skill",
},
{
boundary: "before punctuation",
serializedToken: "$review",
suffix: ".",
tokenType: "skill",
},
])(
"preserves an existing $tokenType chip $boundary during a readline mutation",
({ serializedToken, suffix }) => {
const editor = createEditor();
let tokenKey = "";

editor.update(
() => {
const paragraph = $createParagraphNode();
const prefix = $createTextNode("before");
const token = $createTextNode(serializedToken).setMode("token");
tokenKey = token.getKey();
paragraph.append(prefix, token);
if (suffix.length > 0) paragraph.append($createTextNode(suffix));
$getRoot().append(paragraph);

$replaceComposerReadlineSelection(prefix.select(0, 1), [$createTextNode("B")]);
},
{ discrete: true },
);

editor.getEditorState().read(() => {
const paragraph = $getRoot().getFirstChild();
expect($isElementNode(paragraph)).toBe(true);
if (!$isElementNode(paragraph)) return;
const token = paragraph.getChildren().find((node) => node.getKey() === tokenKey);
expect(token?.getTextContent()).toBe(serializedToken);
expect($getRoot().getTextContent()).toBe(`Before${serializedToken}${suffix}`);
});
},
);
});

class TestClipboardEvent extends Event {
readonly clipboardData: DataTransfer;
Expand Down
Loading
Loading