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 ( ); }; // 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; 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 (
{expanded && (
{sessions.map((s) => (
{s.hostLabel || s.label}
))}
)}
); }; interface TrayPanelContentProps { terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number }; } const TrayPanelContent: React.FC = ({ 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([]); 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 ( <>
Netcatty
{t("tray.openMainWindow")}
{jumpableSessions.length > 0 && (() => { // Group sessions by workspace const workspaceGroups = new Map(); 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 (
{t("tray.sessions")}
{/* Workspace groups */} {Array.from(workspaceGroups.entries()).map(([wsId, group]) => ( ))} {/* Solo sessions */} {soloSessions.map((s) => (
{s.hostLabel || s.label}
))}
); })()} {activeSession && (
Current
{activeSession.hostLabel || activeSession.label}
)} {portForwardingRules.length > 0 && (
{t("tray.portForwarding")}
{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 ( {label} ); })}
)} {/* Empty state - show when nothing is active */} {jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
😴 {t("tray.empty.title")} {t("tray.empty.subtitle")}
)}
{/* Quit button at the bottom */}
); }; type SettingsState = AppLockGateRenderContext["settings"]; const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => { return ( ); }; export default TrayPanel;