feat: add shortcuts

This commit is contained in:
zhom
2026-05-17 20:38:56 +04:00
parent 9e777ed37b
commit 36263eac04
17 changed files with 1084 additions and 22 deletions
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear } from "react-icons/go";
import {
LuCircleStop,
LuCloud,
LuInfo,
LuKeyboard,
LuPlay,
LuPlug,
LuPuzzle,
LuUser,
LuUsers,
} from "react-icons/lu";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
formatGroupShortcut,
formatShortcut,
SHORTCUTS,
type ShortcutDef,
type ShortcutId,
} from "@/lib/shortcuts";
import type { BrowserProfile } from "@/types";
interface GroupTarget {
id: string;
name: string;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAction: (id: ShortcutId) => void;
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
groupTargets: GroupTarget[];
onSelectGroup: (id: string) => void;
/** All profiles for launch/stop/info entries. */
profiles: BrowserProfile[];
runningProfileIds: Set<string>;
onLaunchProfile: (profile: BrowserProfile) => void;
onKillProfile: (profile: BrowserProfile) => void;
onShowProfileInfo: (profile: BrowserProfile) => void;
}
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
goExtensions: LuPuzzle,
goGroups: LuUsers,
goIntegrations: LuPlug,
goAccount: LuCloud,
goSettings: GoGear,
};
function Tokens({ tokens }: { tokens: string[] }) {
return (
<CommandShortcut className="flex items-center gap-0.5">
{tokens.map((tok, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
>
{tok}
</kbd>
))}
</CommandShortcut>
);
}
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
return <Tokens tokens={formatShortcut(shortcut)} />;
}
/**
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
* to appear as a substring somewhere in the item's value or its keywords; the
* score is reduced when tokens appear later in the haystack so a closer match
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
* requires tokens in document order so it would otherwise return zero.
*/
function fuzzyFilter(
value: string,
search: string,
keywords?: string[],
): number {
if (!search.trim()) return 1;
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
let score = 0;
for (const tok of tokens) {
const idx = haystack.indexOf(tok);
if (idx === -1) return 0;
score += 1 / (1 + idx);
}
return score / tokens.length;
}
export function CommandPalette({
open,
onOpenChange,
onAction,
groupTargets,
onSelectGroup,
profiles,
runningProfileIds,
onLaunchProfile,
onKillProfile,
onShowProfileInfo,
}: CommandPaletteProps) {
const { t } = useTranslation();
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
// on the next tick so an action that opens another dialog doesn't race
// this one's close animation.
const dispatch = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const byGroup = (group: ShortcutDef["group"]) =>
SHORTCUTS.filter((s) => s.group === group);
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
// We still display more in the palette (without a shortcut hint) so the
// user can search/jump to any of them.
const renderGroup = (target: GroupTarget, index: number) => (
<CommandItem
key={target.id}
onSelect={() => {
dispatch(() => {
onSelectGroup(target.id);
});
}}
>
<LuUsers />
<span>{target.name}</span>
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
</CommandItem>
);
return (
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
<CommandInput placeholder={t("commandPalette.placeholder")} />
<CommandList>
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
<CommandGroup heading={t("commandPalette.groups.navigation")}>
{byGroup("navigation").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
{groupTargets.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
{groupTargets.map((target, i) => renderGroup(target, i))}
</CommandGroup>
</>
) : null}
{profiles.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.profiles")}>
{profiles.map((p) => {
const running = runningProfileIds.has(p.id);
return running ? (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onKillProfile(p);
});
}}
>
<LuCircleStop />
<span>
{t("commandPalette.actions.stopProfile", {
name: p.name,
})}
</span>
</CommandItem>
) : (
<CommandItem
key={`run-${p.id}`}
onSelect={() => {
dispatch(() => {
onLaunchProfile(p);
});
}}
>
<LuPlay />
<span>
{t("commandPalette.actions.launchProfile", {
name: p.name,
})}
</span>
</CommandItem>
);
})}
{profiles.map((p) => (
<CommandItem
key={`info-${p.id}`}
onSelect={() => {
dispatch(() => {
onShowProfileInfo(p);
});
}}
>
<LuInfo />
<span>
{t("commandPalette.actions.profileInfo", { name: p.name })}
</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.actions")}>
{byGroup("actions").map((s) => {
const Icon = ICONS[s.id];
return (
<CommandItem
key={s.id}
onSelect={() => {
dispatch(() => {
onAction(s.id);
});
}}
>
<Icon />
<span>{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
@@ -130,6 +130,8 @@ interface ExtensionManagementDialogProps {
onClose: () => void;
limitedMode: boolean;
subPage?: boolean;
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
initialTab?: "extensions" | "groups";
}
export function ExtensionManagementDialog({
@@ -137,6 +139,7 @@ export function ExtensionManagementDialog({
onClose,
limitedMode,
subPage,
initialTab = "extensions",
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
@@ -208,9 +211,10 @@ export function ExtensionManagementDialog({
Record<string, boolean>
>({});
// Tab
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
"extensions",
initialTab,
);
const loadData = useCallback(async () => {
@@ -1120,6 +1124,7 @@ export function ExtensionManagementDialog({
)}
<AnimatedTabs
key={initialTab}
value={activeTab}
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
className="flex-1 min-h-0 flex flex-col"
+4 -1
View File
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
/** Which tab is displayed when the dialog mounts; defaults to "api". */
initialTab?: "api" | "mcp";
}
function AgentIcon({ category }: { category: AgentCategory }) {
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
isOpen,
onClose,
subPage,
initialTab = "api",
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -310,7 +313,7 @@ export function IntegrationsDialog({
)}
<div className="overflow-y-auto flex-1 min-h-0">
<AnimatedTabs defaultValue="api">
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
+25 -2
View File
@@ -1052,6 +1052,13 @@ interface ProfilesDataTableProps {
onSetPassword?: (profile: BrowserProfile) => void;
onChangePassword?: (profile: BrowserProfile) => void;
onRemovePassword?: (profile: BrowserProfile) => void;
/**
* When provided, the info dialog is controlled by the parent. Allows the
* command palette in page.tsx to open the dialog directly without lifting
* every other piece of internal table state.
*/
infoDialogProfile?: BrowserProfile | null;
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
}
export function ProfilesDataTable({
@@ -1084,6 +1091,8 @@ export function ProfilesDataTable({
onSetPassword,
onChangePassword,
onRemovePassword,
infoDialogProfile,
onInfoDialogProfileChange,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -1155,8 +1164,22 @@ export function ProfilesDataTable({
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [profileForInfoDialog, setProfileForInfoDialog] =
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
React.useState<BrowserProfile | null>(null);
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
const profileForInfoDialog = isInfoDialogControlled
? (infoDialogProfile ?? null)
: internalInfoDialogProfile;
const setProfileForInfoDialog = React.useCallback(
(p: BrowserProfile | null) => {
if (isInfoDialogControlled) {
onInfoDialogProfileChange?.(p);
} else {
setInternalInfoDialogProfile(p);
}
},
[isInfoDialogControlled, onInfoDialogProfileChange],
);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
@@ -2836,7 +2859,7 @@ export function ProfilesDataTable({
},
},
],
[t],
[t, setProfileForInfoDialog],
);
const table = useReactTable({
+16 -2
View File
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
import {
LuCloud,
LuKeyboard,
LuPlug,
LuPuzzle,
LuUser,
LuUsers,
} from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -19,7 +26,8 @@ export type AppPage =
| "settings"
| "integrations"
| "account"
| "import";
| "import"
| "shortcuts";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
page: "shortcuts",
Icon: LuKeyboard,
labelKey: "rail.more.keyboardShortcuts",
hintKey: "rail.more.keyboardShortcutsHint",
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useTranslation } from "react-i18next";
import {
formatGroupShortcut,
formatShortcut,
SHORTCUTS,
type ShortcutDef,
} from "@/lib/shortcuts";
interface GroupTarget {
id: string;
name: string;
}
interface ShortcutsPageProps {
/** Ordered list — first 9 entries display their Mod+digit binding. */
groupTargets: GroupTarget[];
}
function Tokens({ tokens }: { tokens: string[] }) {
return (
<div className="flex items-center gap-1">
{tokens.map((tok, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted text-[11px] font-medium text-foreground"
>
{tok}
</kbd>
))}
</div>
);
}
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
return <Tokens tokens={formatShortcut(shortcut)} />;
}
export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
const { t } = useTranslation();
const sections: Array<{ key: ShortcutDef["group"]; titleKey: string }> = [
{ key: "navigation", titleKey: "commandPalette.groups.navigation" },
{ key: "actions", titleKey: "commandPalette.groups.actions" },
];
const digitGroups = groupTargets.slice(0, 9);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-6 pt-4 pb-8">
<div className="max-w-3xl w-full mx-auto flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">{t("shortcutsPage.title")}</h1>
<p className="text-xs text-muted-foreground">
{t("shortcutsPage.description")}
</p>
</header>
{sections.map(({ key, titleKey }) => {
const items = SHORTCUTS.filter((s) => s.group === key);
if (items.length === 0) return null;
return (
<section key={key} className="flex flex-col gap-2">
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t(titleKey)}
</h2>
<div className="rounded-md border bg-card divide-y divide-border">
{items.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</div>
))}
</div>
</section>
);
})}
{digitGroups.length > 0 ? (
<section className="flex flex-col gap-2">
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("commandPalette.groups.profileGroups")}
</h2>
<div className="rounded-md border bg-card divide-y divide-border">
{digitGroups.map((target, i) => (
<div
key={target.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{target.name}</span>
<Tokens tokens={formatGroupShortcut(i + 1)} />
</div>
))}
</div>
</section>
) : null}
</div>
</div>
);
}
+9 -1
View File
@@ -34,10 +34,14 @@ function CommandDialog({
title,
description,
children,
filter,
shouldFilter,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
}) {
const { t } = useTranslation();
const resolvedTitle = title ?? t("common.commandPalette.title");
@@ -50,7 +54,11 @@ function CommandDialog({
<DialogDescription>{resolvedDescription}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<Command
filter={filter}
shouldFilter={shouldFilter}
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>