Files
NetMesh/components/TrayPanel.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

528 lines
22 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "./ui/button";
import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { upsertKnownHost } from "../domain/knownHosts";
import type { Host, KnownHost } from "../domain/models";
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
import type { AppLockGateRenderContext } from "./AppLockGate";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
status,
spinning,
}) => {
const color =
status === "success"
? "bg-emerald-500"
: status === "warning"
? "bg-amber-500"
: status === "error"
? "bg-rose-500"
: "bg-zinc-500";
return (
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
spinning ? "animate-spin" : "",
)}
/>
);
};
// Session type for workspace grouping
type TraySession = {
id: string;
label: string;
hostLabel: string;
status: "connecting" | "connected" | "disconnected";
workspaceId?: string;
workspaceTitle?: string;
/** Mirrors TerminalSession.hiddenFromTabs; marks AI-opened silent sessions. */
aiHidden?: boolean;
};
// Collapsible workspace group component
const WorkspaceGroup: React.FC<{
workspaceId: string;
title: string;
sessions: TraySession[];
activeTabId: string | null;
jumpToSession: (sessionId: string) => Promise<void>;
onCloseSession: (sessionId: string) => void;
t: (key: string) => string;
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, onCloseSession, t }) => {
const [expanded, setExpanded] = useState(true);
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
isAnyActive ? "bg-muted" : "",
)}
>
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
<span className="font-medium truncate">{title}</span>
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
</button>
{expanded && (
<div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => (
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<div
className={cn(
"group w-full rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<button
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className="flex-1 min-w-0 text-left px-2 py-1 flex items-center justify-between gap-1"
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
{s.aiHidden && (
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium leading-none bg-muted text-muted-foreground">
AI
</span>
)}
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onCloseSession(s.id);
}}
className="shrink-0 mr-1 h-6 w-6 rounded inline-flex items-center justify-center opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("tray.closeSession")}
>
<X size={12} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
)}
</div>
);
};
interface TrayPanelContentProps {
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const { t } = useI18n();
const {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
// independent from (and typically empty compared to) the main App's — it's
// used here only for its storage-sync side effects, never for closeSession.
useSessionState({ persistSessionRestore: false });
const {
rules: portForwardingRules,
startTunnel,
stopTunnel,
hasRuntimeTunnel,
} = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
updateKnownHosts(upsertKnownHost(effectiveKnownHosts, knownHost));
}, [effectiveKnownHosts, updateKnownHosts]);
const handleCloseSession = useCallback((sessionId: string) => {
// Forwarded to the main window's App-owned closeSession, which is the
// instance that actually owns `sessions` and republishes tray menu data.
void closeSessionFromTrayPanel(sessionId);
}, [closeSessionFromTrayPanel]);
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
const jumpableSessions = useMemo(
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
[traySessions],
);
const activeSession = useMemo(() => {
if (!activeTabId) return null;
return traySessions.find((s) => s.id === activeTabId) || null;
}, [activeTabId, traySessions]);
useEffect(() => {
const unsubscribe = onTrayPanelMenuData?.((data) => {
setTraySessions(data.sessions || []);
});
return () => unsubscribe?.();
}, [onTrayPanelMenuData]);
useEffect(() => {
const unsubscribe = onTrayPanelRefresh?.(() => {
try {
window.dispatchEvent(new Event("storage"));
} catch {
// ignore
}
});
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [handleClose]);
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
const target = e.target;
if (!(target instanceof Node)) return;
if (document.body && !document.body.contains(target)) return;
// Ignore clicks on interactive elements inside the panel.
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
return;
}
if (
target instanceof HTMLElement &&
target.closest("[data-port-forward-host-key-dialog='true'],[data-port-forward-host-key-tray-prompt='true'],.port-forward-host-key-dialog-layer")
) {
return;
}
// Clicking on background should close panel
const root = document.getElementById("tray-panel-root");
if (root && !root.contains(target)) {
handleClose();
}
};
window.addEventListener("pointerdown", onPointerDown, true);
return () => window.removeEventListener("pointerdown", onPointerDown, true);
}, [handleClose]);
useEffect(() => {
const unsubscribe = onTrayPanelCloseRequest(() => {
handleClose();
});
return () => unsubscribe?.();
}, [handleClose, onTrayPanelCloseRequest]);
const handleOpenMain = useCallback(() => {
void openMainWindow();
}, [openMainWindow]);
const handleQuit = useCallback(() => {
void quitApp();
}, [quitApp]);
return (
<>
<div id="tray-panel-root" className="w-full h-full bg-background/95 supports-[backdrop-filter]:backdrop-blur-sm border border-border/60 rounded-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
<span className="text-sm font-medium">Netcatty</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
>
<Maximize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
</Tooltip>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose}
>
<X size={14} />
</button>
</div>
</div>
<PortForwardHostKeyTrayPrompt onAddKnownHost={handleAddKnownHost} />
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
{jumpableSessions.length > 0 && (() => {
// Group sessions by workspace
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
const soloSessions: typeof jumpableSessions = [];
jumpableSessions.forEach((s) => {
if (s.workspaceId) {
const existing = workspaceGroups.get(s.workspaceId);
if (existing) {
existing.sessions.push(s);
} else {
workspaceGroups.set(s.workspaceId, {
title: s.workspaceTitle || "Workspace",
sessions: [s],
});
}
} else {
soloSessions.push(s);
}
});
return (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
<div className="space-y-1">
{/* Workspace groups */}
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
<WorkspaceGroup
key={wsId}
workspaceId={wsId}
title={group.title}
sessions={group.sessions}
activeTabId={activeTabId}
jumpToSession={jumpToSession}
onCloseSession={handleCloseSession}
t={t}
/>
))}
{/* Solo sessions */}
{soloSessions.map((s) => (
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<div
className={cn(
"group w-full rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<button
onClick={() => {
void jumpToSession(s.id);
}}
className="flex-1 min-w-0 text-left px-2 py-1.5 flex items-center justify-between gap-1"
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
{s.aiHidden && (
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium leading-none bg-muted text-muted-foreground">
AI
</span>
)}
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleCloseSession(s.id);
}}
className="shrink-0 mr-1.5 h-6 w-6 rounded inline-flex items-center justify-center opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("tray.closeSession")}
>
<X size={12} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
</div>
);
})()}
{activeSession && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
</Tooltip>
</div>
)}
{portForwardingRules.length > 0 && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
<div className="space-y-1">
{portForwardingRules.map((rule) => {
const isUnknown = rule.status === "unknown";
const isConnecting = rule.status === "connecting";
const isActive = rule.status === "active";
// unknown/stale: neither Start nor Stop until authority recovers,
// unless this window already holds a live runtime tunnel.
const isStoppable = !isUnknown && (
isConnecting || isActive || hasRuntimeTunnel(rule.id)
);
const isActionDisabled = isConnecting || (isUnknown && !hasRuntimeTunnel(rule.id));
const label = rule.label || (rule.type === "dynamic"
? `SOCKS:${rule.localPort}`
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return (
<Tooltip key={rule.id}>
<TooltipTrigger asChild>
<button
disabled={isActionDisabled}
onClick={() => {
if (isUnknown && !hasRuntimeTunnel(rule.id)) return;
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isStoppable) {
void stopTunnel(rule.id).then((result) => {
if (!result.success && result.error) toast.error(result.error);
});
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isActionDisabled ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting" || rule.status === "unknown"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting" || rule.status === "unknown"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
)}
{/* Empty state - show when nothing is active */}
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<span className="text-2xl mb-2">😴</span>
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
</div>
)}
</div>
{/* Quit button at the bottom */}
<div className="px-3 py-2 border-t border-border/60">
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
onClick={handleQuit}
>
<Power size={14} />
<span>{t("tray.quit")}</span>
</button>
</div>
</div>
</>
);
};
type SettingsState = AppLockGateRenderContext["settings"];
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent terminalSettings={settings.terminalSettings} />
</I18nProvider>
);
};
export default TrayPanel;