mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-28 11:01:28 +02:00
feat: add shortcuts
This commit is contained in:
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -34,6 +35,7 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||
@@ -53,6 +55,12 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
matchesGroupDigit,
|
||||
matchesShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
@@ -149,6 +157,11 @@ export default function Home() {
|
||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||
"proxies" | "vpns"
|
||||
>("proxies");
|
||||
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||
useState<"extensions" | "groups">("extensions");
|
||||
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||
"api" | "mcp"
|
||||
>("api");
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -221,6 +234,11 @@ export default function Home() {
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
@@ -273,9 +291,134 @@ export default function Home() {
|
||||
case "account":
|
||||
setAccountDialogOpen(true);
|
||||
break;
|
||||
case "shortcuts":
|
||||
// Plain page render — nothing else to open.
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runShortcut = useCallback(
|
||||
(id: ShortcutId) => {
|
||||
switch (id) {
|
||||
case "openPalette":
|
||||
setCommandPaletteOpen(true);
|
||||
break;
|
||||
case "openShortcuts":
|
||||
handleRailNavigate("shortcuts");
|
||||
break;
|
||||
case "importProfile":
|
||||
handleRailNavigate("import");
|
||||
break;
|
||||
case "goProfiles":
|
||||
handleRailNavigate("profiles");
|
||||
break;
|
||||
case "goProxies": {
|
||||
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
|
||||
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
|
||||
// initialTab, so we just pick the right destination.
|
||||
if (currentPage === "proxies") {
|
||||
handleRailNavigate("vpns");
|
||||
} else if (currentPage === "vpns") {
|
||||
handleRailNavigate("proxies");
|
||||
} else {
|
||||
handleRailNavigate(
|
||||
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goExtensions": {
|
||||
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
|
||||
if (currentPage === "extensions") {
|
||||
setExtensionManagementInitialTab((cur) =>
|
||||
cur === "extensions" ? "groups" : "extensions",
|
||||
);
|
||||
} else {
|
||||
handleRailNavigate("extensions");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goGroups":
|
||||
handleRailNavigate("groups");
|
||||
break;
|
||||
case "goIntegrations": {
|
||||
// Mod+I: flip api↔mcp tab when already on integrations.
|
||||
if (currentPage === "integrations") {
|
||||
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
|
||||
} else {
|
||||
handleRailNavigate("integrations");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goAccount":
|
||||
handleRailNavigate("account");
|
||||
break;
|
||||
case "goSettings":
|
||||
handleRailNavigate("settings");
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleRailNavigate, currentPage, proxyManagementInitialTab],
|
||||
);
|
||||
|
||||
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
|
||||
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
|
||||
const orderedGroupTargets = useMemo(
|
||||
() => [
|
||||
{ id: "__all__", name: t("rail.profiles") },
|
||||
...groupsData.map((g) => ({ id: g.id, name: g.name })),
|
||||
],
|
||||
[groupsData, t],
|
||||
);
|
||||
|
||||
const selectGroupByDigit = useCallback(
|
||||
(digit: number) => {
|
||||
const target = orderedGroupTargets[digit - 1];
|
||||
if (!target) return;
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(target.id);
|
||||
},
|
||||
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
|
||||
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
|
||||
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const isTyping =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target?.isContentEditable === true;
|
||||
|
||||
const digit = matchesGroupDigit(e);
|
||||
if (digit !== null) {
|
||||
if (isTyping) return;
|
||||
if (digit - 1 >= orderedGroupTargets.length) return;
|
||||
e.preventDefault();
|
||||
selectGroupByDigit(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of SHORTCUTS) {
|
||||
if (!matchesShortcut(s, e)) continue;
|
||||
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
runShortcut(s.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -1306,6 +1449,8 @@ export default function Home() {
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
infoDialogProfile={profileInfoDialog}
|
||||
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onCloneProfile={handleCloneProfile}
|
||||
@@ -1344,6 +1489,10 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage === "shortcuts" && (
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
<SettingsDialog
|
||||
isOpen={settingsDialogOpen}
|
||||
@@ -1368,6 +1517,7 @@ export default function Home() {
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "integrations"}
|
||||
initialTab={integrationsInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1404,6 +1554,7 @@ export default function Home() {
|
||||
}}
|
||||
limitedMode={false}
|
||||
subPage={currentPage === "extensions"}
|
||||
initialTab={extensionManagementInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1447,6 +1598,29 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onAction={runShortcut}
|
||||
groupTargets={orderedGroupTargets}
|
||||
onSelectGroup={(id) => {
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(id);
|
||||
}}
|
||||
profiles={profiles}
|
||||
runningProfileIds={runningProfiles}
|
||||
onLaunchProfile={(profile) => {
|
||||
void launchProfile(profile);
|
||||
}}
|
||||
onKillProfile={(profile) => {
|
||||
void handleKillProfile(profile);
|
||||
}}
|
||||
onShowProfileInfo={(profile) => {
|
||||
handleRailNavigate("profiles");
|
||||
setProfileInfoDialog(profile);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
<ProfileSelectorDialog
|
||||
key={pendingUrl.id}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAction: (id: ShortcutId) => void;
|
||||
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
|
||||
groupTargets: GroupTarget[];
|
||||
onSelectGroup: (id: string) => void;
|
||||
/** All profiles for launch/stop/info entries. */
|
||||
profiles: BrowserProfile[];
|
||||
runningProfileIds: Set<string>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
goExtensions: LuPuzzle,
|
||||
goGroups: LuUsers,
|
||||
goIntegrations: LuPlug,
|
||||
goAccount: LuCloud,
|
||||
goSettings: GoGear,
|
||||
};
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<CommandShortcut className="flex items-center gap-0.5">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</CommandShortcut>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
|
||||
* to appear as a substring somewhere in the item's value or its keywords; the
|
||||
* score is reduced when tokens appear later in the haystack so a closer match
|
||||
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
|
||||
* requires tokens in document order so it would otherwise return zero.
|
||||
*/
|
||||
function fuzzyFilter(
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: string[],
|
||||
): number {
|
||||
if (!search.trim()) return 1;
|
||||
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let score = 0;
|
||||
for (const tok of tokens) {
|
||||
const idx = haystack.indexOf(tok);
|
||||
if (idx === -1) return 0;
|
||||
score += 1 / (1 + idx);
|
||||
}
|
||||
return score / tokens.length;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAction,
|
||||
groupTargets,
|
||||
onSelectGroup,
|
||||
profiles,
|
||||
runningProfileIds,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
|
||||
// on the next tick so an action that opens another dialog doesn't race
|
||||
// this one's close animation.
|
||||
const dispatch = (fn: () => void) => {
|
||||
onOpenChange(false);
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
|
||||
const byGroup = (group: ShortcutDef["group"]) =>
|
||||
SHORTCUTS.filter((s) => s.group === group);
|
||||
|
||||
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
|
||||
// We still display more in the palette (without a shortcut hint) so the
|
||||
// user can search/jump to any of them.
|
||||
const renderGroup = (target: GroupTarget, index: number) => (
|
||||
<CommandItem
|
||||
key={target.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onSelectGroup(target.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuUsers />
|
||||
<span>{target.name}</span>
|
||||
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
{byGroup("navigation").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{groupTargets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
|
||||
{groupTargets.map((target, i) => renderGroup(target, i))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{profiles.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profiles")}>
|
||||
{profiles.map((p) => {
|
||||
const running = runningProfileIds.has(p.id);
|
||||
return running ? (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onKillProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onLaunchProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{profiles.map((p) => (
|
||||
<CommandItem
|
||||
key={`info-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onShowProfileInfo(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,8 @@ interface ExtensionManagementDialogProps {
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||
initialTab?: "extensions" | "groups";
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
@@ -137,6 +139,7 @@ export function ExtensionManagementDialog({
|
||||
onClose,
|
||||
limitedMode,
|
||||
subPage,
|
||||
initialTab = "extensions",
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
@@ -208,9 +211,10 @@ export function ExtensionManagementDialog({
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Tab
|
||||
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
|
||||
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
|
||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||
"extensions",
|
||||
initialTab,
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -1120,6 +1124,7 @@ export function ExtensionManagementDialog({
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
|
||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||
initialTab?: "api" | "mcp";
|
||||
}
|
||||
|
||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "api",
|
||||
}: IntegrationsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
@@ -310,7 +313,7 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<AnimatedTabs defaultValue="api">
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
|
||||
@@ -1052,6 +1052,13 @@ interface ProfilesDataTableProps {
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
/**
|
||||
* When provided, the info dialog is controlled by the parent. Allows the
|
||||
* command palette in page.tsx to open the dialog directly without lifting
|
||||
* every other piece of internal table state.
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1084,6 +1091,8 @@ export function ProfilesDataTable({
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1155,8 +1164,22 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
|
||||
const profileForInfoDialog = isInfoDialogControlled
|
||||
? (infoDialogProfile ?? null)
|
||||
: internalInfoDialogProfile;
|
||||
const setProfileForInfoDialog = React.useCallback(
|
||||
(p: BrowserProfile | null) => {
|
||||
if (isInfoDialogControlled) {
|
||||
onInfoDialogProfileChange?.(p);
|
||||
} else {
|
||||
setInternalInfoDialogProfile(p);
|
||||
}
|
||||
},
|
||||
[isInfoDialogControlled, onInfoDialogProfileChange],
|
||||
);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
@@ -2836,7 +2859,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
description,
|
||||
children,
|
||||
filter,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "設定へ移動"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Перейти к Настройкам"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "转到设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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