diff --git a/AGENTS.md b/AGENTS.md index 1cae7d4..89a1686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,34 @@ A `` becomes a first-class app sub-page (no modal overlay, no center pos Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome. +### Cross-component tab control + +When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky: + +```tsx + +``` + +Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses. + +## Keyboard shortcuts + +All app-wide shortcuts live in `src/lib/shortcuts.ts`: + +- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven locales. +- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette. +- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut. +- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table. + +Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut: + +1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant. +2. Add a `case "yourId":` in `runShortcut` in `page.tsx`. +3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`. +4. Add `shortcuts.yourId` (label) to all seven locale files. + +The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching. + ## Singletons - If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise diff --git a/src/app/page.tsx b/src/app/page.tsx index 1321cc5..f18ddbf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(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(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} )} + {currentPage === "shortcuts" && ( + + )} + {settingsDialogOpen && ( )} @@ -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} /> + { + 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) => ( 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; + onLaunchProfile: (profile: BrowserProfile) => void; + onKillProfile: (profile: BrowserProfile) => void; + onShowProfileInfo: (profile: BrowserProfile) => void; +} + +const ICONS: Record> = { + 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 ( + + {tokens.map((tok, i) => ( + + {tok} + + ))} + + ); +} + +function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) { + return ; +} + +/** + * 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) => ( + { + dispatch(() => { + onSelectGroup(target.id); + }); + }} + > + + {target.name} + {index < 9 ? : null} + + ); + + return ( + + + + {t("commandPalette.empty")} + + + {byGroup("navigation").map((s) => { + const Icon = ICONS[s.id]; + return ( + { + dispatch(() => { + onAction(s.id); + }); + }} + > + + {t(s.labelKey)} + + + ); + })} + + + {groupTargets.length > 0 ? ( + <> + + + {groupTargets.map((target, i) => renderGroup(target, i))} + + + ) : null} + + {profiles.length > 0 ? ( + <> + + + {profiles.map((p) => { + const running = runningProfileIds.has(p.id); + return running ? ( + { + dispatch(() => { + onKillProfile(p); + }); + }} + > + + + {t("commandPalette.actions.stopProfile", { + name: p.name, + })} + + + ) : ( + { + dispatch(() => { + onLaunchProfile(p); + }); + }} + > + + + {t("commandPalette.actions.launchProfile", { + name: p.name, + })} + + + ); + })} + {profiles.map((p) => ( + { + dispatch(() => { + onShowProfileInfo(p); + }); + }} + > + + + {t("commandPalette.actions.profileInfo", { name: p.name })} + + + ))} + + + ) : null} + + + + + {byGroup("actions").map((s) => { + const Icon = ICONS[s.id]; + return ( + { + dispatch(() => { + onAction(s.id); + }); + }} + > + + {t(s.labelKey)} + + + ); + })} + + + + ); +} diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index b13ccc6..90f942f 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -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([]); @@ -208,9 +211,10 @@ export function ExtensionManagementDialog({ Record >({}); - // 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({ )} setActiveTab(v as "extensions" | "groups")} className="flex-1 min-h-0 flex flex-col" diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index 647ad9f..91040e9 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -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({ @@ -310,7 +313,7 @@ export function IntegrationsDialog({ )}
- + {t("integrations.tabApi")} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index e15c699..6ffa870 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -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(null); const [isDeleting, setIsDeleting] = React.useState(false); - const [profileForInfoDialog, setProfileForInfoDialog] = + const [internalInfoDialogProfile, setInternalInfoDialogProfile] = React.useState(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(null); const [dnsBlocklistProfile, setDnsBlocklistProfile] = @@ -2836,7 +2859,7 @@ export function ProfilesDataTable({ }, }, ], - [t], + [t, setProfileForInfoDialog], ); const table = useReactTable({ diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index f6152b4..590c0e4 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -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) { diff --git a/src/components/shortcuts-page.tsx b/src/components/shortcuts-page.tsx new file mode 100644 index 0000000..163749c --- /dev/null +++ b/src/components/shortcuts-page.tsx @@ -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 ( +
+ {tokens.map((tok, i) => ( + + {tok} + + ))} +
+ ); +} + +function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) { + return ; +} + +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 ( +
+
+
+

{t("shortcutsPage.title")}

+

+ {t("shortcutsPage.description")} +

+
+ + {sections.map(({ key, titleKey }) => { + const items = SHORTCUTS.filter((s) => s.group === key); + if (items.length === 0) return null; + return ( +
+

+ {t(titleKey)} +

+
+ {items.map((s) => ( +
+ {t(s.labelKey)} + +
+ ))} +
+
+ ); + })} + + {digitGroups.length > 0 ? ( +
+

+ {t("commandPalette.groups.profileGroups")} +

+
+ {digitGroups.map((target, i) => ( +
+ {target.name} + +
+ ))} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 5957640..62c1c10 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -34,10 +34,14 @@ function CommandDialog({ title, description, children, + filter, + shouldFilter, ...props }: React.ComponentProps & { title?: string; description?: string; + filter?: React.ComponentProps["filter"]; + shouldFilter?: React.ComponentProps["shouldFilter"]; }) { const { t } = useTranslation(); const resolvedTitle = title ?? t("common.commandPalette.title"); @@ -50,7 +54,11 @@ function CommandDialog({ {resolvedDescription} - + {children} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 689d836..fccfdfd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index be3fb55..df5244c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 795473e..32e955f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3bb9d24..3544149 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "設定へ移動" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4f0f684..9c62e80 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4d9be72..b07e2b8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Перейти к Настройкам" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9553664..72dec3e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "转到设置" } } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000..1a1eb9a --- /dev/null +++ b/src/lib/shortcuts.ts @@ -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; +}