import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react"; import { getActiveSessions, deleteOpenTab, type ActiveSessionInfo, type OpenTabRecord, } from "@/main-axios"; import { tabIcon } from "@/shell/tabUtils"; import type { Tab, TabType } from "@/types/ui-types"; import { Badge } from "@/components/badge"; import { Input } from "@/components/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval"; const CONNECTION_TAB_TYPES: TabType[] = [ "terminal", "rdp", "vnc", "telnet", "files", "docker", "host-metrics", "tunnel", ]; const TYPE_LABELS: Record = { terminal: "SSH", rdp: "RDP", vnc: "VNC", telnet: "Telnet", files: "Files", docker: "Docker", "host-metrics": "Host Metrics", tunnel: "Tunnel", }; function formatDuration(ms: number): string { const s = Math.max(0, Math.floor(ms / 1000)); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; const h = Math.floor(m / 60); return `${h}h ${m % 60}m`; } function sessionsUnchanged( prev: ActiveSessionInfo[], next: ActiveSessionInfo[], ): boolean { if (prev.length !== next.length) return false; for (let i = 0; i < prev.length; i++) { const a = prev[i]; const b = next[i]; if ( a.sessionId !== b.sessionId || a.hostId !== b.hostId || a.hostName !== b.hostName || a.tabInstanceId !== b.tabInstanceId || a.isConnected !== b.isConnected || a.createdAt !== b.createdAt ) { return false; } } return true; } function formatExpiry(updatedAt: string): string { const TTL_MS = 30 * 60 * 1000; const elapsed = Date.now() - new Date(updatedAt).getTime(); const remaining = TTL_MS - elapsed; if (remaining <= 0) return "expiring"; return formatDuration(remaining); } function ConnectionRow({ isActive, isLive, tabType, name, hostName, subLabel, icon, onSwitch, onClose, switchTitle, faded, onRename, isDragging, }: { isActive?: boolean; isLive: boolean; tabType: string; name: string; hostName?: string; subLabel: string; icon: React.ReactNode; onSwitch?: () => void; onClose: () => void; switchTitle?: string; faded?: boolean; onRename?: (newLabel: string) => void; isDragging?: boolean; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(name); function startEdit(e: React.MouseEvent) { e.stopPropagation(); setEditValue(name); setEditing(true); } function commitEdit() { const trimmed = editValue.trim(); if (trimmed && trimmed !== name && onRename) { onRename(trimmed); } setEditing(false); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") setEditing(false); } return (
!editing && e.key === "Enter" && onSwitch?.()} className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${ faded ? "opacity-60" : "" } ${isDragging ? "opacity-30" : ""} ${ isActive ? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand" : onSwitch && !editing ? "hover:bg-muted/40 cursor-pointer" : "" }`} >
{icon}
{editing ? ( setEditValue(e.target.value)} onBlur={commitEdit} onKeyDown={handleKeyDown} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground" autoFocus /> ) : ( {name} )} {TYPE_LABELS[tabType] ?? tabType}
{hostName && hostName !== name ? ( {hostName} ·{" "} ) : null} {subLabel}
{editing ? ( ) : ( onRename && ( {t("connections.rename")} ) )} {switchTitle && onSwitch && !editing && ( {switchTitle} )} {!editing && ( )}
); } function SectionHeader({ label, count }: { label: string; count: number }) { return (
{label} {count}
); } export function ConnectionsPanel({ tabs, activeTabId, allHosts, backgroundTabRecords, onSwitchToTab, onCloseTab, onReopenTab, onForgetBackground, onRenameTab, onReorderTabs, }: { tabs: Tab[]; activeTabId: string; allHosts: { id: string; name: string }[]; backgroundTabRecords: OpenTabRecord[]; onSwitchToTab: (tabId: string) => void; onCloseTab: (tabId: string) => void; onReopenTab: ( record: OpenTabRecord, restoredSessionId: string | null, ) => void; onForgetBackground: (recordId: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void; onReorderTabs?: (tabs: Tab[]) => void; }) { const { t } = useTranslation(); const [now, setNow] = useState(Date.now()); const [activeSessions, setActiveSessions] = useState([]); const [search, setSearch] = useState(""); // Drag-to-reorder state const [dragTabId, setDragTabId] = useState(null); const [dragOverTabId, setDragOverTabId] = useState(null); const rowEls = useRef>(new Map()); const dragStartY = useRef(0); const didDragRef = useRef(false); const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type), ); const openInstanceIds = new Set( tabs.map((t) => t.instanceId).filter(Boolean), ); const backgroundTabs = backgroundTabRecords.filter( (r) => !openInstanceIds.has(r.id), ); const q = search.trim().toLowerCase(); const filteredOpenTabs = q ? openTabs.filter((tab) => { const displayName = tab.customLabel ?? tab.host?.name ?? tab.label; return ( displayName.toLowerCase().includes(q) || (tab.host?.name ?? "").toLowerCase().includes(q) ); }) : openTabs; const filteredBackgroundTabs = q ? backgroundTabs.filter((r) => { const host = allHosts.find((h) => h.id === String(r.hostId)); return (host?.name ?? r.label).toLowerCase().includes(q); }) : backgroundTabs; // Duration labels only need minute-level freshness; 1s ticks re-render the whole panel. usePageVisibleInterval(() => setNow(Date.now()), 15_000); const refresh = useCallback(async () => { try { const sessions = await getActiveSessions(); setActiveSessions((prev) => { const next = Array.isArray(sessions) ? sessions : []; if (sessionsUnchanged(prev, next)) return prev; return next; }); } catch { // silently ignore } }, []); // Initial fetch + visibility-aware poll (hook fires once on mount). usePageVisibleInterval(() => { void refresh(); }, 5_000); // Global pointer listeners for drag reorder useEffect(() => { if (!dragTabId) return; function onPointerMove(e: PointerEvent) { if (Math.abs(e.clientY - dragStartY.current) > 4) didDragRef.current = true; if (!didDragRef.current) return; // Find which row the pointer is over let overTabId: string | null = null; rowEls.current.forEach((el, id) => { if (id === dragTabId) return; const rect = el.getBoundingClientRect(); if (e.clientY >= rect.top && e.clientY <= rect.bottom) { overTabId = id; } }); setDragOverTabId(overTabId); } function onPointerUp(e: PointerEvent) { if (didDragRef.current && dragTabId && onReorderTabs) { // Find drop target let targetTabId: string | null = null; rowEls.current.forEach((el, id) => { if (id === dragTabId) return; const rect = el.getBoundingClientRect(); if (e.clientY >= rect.top && e.clientY <= rect.bottom) { targetTabId = id; } }); if (targetTabId) { const fromIdx = openTabs.findIndex((t) => t.id === dragTabId); const toIdx = openTabs.findIndex((t) => t.id === targetTabId); if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) { const reordered = [...openTabs]; reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]); const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]); const nonConnectionTabs = tabs.filter( (t) => !connectionSet.has(t.type), ); onReorderTabs([...nonConnectionTabs, ...reordered]); } } } setDragTabId(null); setDragOverTabId(null); setTimeout(() => { didDragRef.current = false; }, 0); } window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); return () => { window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); }; }, [dragTabId, openTabs, tabs, onReorderTabs]); const sessionByInstanceId = new Map( activeSessions.map((s) => [s.tabInstanceId, s]), ); const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0; const hasResults = filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0; if (!hasAnything) { return (
{t("connections.noConnections")} {t("connections.noConnectionsDesc")}
); } return (
setSearch(e.target.value)} className="pl-8 h-7 text-xs" />
{!hasResults && (
{t("connections.noSearchResults")}
)} {filteredOpenTabs.length > 0 && (
{filteredOpenTabs.map((tab) => { const isActive = tab.id === activeTabId; const liveSession = tab.instanceId ? sessionByInstanceId.get(tab.instanceId) : undefined; const isLive = tab.type === "terminal" ? (liveSession?.isConnected ?? false) : true; const duration = liveSession?.createdAt ? formatDuration(now - liveSession.createdAt) : formatDuration(now - tab.openedAt); const displayName = tab.customLabel ?? tab.host?.name ?? tab.label; const hostName = tab.host?.name; const isDraggingThis = dragTabId === tab.id; const isDropTarget = dragOverTabId === tab.id && !isDraggingThis; return (
{ if (el) rowEls.current.set(tab.id, el); else rowEls.current.delete(tab.id); }} onPointerDown={(e) => { if (e.button !== 0) return; dragStartY.current = e.clientY; didDragRef.current = false; setDragTabId(tab.id); }} className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`} style={{ cursor: dragTabId ? isDraggingThis ? "grabbing" : "default" : "grab", }} > { if (!didDragRef.current) onSwitchToTab(tab.id); }} onClose={() => onCloseTab(tab.id)} onRename={ onRenameTab ? (newLabel) => onRenameTab(tab.id, newLabel) : undefined } isDragging={isDraggingThis} />
); })}
)} {filteredBackgroundTabs.length > 0 && (
0 ? "mt-2" : ""}`} >
{t("connections.backgroundDesc")}
{filteredBackgroundTabs.map((record) => { const host = record.hostId ? allHosts.find((h) => h.id === String(record.hostId)) : undefined; const expiresIn = formatExpiry(record.updatedAt); return ( { const liveSession = sessionByInstanceId.get(record.id); onReopenTab(record, liveSession?.sessionId ?? null); }} onClose={async () => { await deleteOpenTab(record.id).catch(() => {}); onForgetBackground(record.id); }} switchTitle={t("connections.reconnect")} /> ); })}
)}
); }