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
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"radix-ui": "^1.4.3",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid mixing unified and per-primitive Radix deps

Adding radix-ui here while keeping the existing @radix-ui/react-* dependencies creates a mixed dependency strategy that now resolves duplicate Radix primitive versions in the lockfile (for example, multiple @radix-ui/react-separator versions). This increases install/build footprint and raises the chance of subtle UI inconsistencies from version skew; use either the unified radix-ui package consistently or keep per-primitive packages only.

Useful? React with 👍 / 👎.

"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.3.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/app/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { KeyboardShortcutsDialog } from "@/components/keyboard/shortcuts-dialog";

export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
Expand All @@ -11,6 +12,7 @@ function RootLayout() {
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<Outlet />
<KeyboardShortcutsDialog />
</div>
);
}
19 changes: 11 additions & 8 deletions packages/web/src/components/files/collapsible-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { type ReactNode, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";

interface CollapsiblePickerProps {
Expand All @@ -12,7 +12,8 @@ interface CollapsiblePickerProps {
children: ReactNode;
className?: string;
zIndex?: number;
defaultExpanded?: boolean;
isCollapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}

export function CollapsiblePicker({
Expand All @@ -24,21 +25,23 @@ export function CollapsiblePicker({
children,
className,
zIndex = 30,
defaultExpanded = true,
isCollapsed,
onCollapsedChange,
}: CollapsiblePickerProps) {
const [isCollapsed, setIsCollapsed] = useState(!defaultExpanded);

useEffect(() => {
const mql = window.matchMedia("(max-width: 768px)");
const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches) setIsCollapsed(true);
if (e.matches) onCollapsedChange(true);
};
handleChange(mql);
mql.addEventListener("change", handleChange);
return () => mql.removeEventListener("change", handleChange);
}, []);
}, [onCollapsedChange]);

const toggleCollapsed = useCallback(() => setIsCollapsed((prev) => !prev), []);
const toggleCollapsed = useCallback(
() => onCollapsedChange(!isCollapsed),
[isCollapsed, onCollapsedChange],
);

const header = (
<div
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/components/files/file-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface FilePickerProps {
viewedPathSet?: ReadonlySet<string>;
onSelectFile?: (filePath: string) => void;
className?: string;
isCollapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}

export function FilePicker({
Expand All @@ -20,6 +22,8 @@ export function FilePicker({
viewedPathSet,
onSelectFile,
className,
isCollapsed,
onCollapsedChange,
}: FilePickerProps) {
const [filter, setFilter] = useState("");

Expand Down Expand Up @@ -68,6 +72,8 @@ export function FilePicker({
count={files.length}
className={className}
headerExtra={filterInput}
isCollapsed={isCollapsed}
onCollapsedChange={onCollapsedChange}
collapsedIndicators={files.map((file) => (
<div
key={file.path}
Expand Down
51 changes: 51 additions & 0 deletions packages/web/src/components/keyboard/shortcut-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ArrowBigUp,
ChevronUp,
Command,
CornerDownLeft,
Delete,
Option,
Space,
} from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";

const MODIFIER_ICONS: Record<string, FC<{ className?: string }>> = {
"⌘": Command,
"⇧": ArrowBigUp,
"⌥": Option,
"⌃": ChevronUp,
"⌫": Delete,
"↩": CornerDownLeft,
"␣": Space,
};

interface ShortcutLabelProps {
label: string;
}

export function ShortcutLabel({ label }: ShortcutLabelProps) {
return (
<span className="inline-flex items-center gap-0.5">
{label
.split(" ")
.filter(Boolean)
.map((segment, index) => {
const Icon = MODIFIER_ICONS[segment];
const isSingleChar = !Icon && segment.length <= 1;
return (
<kbd
// biome-ignore lint/suspicious/noArrayIndexKey: label segments are static for a given shortcut and may repeat
key={`${index}-${segment}`}
className={cn(
"inline-flex items-center justify-center rounded border border-border bg-black/5 dark:bg-white/10 text-[10px]",
isSingleChar ? "size-4" : "px-1 py-0.5",
)}
>
{Icon ? <Icon className="size-2.5" /> : segment}
</kbd>
);
})}
</span>
);
}
53 changes: 53 additions & 0 deletions packages/web/src/components/keyboard/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getShortcutsByGroup, KEYBOARD_SHORTCUTS, SHORTCUT_KEY } from "@/lib/keyboard-shortcuts";
import { useIsMac } from "@/lib/use-is-mac";
import { ShortcutLabel } from "./shortcut-label";

export function KeyboardShortcutsDialog() {
const [open, setOpen] = useState(false);
const isMac = useIsMac();

const shortcut = KEYBOARD_SHORTCUTS[SHORTCUT_KEY.SHOW_SHORTCUTS];

useHotkeys(shortcut.hotkey, () => setOpen((prev) => !prev), {
preventDefault: true,
...shortcut.hotkeyOptions,
});

const groups = getShortcutsByGroup();

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{[...groups.entries()].map(([group, shortcuts]) => (
<div key={group}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{group}
</h3>
<div className="space-y-1">
{shortcuts.map(({ key, entry }) => {
const platform = isMac ? entry.mac : entry.nonMac;
return (
<div
key={key}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-sm">{entry.description}</span>
<ShortcutLabel label={platform.label} />
</div>
);
})}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
141 changes: 141 additions & 0 deletions packages/web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}

function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}

function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}

function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}

function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}

function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}

function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}

function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}

export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
Loading
Loading