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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Added

- Run the first nine visible command-menu actions with `Cmd+1` through `Cmd+9`,
with matching shortcut hints beside each action.

## 0.3.3 - 2026-08-13

### Added
Expand Down
6 changes: 4 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ The in-app reference is available from **Menu → Keyboard shortcuts**.
| Shortcut | Action |
| --- | --- |
| `Cmd/Ctrl+K` | Open or close the command menu |
| `Cmd+1` … `Cmd+9` while the command menu is open | Run the corresponding numbered visible action |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Open and navigate the recent Pane switcher |
| `Cmd+B` | Toggle the desktop sidebar |
| `Cmd+T` | Create a tab in the focused workspace |
Expand All @@ -264,8 +265,9 @@ The in-app reference is available from **Menu → Keyboard shortcuts**.
| `Ctrl+Shift+G` | Open Diff Viewer |
| `Esc` | Dismiss the current menu, dialog, notification, or update banner |

A host browser can reserve shortcuts such as `Cmd+T` and `Cmd+W`; they are most
reliable in an installed PWA or another standalone/webview host.
A host browser can reserve shortcuts such as `Cmd+1` … `Cmd+9`, `Cmd+T`, and
`Cmd+W`; they are most reliable in an installed PWA or another
standalone/webview host.

### Terminal

Expand Down
114 changes: 114 additions & 0 deletions web/src/components/CommandCombobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import { describe, expect, test } from "bun:test";
import {
commandFilter,
commandNumberedActions,
commandNumberShortcutIndex,
commandNumberShortcutTarget,
commandPathQuery,
normalizeSearchText,
runCommandNumberShortcut,
} from "./CommandCombobox";

describe("command combobox search helpers", () => {
Expand Down Expand Up @@ -35,4 +39,114 @@ describe("command combobox search helpers", () => {
expect(commandPathQuery("new workspace")).toBe("");
expect(commandPathQuery("README")).toBe("");
});

test("maps Command-number-row shortcuts across keyboard layouts", () => {
const event = {
altKey: false,
code: "Digit4",
ctrlKey: false,
key: "4",
metaKey: true,
shiftKey: false,
};
expect(commandNumberShortcutIndex(event)).toBe(3);
expect(
commandNumberShortcutIndex({ ...event, code: "Digit1", key: "&" }),
).toBe(0);
expect(commandNumberShortcutIndex({ ...event, metaKey: false })).toBeNull();
expect(commandNumberShortcutIndex({ ...event, ctrlKey: true })).toBeNull();
expect(commandNumberShortcutIndex({ ...event, altKey: true })).toBeNull();
expect(commandNumberShortcutIndex({ ...event, shiftKey: true })).toBeNull();
expect(
commandNumberShortcutIndex({ ...event, code: "Digit0", key: "0" }),
).toBeNull();
});

test("numbers the first nine actions in visible group order", () => {
const groups = [
{ actions: ["top-1", "top-2"] },
{
actions: Array.from({ length: 10 }, (_, index) => `group-${index + 1}`),
},
];
expect(commandNumberedActions(groups)).toEqual([
"top-1",
"top-2",
"group-1",
"group-2",
"group-3",
"group-4",
"group-5",
"group-6",
"group-7",
]);
});

test("selects the matching displayed action without wrapping", () => {
const event = {
altKey: false,
code: "Digit2",
ctrlKey: false,
key: "2",
metaKey: true,
shiftKey: false,
};
expect(commandNumberShortcutTarget(event, ["first", "second"])).toBe(
"second",
);
expect(
commandNumberShortcutTarget({ ...event, code: "Digit3", key: "3" }, [
"first",
"second",
]),
).toBeNull();
});

test("runs a recognized shortcut once and consumes only that event", () => {
let preventDefaultCalls = 0;
let stopPropagationCalls = 0;
const runs: string[] = [];
const event = {
altKey: false,
code: "Digit2",
ctrlKey: false,
key: "é",
metaKey: true,
preventDefault: () => {
preventDefaultCalls += 1;
},
repeat: false,
shiftKey: false,
stopPropagation: () => {
stopPropagationCalls += 1;
},
};

expect(
runCommandNumberShortcut(event, ["first", "second"], (action) =>
runs.push(action),
),
).toBe(true);
expect(runs).toEqual(["second"]);
expect(preventDefaultCalls).toBe(1);
expect(stopPropagationCalls).toBe(1);

expect(
runCommandNumberShortcut(
{ ...event, code: "Digit1", repeat: true },
["first"],
(action) => runs.push(action),
),
).toBe(false);
expect(
runCommandNumberShortcut(
{ ...event, code: "Digit3", key: "3" },
["first", "second"],
(action) => runs.push(action),
),
).toBe(false);
expect(runs).toEqual(["second"]);
expect(preventDefaultCalls).toBe(1);
expect(stopPropagationCalls).toBe(1);
});
});
77 changes: 75 additions & 2 deletions web/src/components/CommandCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,53 @@ export function commandPathQuery(value: string) {
return "";
}

