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
+174
View File
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -34,6 +35,7 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog";
import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
@@ -53,6 +55,12 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
matchesGroupDigit,
matchesShortcut,
SHORTCUTS,
type ShortcutId,
} from "@/lib/shortcuts";
import {
dismissToast,
showErrorToast,
@@ -149,6 +157,11 @@ export default function Home() {
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns"
>("proxies");
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
useState<"extensions" | "groups">("extensions");
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -221,6 +234,11 @@ export default function Home() {
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
useState<BrowserProfile | null>(null);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
@@ -273,9 +291,134 @@ export default function Home() {
case "account":
setAccountDialogOpen(true);
break;
case "shortcuts":
// Plain page render — nothing else to open.
break;
}
}, []);
const runShortcut = useCallback(
(id: ShortcutId) => {
switch (id) {
case "openPalette":
setCommandPaletteOpen(true);
break;
case "openShortcuts":
handleRailNavigate("shortcuts");
break;
case "importProfile":
handleRailNavigate("import");
break;
case "goProfiles":
handleRailNavigate("profiles");
break;
case "goProxies": {
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
// initialTab, so we just pick the right destination.
if (currentPage === "proxies") {
handleRailNavigate("vpns");
} else if (currentPage === "vpns") {
handleRailNavigate("proxies");
} else {
handleRailNavigate(
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
);
}
break;
}
case "goExtensions": {
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
if (currentPage === "extensions") {
setExtensionManagementInitialTab((cur) =>
cur === "extensions" ? "groups" : "extensions",
);
} else {
handleRailNavigate("extensions");
}
break;
}
case "goGroups":
handleRailNavigate("groups");
break;
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
} else {
handleRailNavigate("integrations");
}
break;
}
case "goAccount":
handleRailNavigate("account");
break;
case "goSettings":
handleRailNavigate("settings");
break;
}
},
[handleRailNavigate, currentPage, proxyManagementInitialTab],
);
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
const orderedGroupTargets = useMemo(
() => [
{ id: "__all__", name: t("rail.profiles") },
...groupsData.map((g) => ({ id: g.id, name: g.name })),
],
[groupsData, t],
);
const selectGroupByDigit = useCallback(
(digit: number) => {
const target = orderedGroupTargets[digit - 1];
if (!target) return;
handleRailNavigate("profiles");
handleSelectGroup(target.id);
},
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
);
useEffect(() => {
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const isTyping =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable === true;
const digit = matchesGroupDigit(e);
if (digit !== null) {
if (isTyping) return;
if (digit - 1 >= orderedGroupTargets.length) return;
e.preventDefault();
selectGroupByDigit(digit);
return;
}
for (const s of SHORTCUTS) {
if (!matchesShortcut(s, e)) continue;
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
return;
}
e.preventDefault();
runShortcut(s.id);
return;
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
@@ -1306,6 +1449,8 @@ export default function Home() {
{isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable
profiles={filteredProfiles}
infoDialogProfile={profileInfoDialog}
onInfoDialogProfileChange={setProfileInfoDialog}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
@@ -1344,6 +1489,10 @@ export default function Home() {
</div>
)}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
@@ -1368,6 +1517,7 @@ export default function Home() {
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
initialTab={integrationsInitialTab}
/>
)}
@@ -1404,6 +1554,7 @@ export default function Home() {
}}
limitedMode={false}
subPage={currentPage === "extensions"}
initialTab={extensionManagementInitialTab}
/>
)}
@@ -1447,6 +1598,29 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
onAction={runShortcut}
groupTargets={orderedGroupTargets}
onSelectGroup={(id) => {
handleRailNavigate("profiles");
handleSelectGroup(id);
}}
profiles={profiles}
runningProfileIds={runningProfiles}
onLaunchProfile={(profile) => {
void launchProfile(profile);
}}
onKillProfile={(profile) => {
void handleKillProfile(profile);
}}
onShowProfileInfo={(profile) => {
handleRailNavigate("profiles");
setProfileInfoDialog(profile);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
+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>
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "More",
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool"
"importProfileHint": "Bring profiles from another tool",
"keyboardShortcuts": "Keyboard shortcuts",
"keyboardShortcutsHint": "View all shortcuts"
},
"network": "Network",
"integrations": "Integrations",
@@ -1817,7 +1819,8 @@
"settings": "Settings",
"integrations": "Integrations",
"account": "Account",
"import": "Import profile"
"import": "Import profile",
"shortcuts": "Keyboard shortcuts"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "Test connection",
"disconnect": "Disconnect"
}
},
"shortcutsPage": {
"title": "Keyboard shortcuts",
"description": "Speed up your workflow with these shortcuts."
},
"commandPalette": {
"placeholder": "Type a command or search...",
"empty": "No results found.",
"groups": {
"navigation": "Navigation",
"profiles": "Profiles",
"actions": "Actions",
"profileGroups": "Profile groups"
},
"actions": {
"launchProfile": "Launch {{name}}",
"stopProfile": "Stop {{name}}",
"profileInfo": "Info — {{name}}"
}
},
"shortcuts": {
"openPalette": "Open command palette",
"openShortcuts": "View keyboard shortcuts",
"importProfile": "Import profile",
"goProfiles": "Go to Profiles",
"goProxies": "Go to Network",
"goExtensions": "Go to Extensions",
"goGroups": "Go to Groups",
"goIntegrations": "Go to Integrations",
"goAccount": "Go to Account",
"goSettings": "Go to Settings"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "Más",
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta"
"importProfileHint": "Trae perfiles de otra herramienta",
"keyboardShortcuts": "Atajos de teclado",
"keyboardShortcutsHint": "Ver todos los atajos"
},
"network": "Red",
"integrations": "Integraciones",
@@ -1817,7 +1819,8 @@
"settings": "Ajustes",
"integrations": "Integraciones",
"account": "Cuenta",
"import": "Importar perfil"
"import": "Importar perfil",
"shortcuts": "Atajos de teclado"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "Probar conexión",
"disconnect": "Desconectar"
}
},
"shortcutsPage": {
"title": "Atajos de teclado",
"description": "Agiliza tu flujo de trabajo con estos atajos."
},
"commandPalette": {
"placeholder": "Escribe un comando o busca...",
"empty": "No se encontraron resultados.",
"groups": {
"navigation": "Navegación",
"profiles": "Perfiles",
"actions": "Acciones",
"profileGroups": "Grupos de perfiles"
},
"actions": {
"launchProfile": "Iniciar {{name}}",
"stopProfile": "Detener {{name}}",
"profileInfo": "Información — {{name}}"
}
},
"shortcuts": {
"openPalette": "Abrir paleta de comandos",
"openShortcuts": "Ver atajos de teclado",
"importProfile": "Importar perfil",
"goProfiles": "Ir a Perfiles",
"goProxies": "Ir a Red",
"goExtensions": "Ir a Extensiones",
"goGroups": "Ir a Grupos",
"goIntegrations": "Ir a Integraciones",
"goAccount": "Ir a Cuenta",
"goSettings": "Ir a Configuración"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "Plus",
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil"
"importProfileHint": "Importer depuis un autre outil",
"keyboardShortcuts": "Raccourcis clavier",
"keyboardShortcutsHint": "Voir tous les raccourcis"
},
"network": "Réseau",
"integrations": "Intégrations",
@@ -1817,7 +1819,8 @@
"settings": "Paramètres",
"integrations": "Intégrations",
"account": "Compte",
"import": "Importer un profil"
"import": "Importer un profil",
"shortcuts": "Raccourcis clavier"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "Tester la connexion",
"disconnect": "Déconnecter"
}
},
"shortcutsPage": {
"title": "Raccourcis clavier",
"description": "Accélérez votre flux de travail avec ces raccourcis."
},
"commandPalette": {
"placeholder": "Tapez une commande ou recherchez...",
"empty": "Aucun résultat trouvé.",
"groups": {
"navigation": "Navigation",
"profiles": "Profils",
"actions": "Actions",
"profileGroups": "Groupes de profils"
},
"actions": {
"launchProfile": "Lancer {{name}}",
"stopProfile": "Arrêter {{name}}",
"profileInfo": "Informations — {{name}}"
}
},
"shortcuts": {
"openPalette": "Ouvrir la palette de commandes",
"openShortcuts": "Voir les raccourcis clavier",
"importProfile": "Importer un profil",
"goProfiles": "Aller à Profils",
"goProxies": "Aller à Réseau",
"goExtensions": "Aller à Extensions",
"goGroups": "Aller à Groupes",
"goIntegrations": "Aller à Intégrations",
"goAccount": "Aller à Compte",
"goSettings": "Aller à Paramètres"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "その他",
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む"
"importProfileHint": "別のツールから取り込む",
"keyboardShortcuts": "キーボードショートカット",
"keyboardShortcutsHint": "すべてのショートカットを表示"
},
"network": "ネットワーク",
"integrations": "連携",
@@ -1817,7 +1819,8 @@
"settings": "設定",
"integrations": "連携",
"account": "アカウント",
"import": "プロファイルをインポート"
"import": "プロファイルをインポート",
"shortcuts": "キーボードショートカット"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "接続をテスト",
"disconnect": "切断"
}
},
"shortcutsPage": {
"title": "キーボードショートカット",
"description": "これらのショートカットでワークフローを高速化できます。"
},
"commandPalette": {
"placeholder": "コマンドを入力するか検索...",
"empty": "結果が見つかりませんでした。",
"groups": {
"navigation": "ナビゲーション",
"profiles": "プロファイル",
"actions": "アクション",
"profileGroups": "プロファイルグループ"
},
"actions": {
"launchProfile": "{{name}} を起動",
"stopProfile": "{{name}} を停止",
"profileInfo": "情報 — {{name}}"
}
},
"shortcuts": {
"openPalette": "コマンドパレットを開く",
"openShortcuts": "キーボードショートカットを表示",
"importProfile": "プロファイルをインポート",
"goProfiles": "プロファイルへ移動",
"goProxies": "ネットワークへ移動",
"goExtensions": "拡張機能へ移動",
"goGroups": "グループへ移動",
"goIntegrations": "統合へ移動",
"goAccount": "アカウントへ移動",
"goSettings": "設定へ移動"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "Mais",
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta"
"importProfileHint": "Trazer perfis de outra ferramenta",
"keyboardShortcuts": "Atalhos de teclado",
"keyboardShortcutsHint": "Ver todos os atalhos"
},
"network": "Rede",
"integrations": "Integrações",
@@ -1817,7 +1819,8 @@
"settings": "Configurações",
"integrations": "Integrações",
"account": "Conta",
"import": "Importar perfil"
"import": "Importar perfil",
"shortcuts": "Atalhos de teclado"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "Testar conexão",
"disconnect": "Desconectar"
}
},
"shortcutsPage": {
"title": "Atalhos de teclado",
"description": "Acelere seu fluxo de trabalho com estes atalhos."
},
"commandPalette": {
"placeholder": "Digite um comando ou pesquise...",
"empty": "Nenhum resultado encontrado.",
"groups": {
"navigation": "Navegação",
"profiles": "Perfis",
"actions": "Ações",
"profileGroups": "Grupos de perfis"
},
"actions": {
"launchProfile": "Iniciar {{name}}",
"stopProfile": "Parar {{name}}",
"profileInfo": "Informações — {{name}}"
}
},
"shortcuts": {
"openPalette": "Abrir paleta de comandos",
"openShortcuts": "Ver atalhos de teclado",
"importProfile": "Importar perfil",
"goProfiles": "Ir para Perfis",
"goProxies": "Ir para Rede",
"goExtensions": "Ir para Extensões",
"goGroups": "Ir para Grupos",
"goIntegrations": "Ir para Integrações",
"goAccount": "Ir para Conta",
"goSettings": "Ir para Configurações"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "Ещё",
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента"
"importProfileHint": "Перенести профили из другого инструмента",
"keyboardShortcuts": "Сочетания клавиш",
"keyboardShortcutsHint": "Показать все сочетания"
},
"network": "Сеть",
"integrations": "Интеграции",
@@ -1817,7 +1819,8 @@
"settings": "Настройки",
"integrations": "Интеграции",
"account": "Аккаунт",
"import": "Импорт профиля"
"import": "Импорт профиля",
"shortcuts": "Сочетания клавиш"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "Проверить соединение",
"disconnect": "Отключить"
}
},
"shortcutsPage": {
"title": "Сочетания клавиш",
"description": "Ускорьте работу с помощью этих сочетаний клавиш."
},
"commandPalette": {
"placeholder": "Введите команду или поиск...",
"empty": "Ничего не найдено.",
"groups": {
"navigation": "Навигация",
"profiles": "Профили",
"actions": "Действия",
"profileGroups": "Группы профилей"
},
"actions": {
"launchProfile": "Запустить {{name}}",
"stopProfile": "Остановить {{name}}",
"profileInfo": "Информация — {{name}}"
}
},
"shortcuts": {
"openPalette": "Открыть командную палитру",
"openShortcuts": "Показать сочетания клавиш",
"importProfile": "Импортировать профиль",
"goProfiles": "Перейти к Профилям",
"goProxies": "Перейти к Сети",
"goExtensions": "Перейти к Расширениям",
"goGroups": "Перейти к Группам",
"goIntegrations": "Перейти к Интеграциям",
"goAccount": "Перейти к Аккаунту",
"goSettings": "Перейти к Настройкам"
}
}
+36 -2
View File
@@ -1803,7 +1803,9 @@
"label": "更多",
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入"
"importProfileHint": "从其他工具导入",
"keyboardShortcuts": "键盘快捷键",
"keyboardShortcutsHint": "查看所有快捷键"
},
"network": "网络",
"integrations": "集成",
@@ -1817,7 +1819,8 @@
"settings": "设置",
"integrations": "集成",
"account": "账户",
"import": "导入配置文件"
"import": "导入配置文件",
"shortcuts": "键盘快捷键"
},
"encryption": {
"required": {
@@ -1870,5 +1873,36 @@
"testConnection": "测试连接",
"disconnect": "断开连接"
}
},
"shortcutsPage": {
"title": "键盘快捷键",
"description": "使用这些快捷键加速您的工作流程。"
},
"commandPalette": {
"placeholder": "输入命令或搜索...",
"empty": "未找到结果。",
"groups": {
"navigation": "导航",
"profiles": "配置文件",
"actions": "操作",
"profileGroups": "配置文件分组"
},
"actions": {
"launchProfile": "启动 {{name}}",
"stopProfile": "停止 {{name}}",
"profileInfo": "信息 — {{name}}"
}
},
"shortcuts": {
"openPalette": "打开命令面板",
"openShortcuts": "查看键盘快捷键",
"importProfile": "导入配置文件",
"goProfiles": "转到配置文件",
"goProxies": "转到网络",
"goExtensions": "转到扩展程序",
"goGroups": "转到分组",
"goIntegrations": "转到集成",
"goAccount": "转到账户",
"goSettings": "转到设置"
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Single source of truth for keyboard shortcuts. Each entry declares both how
* to MATCH a real keyboard event (lowercase `key` + modifiers) and how to
* DISPLAY it to the user. The display side branches on platform so macOS sees
* the glyph while everyone else sees `Ctrl`.
*/
export type ShortcutGroup =
| "navigation"
| "actions"
| "view"
| "profiles"
| "groups";
export interface ShortcutDef {
/** Stable identifier — used by the global listener to dispatch to handlers. */
id: ShortcutId;
/** Translation key for the displayed label in the shortcuts page / palette. */
labelKey: string;
group: ShortcutGroup;
/** Lowercased `KeyboardEvent.key`, e.g. "k", ",", "/". */
key: string;
/** Require the primary modifier (Cmd on mac, Ctrl elsewhere). */
mod?: boolean;
shift?: boolean;
alt?: boolean;
}
export type ShortcutId =
| "openPalette"
| "openShortcuts"
| "importProfile"
| "goProfiles"
| "goProxies"
| "goExtensions"
| "goGroups"
| "goIntegrations"
| "goAccount"
| "goSettings";
export const SHORTCUTS: ShortcutDef[] = [
// Actions
{
id: "openPalette",
labelKey: "shortcuts.openPalette",
group: "actions",
key: "k",
mod: true,
},
{
id: "openShortcuts",
labelKey: "shortcuts.openShortcuts",
group: "actions",
key: "/",
mod: true,
},
{
id: "importProfile",
labelKey: "shortcuts.importProfile",
group: "actions",
key: "o",
mod: true,
},
// Navigation
{
id: "goProfiles",
labelKey: "shortcuts.goProfiles",
group: "navigation",
key: "p",
mod: true,
},
{
id: "goProxies",
labelKey: "shortcuts.goProxies",
group: "navigation",
key: "n",
mod: true,
},
{
id: "goExtensions",
labelKey: "shortcuts.goExtensions",
group: "navigation",
key: "e",
mod: true,
},
{
id: "goGroups",
labelKey: "shortcuts.goGroups",
group: "navigation",
key: "g",
mod: true,
},
{
id: "goIntegrations",
labelKey: "shortcuts.goIntegrations",
group: "navigation",
key: "i",
mod: true,
},
{
id: "goAccount",
labelKey: "shortcuts.goAccount",
group: "navigation",
key: "a",
mod: true,
},
{
id: "goSettings",
labelKey: "shortcuts.goSettings",
group: "navigation",
key: ",",
mod: true,
},
];
/**
* Match Mod+1..9 to the group at that index (1-based). Returns the digit
* pressed, or null. Used by the global keydown handler before falling back to
* the static SHORTCUTS table.
*/
export function matchesGroupDigit(e: KeyboardEvent): number | null {
if (e.key < "1" || e.key > "9") return null;
const mod = isMac() ? e.metaKey : e.ctrlKey;
const oppositeMod = isMac() ? e.ctrlKey : e.metaKey;
if (!mod || oppositeMod || e.shiftKey || e.altKey) return null;
return Number(e.key);
}
/**
* Build display tokens for a Mod+digit group shortcut. Mirrors `formatShortcut`.
*/
export function formatGroupShortcut(digit: number): string[] {
const mac = isMac();
return [mac ? "⌘" : "Ctrl", String(digit)];
}
export function isMac(): boolean {
if (typeof navigator === "undefined") return false;
// userAgentData is preferred but not in all browsers; fall back to platform.
// `navigator.platform` is deprecated but still works in Tauri's webview.
const ua = navigator.userAgent || "";
const platform =
(navigator as unknown as { userAgentData?: { platform?: string } })
.userAgentData?.platform ??
navigator.platform ??
"";
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS X/.test(ua);
}
/**
* Render a shortcut as the platform-correct token list. The shortcuts page and
* the command palette both consume this so the glyphs stay in sync.
*
* On macOS: ["⌘", "⇧", "⌥", "K"]
* Elsewhere: ["Ctrl", "Shift", "Alt", "K"]
*/
export function formatShortcut(s: ShortcutDef): string[] {
const mac = isMac();
const tokens: string[] = [];
if (s.mod) tokens.push(mac ? "⌘" : "Ctrl");
if (s.shift) tokens.push(mac ? "⇧" : "Shift");
if (s.alt) tokens.push(mac ? "⌥" : "Alt");
tokens.push(prettyKey(s.key));
return tokens;
}
function prettyKey(key: string): string {
if (key.length === 1) return key.toUpperCase();
// Named keys like "Enter", "Escape", etc. would already be capitalized.
return key;
}
/**
* Match a real `KeyboardEvent` against a shortcut definition. Returns true
* only when modifiers are an exact match (so Ctrl+Shift+K doesn't fire
* Ctrl+K).
*/
export function matchesShortcut(s: ShortcutDef, e: KeyboardEvent): boolean {
if (e.key.toLowerCase() !== s.key.toLowerCase()) return false;
const mod = isMac() ? e.metaKey : e.ctrlKey;
const oppositeMod = isMac() ? e.ctrlKey : e.metaKey;
if (Boolean(s.mod) !== mod) return false;
// Reject the wrong-platform modifier so Ctrl+K on macOS doesn't accidentally
// trigger something that only expects ⌘+K.
if (oppositeMod) return false;
if (Boolean(s.shift) !== e.shiftKey) return false;
if (Boolean(s.alt) !== e.altKey) return false;
return true;
}