mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-11 15:16:34 +02:00
feat: add shortcuts
This commit is contained in:
@@ -124,6 +124,34 @@ A `<Dialog>` 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.
|
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
|
||||||
|
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Singletons
|
||||||
|
|
||||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AccountPage } from "@/components/account-page";
|
import { AccountPage } from "@/components/account-page";
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||||
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||||
import { CookieManagementDialog } from "@/components/cookie-management-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 { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||||
import { SettingsDialog } from "@/components/settings-dialog";
|
import { SettingsDialog } from "@/components/settings-dialog";
|
||||||
|
import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||||
import { SyncFollowerDialog } from "@/components/sync-follower-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 { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import {
|
||||||
|
matchesGroupDigit,
|
||||||
|
matchesShortcut,
|
||||||
|
SHORTCUTS,
|
||||||
|
type ShortcutId,
|
||||||
|
} from "@/lib/shortcuts";
|
||||||
import {
|
import {
|
||||||
dismissToast,
|
dismissToast,
|
||||||
showErrorToast,
|
showErrorToast,
|
||||||
@@ -149,6 +157,11 @@ export default function Home() {
|
|||||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||||
"proxies" | "vpns"
|
"proxies" | "vpns"
|
||||||
>("proxies");
|
>("proxies");
|
||||||
|
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||||
|
useState<"extensions" | "groups">("extensions");
|
||||||
|
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||||
|
"api" | "mcp"
|
||||||
|
>("api");
|
||||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||||
@@ -221,6 +234,11 @@ export default function Home() {
|
|||||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||||
useState<BrowserProfile | null>(null);
|
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 } =
|
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||||
usePermissions();
|
usePermissions();
|
||||||
|
|
||||||
@@ -273,9 +291,134 @@ export default function Home() {
|
|||||||
case "account":
|
case "account":
|
||||||
setAccountDialogOpen(true);
|
setAccountDialogOpen(true);
|
||||||
break;
|
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
|
// Check for missing binaries and offer to download them
|
||||||
const checkMissingBinaries = useCallback(async () => {
|
const checkMissingBinaries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1306,6 +1449,8 @@ export default function Home() {
|
|||||||
{isLoading && groupsData.length === 0 ? null : null}
|
{isLoading && groupsData.length === 0 ? null : null}
|
||||||
<ProfilesDataTable
|
<ProfilesDataTable
|
||||||
profiles={filteredProfiles}
|
profiles={filteredProfiles}
|
||||||
|
infoDialogProfile={profileInfoDialog}
|
||||||
|
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||||
onLaunchProfile={launchProfile}
|
onLaunchProfile={launchProfile}
|
||||||
onKillProfile={handleKillProfile}
|
onKillProfile={handleKillProfile}
|
||||||
onCloneProfile={handleCloneProfile}
|
onCloneProfile={handleCloneProfile}
|
||||||
@@ -1344,6 +1489,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentPage === "shortcuts" && (
|
||||||
|
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||||
|
)}
|
||||||
|
|
||||||
{settingsDialogOpen && (
|
{settingsDialogOpen && (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
isOpen={settingsDialogOpen}
|
isOpen={settingsDialogOpen}
|
||||||
@@ -1368,6 +1517,7 @@ export default function Home() {
|
|||||||
setCurrentPage("profiles");
|
setCurrentPage("profiles");
|
||||||
}}
|
}}
|
||||||
subPage={currentPage === "integrations"}
|
subPage={currentPage === "integrations"}
|
||||||
|
initialTab={integrationsInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1404,6 +1554,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
limitedMode={false}
|
limitedMode={false}
|
||||||
subPage={currentPage === "extensions"}
|
subPage={currentPage === "extensions"}
|
||||||
|
initialTab={extensionManagementInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1447,6 +1598,29 @@ export default function Home() {
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
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) => (
|
{pendingUrls.map((pendingUrl) => (
|
||||||
<ProfileSelectorDialog
|
<ProfileSelectorDialog
|
||||||
key={pendingUrl.id}
|
key={pendingUrl.id}
|
||||||
|
|||||||
@@ -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;
|
onClose: () => void;
|
||||||
limitedMode: boolean;
|
limitedMode: boolean;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||||
|
initialTab?: "extensions" | "groups";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExtensionManagementDialog({
|
export function ExtensionManagementDialog({
|
||||||
@@ -137,6 +139,7 @@ export function ExtensionManagementDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
limitedMode,
|
limitedMode,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "extensions",
|
||||||
}: ExtensionManagementDialogProps) {
|
}: ExtensionManagementDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
@@ -208,9 +211,10 @@ export function ExtensionManagementDialog({
|
|||||||
Record<string, boolean>
|
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">(
|
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||||
"extensions",
|
initialTab,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -1120,6 +1124,7 @@ export function ExtensionManagementDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatedTabs
|
<AnimatedTabs
|
||||||
|
key={initialTab}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||||
|
initialTab?: "api" | "mcp";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "api",
|
||||||
}: IntegrationsDialogProps) {
|
}: IntegrationsDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
@@ -310,7 +313,7 @@ export function IntegrationsDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
<AnimatedTabs defaultValue="api">
|
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger value="api">
|
<AnimatedTabsTrigger value="api">
|
||||||
{t("integrations.tabApi")}
|
{t("integrations.tabApi")}
|
||||||
|
|||||||
@@ -1052,6 +1052,13 @@ interface ProfilesDataTableProps {
|
|||||||
onSetPassword?: (profile: BrowserProfile) => void;
|
onSetPassword?: (profile: BrowserProfile) => void;
|
||||||
onChangePassword?: (profile: BrowserProfile) => void;
|
onChangePassword?: (profile: BrowserProfile) => void;
|
||||||
onRemovePassword?: (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({
|
export function ProfilesDataTable({
|
||||||
@@ -1084,6 +1091,8 @@ export function ProfilesDataTable({
|
|||||||
onSetPassword,
|
onSetPassword,
|
||||||
onChangePassword,
|
onChangePassword,
|
||||||
onRemovePassword,
|
onRemovePassword,
|
||||||
|
infoDialogProfile,
|
||||||
|
onInfoDialogProfileChange,
|
||||||
}: ProfilesDataTableProps) {
|
}: ProfilesDataTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||||
@@ -1155,8 +1164,22 @@ export function ProfilesDataTable({
|
|||||||
const [profileToDelete, setProfileToDelete] =
|
const [profileToDelete, setProfileToDelete] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
React.useState<BrowserProfile | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
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] =
|
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
React.useState<BrowserProfile | null>(null);
|
||||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||||
@@ -2836,7 +2859,7 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t, setProfileForInfoDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FaDownload } from "react-icons/fa";
|
import { FaDownload } from "react-icons/fa";
|
||||||
import { FiWifi } from "react-icons/fi";
|
import { FiWifi } from "react-icons/fi";
|
||||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
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 { cn } from "@/lib/utils";
|
||||||
import { Logo } from "./icons/logo";
|
import { Logo } from "./icons/logo";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
@@ -19,7 +26,8 @@ export type AppPage =
|
|||||||
| "settings"
|
| "settings"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
| "account"
|
| "account"
|
||||||
| "import";
|
| "import"
|
||||||
|
| "shortcuts";
|
||||||
|
|
||||||
const CLICK_THRESHOLD = 5;
|
const CLICK_THRESHOLD = 5;
|
||||||
const CLICK_WINDOW_MS = 2000;
|
const CLICK_WINDOW_MS = 2000;
|
||||||
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
|||||||
labelKey: "rail.more.importProfile",
|
labelKey: "rail.more.importProfile",
|
||||||
hintKey: "rail.more.importProfileHint",
|
hintKey: "rail.more.importProfileHint",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: "shortcuts",
|
||||||
|
Icon: LuKeyboard,
|
||||||
|
labelKey: "rail.more.keyboardShortcuts",
|
||||||
|
hintKey: "rail.more.keyboardShortcutsHint",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,10 +34,14 @@ function CommandDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
|
filter,
|
||||||
|
shouldFilter,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||||
|
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
|||||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<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}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "More",
|
"label": "More",
|
||||||
"closeAriaLabel": "Close menu",
|
"closeAriaLabel": "Close menu",
|
||||||
"importProfile": "Import profile",
|
"importProfile": "Import profile",
|
||||||
"importProfileHint": "Bring profiles from another tool"
|
"importProfileHint": "Bring profiles from another tool",
|
||||||
|
"keyboardShortcuts": "Keyboard shortcuts",
|
||||||
|
"keyboardShortcutsHint": "View all shortcuts"
|
||||||
},
|
},
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"import": "Import profile"
|
"import": "Import profile",
|
||||||
|
"shortcuts": "Keyboard shortcuts"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "Test connection",
|
"testConnection": "Test connection",
|
||||||
"disconnect": "Disconnect"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "Más",
|
"label": "Más",
|
||||||
"closeAriaLabel": "Cerrar menú",
|
"closeAriaLabel": "Cerrar menú",
|
||||||
"importProfile": "Importar perfil",
|
"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",
|
"network": "Red",
|
||||||
"integrations": "Integraciones",
|
"integrations": "Integraciones",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"integrations": "Integraciones",
|
"integrations": "Integraciones",
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
"import": "Importar perfil"
|
"import": "Importar perfil",
|
||||||
|
"shortcuts": "Atajos de teclado"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "Probar conexión",
|
"testConnection": "Probar conexión",
|
||||||
"disconnect": "Desconectar"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "Plus",
|
"label": "Plus",
|
||||||
"closeAriaLabel": "Fermer le menu",
|
"closeAriaLabel": "Fermer le menu",
|
||||||
"importProfile": "Importer un profil",
|
"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",
|
"network": "Réseau",
|
||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
"account": "Compte",
|
"account": "Compte",
|
||||||
"import": "Importer un profil"
|
"import": "Importer un profil",
|
||||||
|
"shortcuts": "Raccourcis clavier"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "Tester la connexion",
|
"testConnection": "Tester la connexion",
|
||||||
"disconnect": "Déconnecter"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "その他",
|
"label": "その他",
|
||||||
"closeAriaLabel": "メニューを閉じる",
|
"closeAriaLabel": "メニューを閉じる",
|
||||||
"importProfile": "プロファイルをインポート",
|
"importProfile": "プロファイルをインポート",
|
||||||
"importProfileHint": "別のツールから取り込む"
|
"importProfileHint": "別のツールから取り込む",
|
||||||
|
"keyboardShortcuts": "キーボードショートカット",
|
||||||
|
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||||
},
|
},
|
||||||
"network": "ネットワーク",
|
"network": "ネットワーク",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
"account": "アカウント",
|
"account": "アカウント",
|
||||||
"import": "プロファイルをインポート"
|
"import": "プロファイルをインポート",
|
||||||
|
"shortcuts": "キーボードショートカット"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "接続をテスト",
|
"testConnection": "接続をテスト",
|
||||||
"disconnect": "切断"
|
"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": "設定へ移動"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "Mais",
|
"label": "Mais",
|
||||||
"closeAriaLabel": "Fechar menu",
|
"closeAriaLabel": "Fechar menu",
|
||||||
"importProfile": "Importar perfil",
|
"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",
|
"network": "Rede",
|
||||||
"integrations": "Integrações",
|
"integrations": "Integrações",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"integrations": "Integrações",
|
"integrations": "Integrações",
|
||||||
"account": "Conta",
|
"account": "Conta",
|
||||||
"import": "Importar perfil"
|
"import": "Importar perfil",
|
||||||
|
"shortcuts": "Atalhos de teclado"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "Testar conexão",
|
"testConnection": "Testar conexão",
|
||||||
"disconnect": "Desconectar"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "Ещё",
|
"label": "Ещё",
|
||||||
"closeAriaLabel": "Закрыть меню",
|
"closeAriaLabel": "Закрыть меню",
|
||||||
"importProfile": "Импорт профиля",
|
"importProfile": "Импорт профиля",
|
||||||
"importProfileHint": "Перенести профили из другого инструмента"
|
"importProfileHint": "Перенести профили из другого инструмента",
|
||||||
|
"keyboardShortcuts": "Сочетания клавиш",
|
||||||
|
"keyboardShortcutsHint": "Показать все сочетания"
|
||||||
},
|
},
|
||||||
"network": "Сеть",
|
"network": "Сеть",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
"account": "Аккаунт",
|
"account": "Аккаунт",
|
||||||
"import": "Импорт профиля"
|
"import": "Импорт профиля",
|
||||||
|
"shortcuts": "Сочетания клавиш"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "Проверить соединение",
|
"testConnection": "Проверить соединение",
|
||||||
"disconnect": "Отключить"
|
"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": "Перейти к Настройкам"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1803,7 +1803,9 @@
|
|||||||
"label": "更多",
|
"label": "更多",
|
||||||
"closeAriaLabel": "关闭菜单",
|
"closeAriaLabel": "关闭菜单",
|
||||||
"importProfile": "导入配置文件",
|
"importProfile": "导入配置文件",
|
||||||
"importProfileHint": "从其他工具导入"
|
"importProfileHint": "从其他工具导入",
|
||||||
|
"keyboardShortcuts": "键盘快捷键",
|
||||||
|
"keyboardShortcutsHint": "查看所有快捷键"
|
||||||
},
|
},
|
||||||
"network": "网络",
|
"network": "网络",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
@@ -1817,7 +1819,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"import": "导入配置文件"
|
"import": "导入配置文件",
|
||||||
|
"shortcuts": "键盘快捷键"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1873,36 @@
|
|||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"disconnect": "断开连接"
|
"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": "转到设置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user