type CommandNumberShortcutModifiers = Pick<
KeyboardEvent,
"altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey"
>;

type CommandNumberShortcutEvent = CommandNumberShortcutModifiers &
Pick<KeyboardEvent, "preventDefault" | "repeat" | "stopPropagation">;

export function commandNumberShortcutIndex(
event: CommandNumberShortcutModifiers,
) {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return null;
}
if (/^[1-9]$/.test(event.key)) return Number(event.key) - 1;
const match = /^Digit([1-9])$/.exec(event.code);
return match ? Number(match[1]) - 1 : null;
}

export function commandNumberedActions<T>(
groups: readonly { actions: readonly T[] }[],
) {
return groups.flatMap((group) => group.actions).slice(0, 9);
}

export function commandNumberShortcutTarget<T>(
event: CommandNumberShortcutModifiers,
actions: readonly T[],
) {
const index = commandNumberShortcutIndex(event);
return index === null ? null : (actions[index] ?? null);
}

export function runCommandNumberShortcut<T>(
event: CommandNumberShortcutEvent,
actions: readonly T[],
runAction: (action: T) => void,
) {
if (event.repeat) return false;
const action = commandNumberShortcutTarget(event, actions);
if (action === null) return false;
event.preventDefault();
event.stopPropagation();
runAction(action);
return true;
}

export function CommandCombobox({
onOpenFileExplorer,
onOpenFile,
Expand Down Expand Up @@ -811,6 +858,10 @@ export function CommandCombobox({
}))
.filter((group) => group.actions.length > 0),
];
const numberedActions = commandNumberedActions(displayedActionGroups);
const numberShortcutIndexByKey = new Map(
numberedActions.map((action, index) => [action.key, index]),
);
const firstDisplayedActionValue = displayedActionGroups[0]?.actions[0]
? actionCommandValue(displayedActionGroups[0].actions[0])
: "";
Expand Down Expand Up @@ -855,7 +906,15 @@ export function CommandCombobox({
<ChevronsUpDown size={14} />
</button>
</PopoverTrigger>
<PopoverContent className="command-popover" align="end">
<PopoverContent
className="command-popover"
align="end"
onKeyDownCapture={(event) => {
runCommandNumberShortcut(event, numberedActions, (action) =>
run(action.run),
);
}}
>
<Command
loop
shouldFilter={false}
Expand All @@ -882,6 +941,9 @@ export function CommandCombobox({
title={action.title}
detail={action.detail}
shortcut={action.shortcut}
numberShortcutIndex={numberShortcutIndexByKey.get(
action.key,
)}
keywords={action.keywords}
danger={action.danger}
onSelect={() => run(action.run)}
Expand Down Expand Up @@ -995,6 +1057,7 @@ function ActionItem({
title,
detail,
shortcut,
numberShortcutIndex,
keywords,
danger,
onSelect,
Expand All @@ -1004,23 +1067,33 @@ function ActionItem({
title: string;
detail?: string;
shortcut?: string;
numberShortcutIndex?: number;
keywords?: string[];
danger?: boolean;
onSelect: () => void;
}) {
const numberShortcut =
numberShortcutIndex === undefined ? null : `⌘${numberShortcutIndex + 1}`;
return (
<CommandItem
value={value}
keywords={keywords}
onSelect={onSelect}
className={danger ? "is-danger" : undefined}
aria-keyshortcuts={
numberShortcutIndex === undefined
? undefined
: `Meta+${numberShortcutIndex + 1}`
}
>
<span className="command-item-icon">{icon}</span>
<span className="command-item-text">
<span className="command-item-title">{title}</span>
{detail ? <span className="command-item-detail">{detail}</span> : null}
</span>
{shortcut ? <CommandShortcut>{shortcut}</CommandShortcut> : null}
{numberShortcut || shortcut ? (
<CommandShortcut>{numberShortcut ?? shortcut}</CommandShortcut>
) : null}
</CommandItem>
);
}
1 change: 1 addition & 0 deletions web/src/components/ShortcutLookupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
title: "Global",
shortcuts: [
{ keys: "Cmd/Ctrl+K", description: "Open or close the command menu" },
{ keys: "Cmd+1 ... Cmd+9 in menu", description: "Run a numbered command menu action" },
{ keys: "Cmd+B", description: "Toggle the sidebar on desktop" },
{ keys: "Ctrl+Tab / Ctrl+Shift+Tab", description: "Open the recent pane switcher" },
{ keys: "Cmd+T", description: "Create a tab in the focused workspace" },
Expand Down