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
50 changes: 32 additions & 18 deletions apps/code/src/renderer/components/GlobalEventHandlers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace";
import { useWorkspaces } from "@features/workspace/hooks/useWorkspace";
import { useShortcut } from "@hooks/useShortcut";
import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts";
import { useTRPC } from "@renderer/trpc";
import { isMac } from "@renderer/utils/platform";
import type { Task } from "@shared/types";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { useNavigationStore } from "@stores/navigationStore";
Expand Down Expand Up @@ -157,33 +159,43 @@ export function GlobalEventHandlers({
preventDefault: true,
} as const;

useHotkeys(SHORTCUTS.COMMAND_MENU, onToggleCommandMenu, {
const commandMenuKey = useShortcut("command-menu");
const newTaskKey = useShortcut("new-task");
const settingsKey = useShortcut("settings");
const goBackKey = useShortcut("go-back");
const goForwardKey = useShortcut("go-forward");
const toggleLeftSidebarKey = useShortcut("toggle-left-sidebar");
const toggleReviewPanelKey = useShortcut("toggle-review-panel");
const shortcutsSheetKey = useShortcut("shortcuts");
const inboxKey = useShortcut("inbox");
const prevTaskKey = useShortcut("prev-task");
const nextTaskKey = useShortcut("next-task");
const toggleFocusKey = useShortcut("toggle-focus");

useHotkeys(commandMenuKey, onToggleCommandMenu, {
...globalOptions,
enabled: !commandMenuOpen,
});
useHotkeys(SHORTCUTS.NEW_TASK, handleFocusTaskMode, globalOptions);
useHotkeys(SHORTCUTS.SETTINGS, handleOpenSettings, globalOptions);
useHotkeys(SHORTCUTS.GO_BACK, goBack, globalOptions);
useHotkeys(SHORTCUTS.GO_FORWARD, goForward, globalOptions);
useHotkeys(newTaskKey, handleFocusTaskMode, globalOptions);
useHotkeys(settingsKey, handleOpenSettings, globalOptions);
useHotkeys(goBackKey, goBack, globalOptions);
useHotkeys(goForwardKey, goForward, globalOptions);

const handleToggleReview = useCallback(() => {
if (!currentTaskId) return;
const mode = getReviewMode(currentTaskId);
setReviewMode(currentTaskId, mode === "closed" ? "split" : "closed");
}, [currentTaskId, getReviewMode, setReviewMode]);

useHotkeys(SHORTCUTS.TOGGLE_LEFT_SIDEBAR, toggleLeftSidebar, globalOptions);
useHotkeys(SHORTCUTS.TOGGLE_REVIEW_PANEL, handleToggleReview, globalOptions);
useHotkeys(SHORTCUTS.SHORTCUTS_SHEET, onToggleShortcutsSheet, globalOptions);
useHotkeys(SHORTCUTS.INBOX, navigateToInbox, globalOptions);
useHotkeys(SHORTCUTS.PREV_TASK, handlePrevTask, globalOptions, [
handlePrevTask,
]);
useHotkeys(SHORTCUTS.NEXT_TASK, handleNextTask, globalOptions, [
handleNextTask,
]);
useHotkeys(toggleLeftSidebarKey, toggleLeftSidebar, globalOptions);
useHotkeys(toggleReviewPanelKey, handleToggleReview, globalOptions);
useHotkeys(shortcutsSheetKey, onToggleShortcutsSheet, globalOptions);
useHotkeys(inboxKey, navigateToInbox, globalOptions);
useHotkeys(prevTaskKey, handlePrevTask, globalOptions, [handlePrevTask]);
useHotkeys(nextTaskKey, handleNextTask, globalOptions, [handleNextTask]);

useHotkeys(
SHORTCUTS.TOGGLE_FOCUS,
toggleFocusKey,
handleToggleFocus,
{
...globalOptions,
Expand All @@ -192,11 +204,13 @@ export function GlobalEventHandlers({
[handleToggleFocus],
);

// Task switching with mod+1-9
// Task switching with mod+1-9. On macOS, Ctrl+1..9 is reserved for
// SWITCH_TAB (panel tabs), so ignore plain-Ctrl there; on Windows/Linux,
// Ctrl IS mod, so the same event must trigger task switching.
useHotkeys(
SHORTCUTS.SWITCH_TASK,
(event, handler) => {
if (event.ctrlKey && !event.metaKey) return;
if (isMac && event.ctrlKey && !event.metaKey) return;

const keyPressed = handler.keys?.[0];
if (!keyPressed) return;
Expand Down
138 changes: 84 additions & 54 deletions apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,19 @@
import { Box, Dialog, Flex, Text } from "@radix-ui/themes";
import { Keycap } from "@components/Keycap";
import { ShortcutRecorder } from "@components/ShortcutRecorder";
import { Tooltip } from "@components/ui/Tooltip";
import { useShortcut } from "@hooks/useShortcut";
import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes";
import {
CATEGORY_LABELS,
type ConfigurableShortcutId,
formatHotkeyParts,
getShortcutsByCategory,
type ShortcutCategory,
} from "@renderer/constants/keyboard-shortcuts";
import { useMemo, useState } from "react";
import { useKeybindingsStore } from "@stores/keybindingsStore";
import { useMemo } from "react";
import { useHotkeys } from "react-hotkeys-hook";

function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) {
const [pressed, setPressed] = useState(false);
const isSmall = size === "sm";
const minW = isSmall ? "22px" : "28px";
const h = isSmall ? "22px" : "28px";
const fontSize = isSmall ? "11px" : "13px";
const shadowSize = isSmall ? "2px" : "3px";

return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation
<span
role="presentation"
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
minWidth: minW,
height: h,
fontSize,
fontFamily: "system-ui, -apple-system, sans-serif",
lineHeight: 1,
borderBottomWidth: pressed ? "1px" : shadowSize,
borderBottomColor: "var(--gray-7)",
transform: pressed
? `translateY(${isSmall ? "1px" : "2px"})`
: "translateY(0)",
transition:
"transform 80ms ease-out, border-bottom-width 80ms ease-out",
}}
className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)"
>
{label}
</span>
);
}

interface KeyboardShortcutsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
Expand All @@ -65,9 +35,9 @@ export function KeyboardShortcutsSheet({
<Dialog.Content
maxWidth="600px"
onEscapeKeyDown={(e) => e.preventDefault()}
className="max-h-[80vh] overflow-hidden"
className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden"
>
<Flex align="start" justify="between" className="relative">
<Flex align="start" justify="between" className="shrink-0 pb-2">
<ShortcutsHeader />
<button
type="button"
Expand All @@ -78,16 +48,51 @@ export function KeyboardShortcutsSheet({
</button>
</Flex>

<Box className="max-h-[calc(80vh-120px)] overflow-y-auto pr-[8px]">
<Box className="min-h-0 flex-1 overflow-y-auto pr-[8px]">
<KeyboardShortcutsList />
{/* Bottom padding so list content doesn't sit behind the sticky footer */}
<Box className="h-[56px]" />
</Box>

<ResetAllFooter />
</Dialog.Content>
</Dialog.Root>
);
}

function ResetAllFooter() {
const hasCustomBindings = useKeybindingsStore((s) =>
Object.keys(s.customKeybindings).some(
(k) =>
(s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0,
),
);
const resetAll = useKeybindingsStore((s) => s.resetAll);

if (!hasCustomBindings) return null;

return (
<Flex
justify="center"
align="center"
className="shrink-0 border-(--gray-4) border-t py-3"
>
<Button
variant="ghost"
color="gray"
size="1"
onClick={resetAll}
className="cursor-pointer"
>
Reset all shortcuts to defaults
</Button>
</Flex>
);
}

function ShortcutsHeader() {
const triggerParts = formatHotkeyParts("mod+/");
const shortcutsKey = useShortcut("shortcuts");
const triggerParts = formatHotkeyParts(shortcutsKey);

return (
<Box mb="4">
Expand Down Expand Up @@ -148,14 +153,30 @@ export function KeyboardShortcutsList() {
key={shortcut.id}
align="center"
justify="between"
gap="3"
px="3"
className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)"
className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)"
>
<Text className="text-sm">{shortcut.description}</Text>
<ShortcutKeys
keys={shortcut.keys}
alternateKeys={shortcut.alternateKeys}
/>
<Flex direction="column" gap="0" className="min-w-0 flex-1">
<Text className="text-sm">{shortcut.description}</Text>
{shortcut.context && (
<Text color="gray" className="text-[11px]">
{shortcut.context}
</Text>
)}
</Flex>
<div className="shrink-0">
{shortcut.configurable ? (
<ShortcutRecorder
id={shortcut.id as ConfigurableShortcutId}
/>
) : (
<ShortcutKeys
keys={shortcut.keys}
alternateKeys={shortcut.alternateKeys}
/>
)}
</div>
</Flex>
))}
</Box>
Expand All @@ -168,7 +189,6 @@ export function KeyboardShortcutsList() {

function SingleShortcutKeys({ keys }: { keys: string }) {
const parts = formatHotkeyParts(keys);

return (
<Flex gap="1" align="center">
{parts.map((part) => (
Expand All @@ -185,17 +205,27 @@ function ShortcutKeys({
keys: string;
alternateKeys?: string;
}) {
if (!alternateKeys) {
return <SingleShortcutKeys keys={keys} />;
}

return (
const inner = alternateKeys ? (
<Flex gap="1" align="center">
<SingleShortcutKeys keys={keys} />
<Text color="gray" className="text-[13px]">
or
</Text>
<SingleShortcutKeys keys={alternateKeys} />
</Flex>
) : (
<SingleShortcutKeys keys={keys} />
);

return (
<Tooltip
content="This shortcut cannot be customized"
side="left"
delayDuration={400}
>
<div className="cursor-default rounded-(--radius-1) p-[2px] transition-colors hover:bg-(--gray-4)">
{inner}
</div>
</Tooltip>
);
}
42 changes: 42 additions & 0 deletions apps/code/src/renderer/components/Keycap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useState } from "react";

interface KeycapProps {
label: string;
size?: "sm" | "md";
}

export function Keycap({ label, size = "md" }: KeycapProps) {
const [pressed, setPressed] = useState(false);
const isSmall = size === "sm";
const minW = isSmall ? "22px" : "28px";
const h = isSmall ? "22px" : "28px";
const fontSize = isSmall ? "11px" : "13px";
const shadowSize = isSmall ? "2px" : "3px";

return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation
<span
role="presentation"
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
minWidth: minW,
height: h,
fontSize,
fontFamily: "system-ui, -apple-system, sans-serif",
lineHeight: 1,
borderBottomWidth: pressed ? "1px" : shadowSize,
borderBottomColor: "var(--gray-7)",
transform: pressed
? `translateY(${isSmall ? "1px" : "2px"})`
: "translateY(0)",
transition:
"transform 80ms ease-out, border-bottom-width 80ms ease-out",
}}
className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)"
>
{label}
</span>
);
}
Loading