From 4c2f54dac04887c5e97c3733d586bd10c6770f67 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:47:21 +0800 Subject: [PATCH] feat(tmux-monitor): add a unified multi-host view (#1520) --- plugins/tmux-monitor/README.md | 23 + plugins/tmux-monitor/locales/en.json | 10 +- .../src/frontend/HostOverview.tsx | 108 +++ .../tmux-monitor/src/frontend/SessionTree.tsx | 29 +- .../tmux-monitor/src/frontend/TmuxMonitor.tsx | 613 +++++++++++++++--- plugins/tmux-monitor/src/frontend/index.tsx | 12 +- .../src/frontend/monitor-storage.ts | 36 + .../tmux-monitor/src/frontend/poll-queue.ts | 19 + .../tests/frontend/monitor-storage.test.ts | 32 + .../frontend/multi-host-monitor.test.tsx | 351 ++++++++++ .../tests/frontend/poll-queue.test.ts | 24 + .../frontend/session-tree-selection.test.tsx | 50 ++ 12 files changed, 1192 insertions(+), 115 deletions(-) create mode 100644 plugins/tmux-monitor/src/frontend/HostOverview.tsx create mode 100644 plugins/tmux-monitor/src/frontend/monitor-storage.ts create mode 100644 plugins/tmux-monitor/src/frontend/poll-queue.ts create mode 100644 plugins/tmux-monitor/tests/frontend/monitor-storage.test.ts create mode 100644 plugins/tmux-monitor/tests/frontend/multi-host-monitor.test.tsx create mode 100644 plugins/tmux-monitor/tests/frontend/poll-queue.test.ts create mode 100644 plugins/tmux-monitor/tests/frontend/session-tree-selection.test.tsx diff --git a/plugins/tmux-monitor/README.md b/plugins/tmux-monitor/README.md index 80ca29dbb..bb4b5bb1b 100644 --- a/plugins/tmux-monitor/README.md +++ b/plugins/tmux-monitor/README.md @@ -10,6 +10,29 @@ Browse and control tmux sessions, windows and panes across your hosts. - Attach a terminal to any pane. - Tag sessions with your own labels. +## Unified monitor + +Open Tmux Monitor from a host action or the command palette. **All hosts** +shows an expandable tree for each SSH host with the monitor enabled. Filter +by host name or address, or choose **Single host** to use one host at a time. +Click a session name or pane to switch the live preview. The tree actions +continue to target the host containing that session, even when two hosts +have identical session names or pane IDs. The toolbar and output search +operate on the selected host. + +The monitor remembers its mode, filter, collapsed hosts, expanded sessions, +last host and selected pane locally. An explicit host action takes precedence +over the saved host and clears the filter. A saved pane is restored only if +it still exists in the host's latest overview. + +Expanded background hosts poll overview and resource metrics independently, +with at most four background hosts loading at once. Collapsing or filtering +out a background host stops its polling; switching away from the monitor +stops all overview/metrics polling. The selected host keeps polling while +its preview is active, including when its tree is collapsed. These controls +use the existing SSH APIs and require only tmux on the remote hosts, not +Auto Tmux. + ## Settings ### Host diff --git a/plugins/tmux-monitor/locales/en.json b/plugins/tmux-monitor/locales/en.json index f38d2f6d8..983a87794 100644 --- a/plugins/tmux-monitor/locales/en.json +++ b/plugins/tmux-monitor/locales/en.json @@ -100,6 +100,14 @@ "timeJustNow": "just now", "timeMinutes": "{{count}}m ago", "timeHours": "{{count}}h ago", - "timeDays": "{{count}}d ago" + "timeDays": "{{count}}d ago", + "allHosts": "All hosts", + "singleHost": "Single host", + "hostScope": "Monitor scope", + "filterHosts": "Filter hosts by name or address", + "selectHost": "Select host", + "toggleHost": "Expand or collapse {{host}}", + "noMatchingHosts": "No matching hosts", + "toggleSession": "Expand or collapse {{session}}" } } diff --git a/plugins/tmux-monitor/src/frontend/HostOverview.tsx b/plugins/tmux-monitor/src/frontend/HostOverview.tsx new file mode 100644 index 000000000..3a960ec5d --- /dev/null +++ b/plugins/tmux-monitor/src/frontend/HostOverview.tsx @@ -0,0 +1,108 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { Button, Skeleton } from "@termix/plugin-sdk/ui"; +import { + getTmuxOverview, + getTmuxMetrics, + type TmuxOverview, + type TmuxPaneMetrics, +} from "./api"; +import { pollBackgroundHost } from "./poll-queue"; +import { useAdaptivePolling } from "./use-adaptive-polling"; + +/** Each background host owns its response, error and adaptive polling cycle. */ +export function HostOverview({ + hostId, + children, +}: { + hostId: number; + children: (overview: TmuxOverview, metrics: TmuxPaneMetrics[]) => ReactNode; +}) { + const { t } = useTranslation(); + const [overview, setOverview] = useState(null); + const [metrics, setMetrics] = useState([]); + const [error, setError] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + const signature = useRef(""); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const load = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + try { + const snapshot = await pollBackgroundHost(async () => { + if (!mounted.current) return null; + const next = await getTmuxOverview(hostId); + const values = + next.available && mounted.current + ? await getTmuxMetrics(hostId).catch(() => []) + : []; + return { next, values }; + }); + if (!snapshot) return; + const { next, values } = snapshot; + if (!mounted.current) return; + setOverview(next); + setError(false); + setMetrics(values); + const nextSignature = JSON.stringify([next, values]); + const changed = signature.current !== nextSignature; + signature.current = nextSignature; + return changed; + } catch (err) { + if (mounted.current) { + setError(true); + setMetrics([]); + } + throw err; + } finally { + inFlight.current = false; + } + }, [hostId]); + useAdaptivePolling( + load, + { minIntervalMs: 10_000, maxIntervalMs: 60_000, stablePollsPerStep: 3 }, + true, + { runImmediately: true }, + ); + if (error) + return ( +
+ {t("tmuxMonitor.failedToLoad")} + +
+ ); + if (!overview) return ; + if (!overview.available) + return ( +

+ {t("tmuxMonitor.tmuxUnavailable")} +

+ ); + if (!overview.sessions.length) + return ( +

+ {t("tmuxMonitor.noSessions")} +

+ ); + return children(overview, metrics); +} diff --git a/plugins/tmux-monitor/src/frontend/SessionTree.tsx b/plugins/tmux-monitor/src/frontend/SessionTree.tsx index 10349804b..5ebb69da4 100644 --- a/plugins/tmux-monitor/src/frontend/SessionTree.tsx +++ b/plugins/tmux-monitor/src/frontend/SessionTree.tsx @@ -57,6 +57,7 @@ interface SessionTreeProps { onEditTags: (sessionName: string) => void; /** Open a terminal attached to the session (the ▷ hover action). */ onAttachSession: (sessionName: string) => void; + onSelectSession?: (sessionName: string) => void; /** Create a new window in the session (the + hover action). */ onNewWindow: (sessionName: string) => void; /** Open the rename dialog for a session (… menu). */ @@ -87,6 +88,7 @@ export function SessionTree({ metricsBySession, onEditTags, onAttachSession, + onSelectSession, onNewWindow, onRenameSession, onKillSession, @@ -137,13 +139,28 @@ export function SessionTree({
onToggleSession(session.name)} + onClick={() => + onSelectSession + ? onSelectSession(session.name) + : onToggleSession(session.name) + } > - {expanded ? ( - - ) : ( - - )} + 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`} title={ diff --git a/plugins/tmux-monitor/src/frontend/TmuxMonitor.tsx b/plugins/tmux-monitor/src/frontend/TmuxMonitor.tsx index c53d3b08e..7d55cd961 100644 --- a/plugins/tmux-monitor/src/frontend/TmuxMonitor.tsx +++ b/plugins/tmux-monitor/src/frontend/TmuxMonitor.tsx @@ -21,6 +21,8 @@ import { isElectron, } from "@termix/plugin-sdk/ui"; import { + ChevronDown, + ChevronRight, ChevronsDownUp, ChevronsUpDown, ExternalLink, @@ -57,6 +59,24 @@ import { SearchResults } from "./SearchResults"; import { PanePreview } from "./PanePreview"; import type { SelectedPane } from "./types"; +import { HostOverview } from "./HostOverview"; +import { + readMonitorValue, + saveMonitorValue, + readExpanded, + readSelectedPane, +} from "./monitor-storage"; + +type TreeAction = + | { type: "pane"; pane: SelectedPane } + | { + type: "attach" | "tags" | "rename" | "killSession" | "newWindow"; + session: string; + } + | { type: "killPane"; paneId: string } + | { type: "split"; paneId: string; direction: "h" | "v" } + | { type: "killWindow"; session: string; index: number }; + const OVERVIEW_POLL_MS = 10_000; const METRICS_POLL_MS = 10_000; const TIME_TICK_MS = 30_000; @@ -73,23 +93,13 @@ function expandedStorageKey(hostId: string | number): string { return `${LS_PREFIX}expanded-${hostId}`; } -function readStoredExpanded(hostId: string | number): Set | null { - try { - const raw = localStorage.getItem(expandedStorageKey(hostId)); - if (!raw) return null; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return null; - return new Set(parsed.filter((v): v is string => typeof v === "string")); - } catch { - return null; - } -} - export function TmuxMonitor({ initialHostId, + initialHostRequest, isVisible = true, }: { initialHostId?: number; + initialHostRequest?: number; isVisible?: boolean; }) { const { t } = useTranslation(); @@ -99,6 +109,7 @@ export function TmuxMonitor({ () => allHosts.filter( (h) => + h.enableSsh !== false && ((h.connectionType as string | undefined) ?? "ssh") === "ssh" && tmuxMonitorEnabled(h), ), @@ -107,6 +118,31 @@ export function TmuxMonitor({ const [selectedHostId, setSelectedHostId] = useState( null, ); + const [scope, setScope] = useState<"all" | "single">(() => + readMonitorValue("scope", "all") === "single" ? "single" : "all", + ); + const [hostFilter, setHostFilter] = useState(() => { + const value = readMonitorValue("filter", ""); + return typeof value === "string" ? value : ""; + }); + const [collapsedHosts, setCollapsedHosts] = useState>(() => { + const value = readMonitorValue("collapsed-hosts", []); + return new Set( + Array.isArray(value) + ? value.filter((id): id is string => typeof id === "string") + : [], + ); + }); + const [expandedByHost, setExpandedByHost] = useState< + Record> + >({}); + const [pendingAction, setPendingAction] = useState<{ + hostId: string | number; + action: TreeAction; + } | null>(null); + const [overviewHostId, setOverviewHostId] = useState( + null, + ); const [overview, setOverview] = useState(null); const [overviewLoading, setOverviewLoading] = useState(false); const [overviewError, setOverviewError] = useState(null); @@ -132,6 +168,7 @@ export function TmuxMonitor({ // stale (the user switched away while the request was in flight) and must // not overwrite the current tree. const activeHostRef = useRef(null); + const hostGeneration = useRef(0); const overviewSignatureRef = useRef(""); const metricsSignatureRef = useRef(""); // True when the expanded-session set for the current host was restored from @@ -153,7 +190,7 @@ export function TmuxMonitor({ // -- resizable tree panel (same pattern as the AppShell host sidebar) ------- const [treeWidth, setTreeWidth] = useState(() => { - const saved = Number(localStorage.getItem(LS_TREE_WIDTH_KEY)); + const saved = Number(readMonitorValue("tree-width", TREE_WIDTH_DEFAULT)); return Number.isFinite(saved) && saved >= TREE_WIDTH_MIN ? Math.min(saved, TREE_WIDTH_MAX) : TREE_WIDTH_DEFAULT; @@ -203,12 +240,18 @@ export function TmuxMonitor({ hosts.some((h) => String(h.id) === String(initialHostId)) ) { initialHostPicked.current = true; - return initialHostId; + return hosts.find((h) => String(h.id) === String(initialHostId))!.id; } - const stored = localStorage.getItem(LS_LAST_HOST_KEY); - if (stored !== null && hosts.some((h) => String(h.id) === stored)) { + const stored = readMonitorValue( + "last-host", + null, + ); + if ( + stored !== null && + hosts.some((h) => String(h.id) === String(stored)) + ) { initialHostPicked.current = true; - return stored; + return hosts.find((h) => String(h.id) === String(stored))!.id; } initialHostPicked.current = true; return hosts[0].id; @@ -219,7 +262,7 @@ export function TmuxMonitor({ // for it), fall back the same way the initial pick does. useEffect(() => { if (!hostsLoaded || selectedHostId === null) return; - if (hosts.some((h) => h.id === selectedHostId)) return; + if (hosts.some((h) => String(h.id) === String(selectedHostId))) return; setSelectedHostId(hosts.length > 0 ? hosts[0].id : null); }, [hostsLoaded, hosts, selectedHostId]); @@ -227,12 +270,20 @@ export function TmuxMonitor({ // follow it so the requested host gets selected without a remount. useEffect(() => { if (initialHostId == null) return; - if (hosts.some((h) => String(h.id) === String(initialHostId))) - setSelectedHostId(initialHostId); + const host = hosts.find((h) => String(h.id) === String(initialHostId)); + if (host) setSelectedHostId(host.id); + setHostFilter(""); + saveMonitorValue("filter", ""); + setCollapsedHosts((previous) => { + const next = new Set(previous); + next.delete(String(initialHostId)); + saveMonitorValue("collapsed-hosts", [...next]); + return next; + }); // Intentionally not depending on `hosts`: the mount effect already picks // the initial host once hosts load. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialHostId]); + }, [initialHostId, initialHostRequest]); // -- relative time refresh -------------------------------------------------- useEffect(() => { @@ -244,24 +295,54 @@ export function TmuxMonitor({ // -- overview polling ----------------------------------------------------- const loadOverview = useCallback( async (hostId: string | number, silent = false) => { + const generation = hostGeneration.current; if (!silent) { setOverviewLoading(true); setOverviewError(null); } try { const data = await getTmuxOverview(Number(hostId)); - if (activeHostRef.current !== hostId) return true; + if ( + activeHostRef.current !== hostId || + generation !== hostGeneration.current + ) + return true; const signature = JSON.stringify(data); const changed = signature !== overviewSignatureRef.current; overviewSignatureRef.current = signature; + setOverviewError(null); + setOverviewHostId(hostId); setOverview(data); + setSelectedPane((pane) => { + const candidate = pane ?? readSelectedPane(hostId); + const exists = + candidate && + data.sessions.some( + (session) => + session.name === candidate.sessionName && + session.windows.some( + (win) => + win.index === candidate.windowIndex && + win.panes.some((p) => p.id === candidate.paneId), + ), + ); + if (!exists) saveMonitorValue(`pane-${hostId}`, null); + return exists ? candidate : null; + }); setExpandedSessions((prev) => { if (expandedRestoredRef.current || prev.size > 0) return prev; return new Set(data.sessions.map((s) => s.name)); }); return changed; } catch (err) { - if (activeHostRef.current !== hostId) return true; + if ( + activeHostRef.current !== hostId || + generation !== hostGeneration.current + ) + return true; + setPendingAction((pending) => + pending?.hostId === hostId ? null : pending, + ); if (!silent) { const axiosErr = err as { code?: string; @@ -294,7 +375,11 @@ export function TmuxMonitor({ if (silent) throw err; return false; } finally { - if (!silent && activeHostRef.current === hostId) + if ( + !silent && + activeHostRef.current === hostId && + generation === hostGeneration.current + ) setOverviewLoading(false); } }, @@ -314,8 +399,9 @@ export function TmuxMonitor({ } setRefreshing(true); try { - const ok = await loadOverview(selectedHostId, true); - if (!ok) toast.error(t("tmuxMonitor.refreshFailed")); + await loadOverview(selectedHostId, true); + } catch { + toast.error(t("tmuxMonitor.refreshFailed")); } finally { setRefreshing(false); } @@ -323,8 +409,12 @@ export function TmuxMonitor({ useEffect(() => { activeHostRef.current = selectedHostId; + hostGeneration.current++; setOverview(null); + setOverviewHostId(null); setSelectedPane(null); + overviewSignatureRef.current = ""; + metricsSignatureRef.current = ""; setSearchResults(null); setMetrics([]); if (selectedHostId === null) return; @@ -333,7 +423,7 @@ export function TmuxMonitor({ } catch { // localStorage may be unavailable } - const storedExpanded = readStoredExpanded(selectedHostId); + const storedExpanded = readExpanded(selectedHostId); expandedRestoredRef.current = storedExpanded !== null; setExpandedSessions(storedExpanded ?? new Set()); loadOverview(selectedHostId); @@ -359,7 +449,14 @@ export function TmuxMonitor({ useAdaptivePolling( async () => { if (selectedHostId === null) return; - const next = await getTmuxMetrics(Number(selectedHostId)); + const hostId = selectedHostId; + const generation = hostGeneration.current; + const next = await getTmuxMetrics(Number(hostId)); + if ( + activeHostRef.current !== hostId || + generation !== hostGeneration.current + ) + return; const signature = JSON.stringify(next); const changed = signature !== metricsSignatureRef.current; metricsSignatureRef.current = signature; @@ -371,7 +468,10 @@ export function TmuxMonitor({ maxIntervalMs: 60_000, stablePollsPerStep: 3, }, - selectedHostId !== null && !!overview?.available && isVisible, + selectedHostId !== null && + overviewHostId === selectedHostId && + !!overview?.available && + isVisible, ); // -- keyboard shortcuts ----------------------------------------------------- @@ -403,6 +503,7 @@ export function TmuxMonitor({ setSearching(true); try { const result = await searchTmux(Number(selectedHostId), query); + if (activeHostRef.current !== selectedHostId) return; setSearchResults(result.matches); setSearchLimits(result); setSearchedQuery(query); @@ -415,6 +516,10 @@ export function TmuxMonitor({ const persistExpanded = useCallback( (hostId: string | number, set: Set) => { + setExpandedByHost((previous) => ({ + ...previous, + [String(hostId)]: new Set(set), + })); try { localStorage.setItem( expandedStorageKey(hostId), @@ -441,8 +546,7 @@ export function TmuxMonitor({ const selectPane = useCallback( (pane: SelectedPane) => { setSelectedPane(pane); - if (selectedHostId !== null) - focusTmuxPane(Number(selectedHostId), pane.paneId).catch(() => {}); + saveMonitorValue(`pane-${selectedHostId}`, pane); }, [selectedHostId], ); @@ -461,6 +565,17 @@ export function TmuxMonitor({ } } + useEffect(() => { + if ( + selectedPane && + selectedHostId !== null && + overviewHostId === selectedHostId + ) + void focusTmuxPane(Number(selectedHostId), selectedPane.paneId).catch( + () => {}, + ); + }, [selectedHostId, overviewHostId, selectedPane]); + // -- create session --------------------------------------------------------- const [newSessionName, setNewSessionName] = useState(""); const [newSessionOpen, setNewSessionOpen] = useState(false); @@ -477,6 +592,7 @@ export function TmuxMonitor({ setCreatingSession(true); try { await createTmuxSession(Number(selectedHostId), name); + if (activeHostRef.current !== selectedHostId) return; toast.success(t("tmuxMonitor.sessionCreated", { name })); setNewSessionOpen(false); setNewSessionName(""); @@ -484,7 +600,7 @@ export function TmuxMonitor({ setExpandedSessions(next); expandedRestoredRef.current = true; persistExpanded(selectedHostId, next); - loadOverview(selectedHostId, true); + void loadOverview(selectedHostId, true).catch(() => {}); } catch (err) { const axiosErr = err as { response?: { data?: { error?: string } } }; toast.error( @@ -504,7 +620,8 @@ export function TmuxMonitor({ if (selectedHostId === null) return; try { await splitTmuxPane(Number(selectedHostId), paneId, direction); - loadOverview(selectedHostId, true); + if (activeHostRef.current !== selectedHostId) return; + void loadOverview(selectedHostId, true).catch(() => {}); nudgePreviewRedraw(); } catch { toast.error(t("tmuxMonitor.splitFailed")); @@ -519,7 +636,8 @@ export function TmuxMonitor({ if (selectedHostId === null) return; try { await createTmuxWindow(Number(selectedHostId), sessionName); - loadOverview(selectedHostId, true); + if (activeHostRef.current !== selectedHostId) return; + void loadOverview(selectedHostId, true).catch(() => {}); nudgePreviewRedraw(); } catch { toast.error(t("tmuxMonitor.windowCreateFailed")); @@ -560,6 +678,7 @@ export function TmuxMonitor({ setRenaming(true); try { await renameTmuxSession(Number(selectedHostId), renameTarget, newName); + if (activeHostRef.current !== selectedHostId) return; toast.success(t("tmuxMonitor.sessionRenamed", { name: newName })); if (expandedSessions.has(renameTarget)) { const next = new Set(expandedSessions); @@ -574,7 +693,7 @@ export function TmuxMonitor({ setSelectedPane({ ...selectedPane, sessionName: newName }); } setRenameTarget(null); - loadOverview(selectedHostId, true); + void loadOverview(selectedHostId, true).catch(() => {}); } catch (err) { const axiosErr = err as { response?: { data?: { error?: string } } }; toast.error( @@ -590,6 +709,7 @@ export function TmuxMonitor({ setKilling(true); try { await killTmuxSession(Number(selectedHostId), killTarget); + if (activeHostRef.current !== selectedHostId) return; toast.success(t("tmuxMonitor.sessionKilled", { name: killTarget })); if (selectedPane?.sessionName === killTarget) setSelectedPane(null); if (expandedSessions.has(killTarget)) { @@ -599,7 +719,7 @@ export function TmuxMonitor({ persistExpanded(selectedHostId, next); } setKillTarget(null); - loadOverview(selectedHostId, true); + void loadOverview(selectedHostId, true).catch(() => {}); } catch (err) { const axiosErr = err as { response?: { data?: { error?: string } } }; toast.error( @@ -649,11 +769,13 @@ export function TmuxMonitor({ killWindowTarget.sessionName, killWindowTarget.windowIndex, ); + if (activeHostRef.current !== selectedHostId) return; const wasViewing = selectedPane?.sessionName === killWindowTarget.sessionName && selectedPane?.windowIndex === killWindowTarget.windowIndex; setKillWindowTarget(null); const data = await getTmuxOverview(Number(selectedHostId)); + if (activeHostRef.current !== selectedHostId) return; setOverview(data); if (wasViewing) selectSurvivor(data, killWindowTarget.sessionName); nudgePreviewRedraw(); @@ -677,12 +799,14 @@ export function TmuxMonitor({ setKillingPane(true); try { await killTmuxPane(Number(selectedHostId), killPaneTarget); + if (activeHostRef.current !== selectedHostId) return; const viewedSession = selectedPane?.paneId === killPaneTarget ? selectedPane.sessionName : null; setKillPaneTarget(null); const data = await getTmuxOverview(Number(selectedHostId)); + if (activeHostRef.current !== selectedHostId) return; setOverview(data); if (viewedSession) selectSurvivor(data, viewedSession); nudgePreviewRedraw(); @@ -716,9 +840,10 @@ export function TmuxMonitor({ setSavingTags(true); try { await setTmuxSessionTags(Number(selectedHostId), tagsTarget, tags); + if (activeHostRef.current !== selectedHostId) return; toast.success(t("tmuxMonitor.tagsSaved")); setTagsTarget(null); - loadOverview(selectedHostId, true); + void loadOverview(selectedHostId, true).catch(() => {}); } catch { toast.error(t("tmuxMonitor.tagsSaveFailed")); } finally { @@ -789,7 +914,7 @@ export function TmuxMonitor({ }, [metrics]); const selectedHost: PluginHostRecord | undefined = hosts.find( - (h) => h.id === selectedHostId, + (h) => String(h.id) === String(selectedHostId), ); const hostLabel = selectedHost ? selectedHost.name || @@ -799,6 +924,156 @@ export function TmuxMonitor({ ? metricsByPane.get(selectedPane.paneId) : undefined; + function executeTreeAction(action: TreeAction) { + if ( + action.type === "pane" && + !overview?.sessions.some( + (session) => + session.name === action.pane.sessionName && + session.windows.some( + (win) => + win.index === action.pane.windowIndex && + win.panes.some((pane) => pane.id === action.pane.paneId), + ), + ) + ) + return; + switch (action.type) { + case "pane": + selectPane(action.pane); + break; + case "attach": + attachInline(action.session); + break; + case "tags": + openTagsEditor(action.session); + break; + case "rename": + setRenameDraft(action.session); + setRenameTarget(action.session); + break; + case "killSession": + setKillTarget(action.session); + break; + case "newWindow": + void newWindow(action.session); + break; + case "killPane": + setKillPaneTarget(action.paneId); + break; + case "split": + void splitPane(action.paneId, action.direction); + break; + case "killWindow": + setKillWindowTarget({ + sessionName: action.session, + windowIndex: action.index, + }); + break; + } + } + function dispatchTreeAction(hostId: string | number, action: TreeAction) { + if (hostId === selectedHostId && overviewHostId === hostId) + executeTreeAction(action); + else { + setPendingAction({ hostId, action }); + setSelectedHostId(hostId); + } + } + const executeTreeActionRef = useRef(executeTreeAction); + executeTreeActionRef.current = executeTreeAction; + useEffect(() => { + if ( + !pendingAction || + pendingAction.hostId !== selectedHostId || + overviewHostId !== selectedHostId + ) + return; + setPendingAction(null); + executeTreeActionRef.current(pendingAction.action); + }, [pendingAction, selectedHostId, overviewHostId]); + useEffect(() => { + setPendingAction((pending) => + pending?.hostId === selectedHostId ? pending : null, + ); + setRenameTarget(null); + setKillTarget(null); + setTagsTarget(null); + setKillPaneTarget(null); + setKillWindowTarget(null); + setNewSessionOpen(false); + }, [selectedHostId]); + + useEffect(() => { + if (selectedHostId !== null && overviewHostId === selectedHostId) + saveMonitorValue(`pane-${selectedHostId}`, selectedPane); + }, [selectedHostId, overviewHostId, selectedPane]); + + const filteredHosts = + scope === "single" + ? hosts.filter((h) => h.id === selectedHostId) + : hosts.filter((h) => + `${h.name ?? ""} ${h.ip ?? ""}` + .toLowerCase() + .includes(hostFilter.toLowerCase().trim()), + ); + + function backgroundTree( + hostId: string | number, + data: TmuxOverview, + values: TmuxPaneMetrics[], + ) { + const expanded = + expandedByHost[String(hostId)] ?? + readExpanded(hostId) ?? + new Set(data.sessions.map((session) => session.name)); + const byPane = new Map(values.map((m) => [m.paneId, m])); + const bySession = new Map(); + for (const m of values) { + const agg = bySession.get(m.sessionName) ?? { + cpu: 0, + memKb: 0, + gpuMb: 0, + }; + agg.cpu += m.cpuPercent; + agg.memKb += m.memRssKb; + agg.gpuMb += m.gpuMemMb; + bySession.set(m.sessionName, agg); + } + const run = (action: TreeAction) => dispatchTreeAction(hostId, action); + return ( + { + const next = new Set(expanded); + if (next.has(name)) next.delete(name); + else next.add(name); + persistExpanded(hostId, next); + }} + selectedPaneId={null} + onSelectPane={(pane) => run({ type: "pane", pane })} + metricsByPane={byPane} + metricsBySession={bySession} + onEditTags={(session) => run({ type: "tags", session })} + onAttachSession={(session) => run({ type: "attach", session })} + onSelectSession={(session) => run({ type: "attach", session })} + onNewWindow={(session) => run({ type: "newWindow", session })} + onRenameSession={(session) => run({ type: "rename", session })} + onKillSession={(session) => run({ type: "killSession", session })} + onKillPane={(paneId) => run({ type: "killPane", paneId })} + onSplitPane={(paneId, direction) => + run({ type: "split", paneId, direction }) + } + onKillWindow={(session, index) => + run({ type: "killWindow", session, index }) + } + compact={treeWidth < 280} + now={now} + /> + ); + } + return (
{/* Left rail: hosts + session tree. Resizable via the right-edge @@ -904,6 +1179,52 @@ export function TmuxMonitor({
+
+ + {scope === "all" ? ( + { + setHostFilter(e.target.value); + saveMonitorValue("filter", e.target.value); + }} + className="h-8" + /> + ) : ( + + )} +
{/* Radix wraps the viewport content in a display:table div sized to the widest row, so one long pane path would stretch every row and clip the right-aligned actions; force block so rows shrink and @@ -920,75 +1241,152 @@ export function TmuxMonitor({

)} - {overviewLoading && ( -
- {[0, 1, 2].map((i) => ( -
- - - -
- ))} -
- )} - {overviewError && ( -
-

{overviewError}

- -
- )} - {overview && !overview.available && ( -
-

- {t("tmuxMonitor.tmuxUnavailable")} -

-

- {t("tmuxMonitor.tmuxInstallHint")}{" "} - sudo apt install tmux -

-
- )} - {overview?.available && overview.sessions.length === 0 && ( -

- {t("tmuxMonitor.noSessions")} +

+ {scope === "all" && ( + + )} + +
+ {!collapsed && + (active ? ( + <> + {overviewLoading && ( +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ )} + {overviewError && ( +
+

+ {overviewError} +

+ +
+ )} + {overview && !overview.available && ( +
+

+ {t("tmuxMonitor.tmuxUnavailable")} +

+

+ {t("tmuxMonitor.tmuxInstallHint")}{" "} + + sudo apt install tmux + +

+
+ )} + {overview?.available && + overview.sessions.length === 0 && ( +

+ {t("tmuxMonitor.noSessions")} +

+ )} + {overview?.available && + overviewHostId === selectedHostId && ( + { + setRenameDraft(name); + setRenameTarget(name); + }} + onKillSession={setKillTarget} + onKillPane={setKillPaneTarget} + onSplitPane={splitPane} + onKillWindow={(sessionName, windowIndex) => + setKillWindowTarget({ + sessionName, + windowIndex, + }) + } + compact={treeWidth < 280} + now={now} + /> + )} + + ) : ( + isVisible && + selectedHostId !== null && ( + + {(data, values) => + backgroundTree(host.id, data, values) + } + + ) + ))} + + ); + })} + {hosts.length > 0 && filteredHosts.length === 0 && ( +

+ {t("tmuxMonitor.noMatchingHosts")}

)} - {overview?.available && ( - { - setRenameDraft(name); - setRenameTarget(name); - }} - onKillSession={setKillTarget} - onKillPane={setKillPaneTarget} - onSplitPane={splitPane} - onKillWindow={(sessionName, windowIndex) => - setKillWindowTarget({ sessionName, windowIndex }) - } - compact={treeWidth < 280} - now={now} - /> - )}
@@ -1077,7 +1475,7 @@ export function TmuxMonitor({ {/* Pane preview */}
- {selectedPane && selectedHost ? ( + {selectedPane && selectedHost && overviewHostId === selectedHostId ? ( splitPane(selectedPane.paneId, direction)} onKillPane={() => setKillPaneTarget(selectedPane.paneId)} - onClose={() => setSelectedPane(null)} + onClose={() => { + setSelectedPane(null); + saveMonitorValue(`pane-${selectedHostId}`, null); + }} /> ) : hostsLoaded && hosts.length === 0 ? (
diff --git a/plugins/tmux-monitor/src/frontend/index.tsx b/plugins/tmux-monitor/src/frontend/index.tsx index 275076d96..a19d017fd 100644 --- a/plugins/tmux-monitor/src/frontend/index.tsx +++ b/plugins/tmux-monitor/src/frontend/index.tsx @@ -14,7 +14,13 @@ const VIEW_ID = "tmux_monitor"; function TmuxMonitorTab({ tab, isVisible }: TabProps) { const hostId = (tab.data?.hostId as number | undefined) ?? undefined; - return ; + return ( + + ); } function TmuxMonitorStandalone(props: StandaloneViewProps) { @@ -46,7 +52,9 @@ export function activate(app: TermixApp): void { copyUrlView: VIEW_ID, when: (host) => !!host.enableSsh && tmuxMonitorEnabled(host), run: (host, shell) => - shell.openSingletonTab(VIEW_ID, { data: { hostId: Number(host.id) } }), + shell.openSingletonTab(VIEW_ID, { + data: { hostId: Number(host.id), hostRequest: Date.now() }, + }), }); app.registerHostEditorSection({ diff --git a/plugins/tmux-monitor/src/frontend/monitor-storage.ts b/plugins/tmux-monitor/src/frontend/monitor-storage.ts new file mode 100644 index 000000000..017eeed70 --- /dev/null +++ b/plugins/tmux-monitor/src/frontend/monitor-storage.ts @@ -0,0 +1,36 @@ +import type { SelectedPane } from "./types"; + +const PREFIX = "termix-tmux-monitor-"; +export function readMonitorValue(key: string, fallback: T): T { + try { + const value = localStorage.getItem(PREFIX + key); + return value === null ? fallback : JSON.parse(value); + } catch { + return fallback; + } +} +export function saveMonitorValue(key: string, value: unknown): void { + try { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + } catch { + /* unavailable */ + } +} +export function readExpanded(hostId: number | string): Set | null { + const value = readMonitorValue(`expanded-${hostId}`, null); + return Array.isArray(value) + ? new Set(value.filter((v): v is string => typeof v === "string")) + : null; +} +export function readSelectedPane(hostId: number | string): SelectedPane | null { + const value = readMonitorValue | null>( + `pane-${hostId}`, + null, + ); + return value && + typeof value.paneId === "string" && + typeof value.sessionName === "string" && + Number.isInteger(value.windowIndex) + ? (value as SelectedPane) + : null; +} diff --git a/plugins/tmux-monitor/src/frontend/poll-queue.ts b/plugins/tmux-monitor/src/frontend/poll-queue.ts new file mode 100644 index 000000000..af8acce85 --- /dev/null +++ b/plugins/tmux-monitor/src/frontend/poll-queue.ts @@ -0,0 +1,19 @@ +/** Bound background SSH polling when the unified view contains many hosts. */ +export function createPollQueue(limit: number) { + let running = 0; + const waiting: Array<() => void> = []; + return async function run(task: () => Promise): Promise { + if (running >= limit) + await new Promise((resolve) => waiting.push(resolve)); + else running++; + try { + return await task(); + } finally { + const next = waiting.shift(); + if (next) next(); + else running--; + } + }; +} + +export const pollBackgroundHost = createPollQueue(4); diff --git a/plugins/tmux-monitor/tests/frontend/monitor-storage.test.ts b/plugins/tmux-monitor/tests/frontend/monitor-storage.test.ts new file mode 100644 index 000000000..33e28ee45 --- /dev/null +++ b/plugins/tmux-monitor/tests/frontend/monitor-storage.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + readExpanded, + readSelectedPane, + readMonitorValue, + saveMonitorValue, +} from "../../src/frontend/monitor-storage"; + +beforeEach(() => localStorage.clear()); +describe("per-host monitor preferences", () => { + it("keeps identically named sessions and panes separate by host", () => { + saveMonitorValue("expanded-1", ["same"]); + saveMonitorValue("expanded-2", []); + saveMonitorValue("pane-1", { + paneId: "%0", + sessionName: "same", + windowIndex: 0, + }); + expect(readExpanded(1)).toEqual(new Set(["same"])); + expect(readExpanded(2)).toEqual(new Set()); + expect(readSelectedPane(1)?.paneId).toBe("%0"); + expect(readSelectedPane(2)).toBeNull(); + }); + it("tolerates invalid or obsolete stored values", () => { + localStorage.setItem("termix-tmux-monitor-filter", "{broken"); + expect(readMonitorValue("filter", "")).toBe(""); + saveMonitorValue("pane-1", { paneId: "%0", windowIndex: "0" }); + expect(readSelectedPane(1)).toBeNull(); + saveMonitorValue("expanded-1", ["same", null, 3]); + expect(readExpanded(1)).toEqual(new Set(["same"])); + }); +}); diff --git a/plugins/tmux-monitor/tests/frontend/multi-host-monitor.test.tsx b/plugins/tmux-monitor/tests/frontend/multi-host-monitor.test.tsx new file mode 100644 index 000000000..522136896 --- /dev/null +++ b/plugins/tmux-monitor/tests/frontend/multi-host-monitor.test.tsx @@ -0,0 +1,351 @@ +import type { ComponentProps } from "react"; +import type { PanePreview } from "../../src/frontend/PanePreview"; +import type { SessionTree } from "../../src/frontend/SessionTree"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { TmuxMonitor } from "../../src/frontend/TmuxMonitor"; +import * as api from "../../src/frontend/api"; + +const state = vi.hoisted(() => ({ + hosts: [1, 2].map((id) => ({ + id, + name: `Host ${id}`, + ip: `host${id}`, + enableSsh: true, + pluginSettings: { "tmux-monitor": { enableTmuxMonitor: true } }, + })), + toast: { success: vi.fn(), error: vi.fn() }, + t: (key: string) => key, +})); +vi.mock("@termix/plugin-sdk/frontend", async (original) => ({ + ...(await original()), + useHosts: () => ({ hosts: state.hosts, loaded: true }), + useToast: () => state.toast, + useTranslation: () => ({ t: state.t }), +})); +vi.mock("../../src/frontend/api", () => ({ + getTmuxOverview: vi.fn(), + getTmuxMetrics: vi.fn(), + focusTmuxPane: vi.fn(), + searchTmux: vi.fn(), + setTmuxSessionTags: vi.fn(), + createTmuxSession: vi.fn(), + createTmuxWindow: vi.fn(), + renameTmuxSession: vi.fn(), + killTmuxSession: vi.fn(), + killTmuxWindow: vi.fn(), + killTmuxPane: vi.fn(), + splitTmuxPane: vi.fn(), +})); +vi.mock("../../src/frontend/use-adaptive-polling", async () => { + const { useEffect, useRef } = await import("react"); + return { + useAdaptivePolling: ( + poll: () => Promise, + _policy: unknown, + enabled: boolean, + options?: { runImmediately?: boolean }, + ) => { + const ref = useRef(poll); + ref.current = poll; + useEffect(() => { + if (enabled && options?.runImmediately !== false) + void Promise.resolve(ref.current()).catch(() => {}); + }, [enabled, options?.runImmediately]); + }, + }; +}); +vi.mock("../../src/frontend/PanePreview", () => ({ + PanePreview: ({ + host, + pane, + metrics, + }: ComponentProps) => ( +
+ {host.id}:{pane.paneId}:{metrics?.cpuPercent ?? "none"} +
+ ), +})); +vi.mock("../../src/frontend/SessionTree", () => ({ + SessionTree: (props: ComponentProps) => ( +
+ cpu:{props.metricsByPane.get("%0")?.cpuPercent ?? "none"} + + {props.expandedSessions.has("same") ? "expanded" : "collapsed"} + + + + + + +
+ ), +})); + +function overview(): api.TmuxOverview { + return { + available: true, + sessions: [ + { + name: "same", + created: 1, + lastActivity: 1, + attachedClients: 0, + tags: [], + windows: [ + { + index: 0, + name: "window", + active: true, + panes: [ + { + id: "%0", + index: 0, + pid: 1, + active: true, + width: 80, + height: 24, + command: "bash", + path: "/", + title: "", + }, + ], + }, + ], + }, + ], + }; +} +function metrics(id: number): api.TmuxPaneMetrics[] { + return [ + { + paneId: "%0", + sessionName: "same", + pid: 1, + processCount: 1, + cpuPercent: id * 10, + memRssKb: id, + gpuMemMb: 0, + topCommand: "bash", + }, + ]; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +const host = (id: number) => + within(screen.getByRole("region", { name: `Host ${id}` })); +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.mocked(api.getTmuxOverview).mockImplementation(async () => overview()); + vi.mocked(api.getTmuxMetrics).mockImplementation(async (id) => metrics(id)); + vi.mocked(api.focusTmuxPane).mockResolvedValue(); + vi.mocked(api.createTmuxWindow).mockResolvedValue(); +}); +afterEach(cleanup); + +describe("multi-host tmux monitor", () => { + it("isolates duplicate session/pane IDs and routes preview and management to their host", async () => { + render(); + await waitFor(() => + expect(host(1).getByText("cpu:10")).toBeInTheDocument(), + ); + await waitFor(() => + expect(host(2).getByText("cpu:20")).toBeInTheDocument(), + ); + fireEvent.click(host(2).getByText("pane")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"), + ); + expect(api.focusTmuxPane).toHaveBeenLastCalledWith(2, "%0"); + await waitFor(() => + expect(host(1).getByText("new window")).toBeInTheDocument(), + ); + fireEvent.click(host(1).getByText("new window")); + await waitFor(() => + expect(api.createTmuxWindow).toHaveBeenCalledExactlyOnceWith(1, "same"), + ); + }); + + it("restores the last host, pane, filter and expanded sessions", async () => { + const first = render(); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + fireEvent.click(host(2).getByText("pane")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"), + ); + fireEvent.click(host(2).getByText("toggle session")); + fireEvent.change(screen.getByLabelText("tmuxMonitor.filterHosts"), { + target: { value: "Host 2" }, + }); + first.unmount(); + render(); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"), + ); + expect(screen.getByLabelText("tmuxMonitor.filterHosts")).toHaveValue( + "Host 2", + ); + expect(host(2).getByText("collapsed")).toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Host 1" }), + ).not.toBeInTheDocument(); + }); + + it("ignores late metrics from a previously selected host", async () => { + const old = deferred(); + vi.mocked(api.getTmuxMetrics).mockImplementation((id) => + id === 1 ? old.promise : Promise.resolve(metrics(id)), + ); + render(); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + fireEvent.click(host(2).getByText("pane")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"), + ); + await act(async () => old.resolve(metrics(9))); + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0:20"); + }); + + it("does not execute an abandoned cross-host action after its overview arrives", async () => { + const delayed = deferred(); + render(); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + vi.mocked(api.getTmuxOverview).mockImplementation((id) => + id === 2 ? delayed.promise : Promise.resolve(overview()), + ); + fireEvent.click(host(2).getByText("new window")); + fireEvent.click(host(1).getByRole("button", { name: "Host 1" })); + await act(async () => delayed.resolve(overview())); + expect(api.createTmuxWindow).not.toHaveBeenCalled(); + }); + + it("isolates an unavailable host and stops loading collapsed, filtered and hidden hosts", async () => { + localStorage.setItem("termix-tmux-monitor-collapsed-hosts", '["2"]'); + const app = render(); + await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument()); + expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2); + vi.mocked(api.getTmuxOverview).mockImplementation(async (id) => { + if (id === 2) throw new Error("offline"); + return overview(); + }); + fireEvent.click(host(2).getByLabelText("tmuxMonitor.toggleHost")); + await waitFor(() => + expect(host(2).getByRole("status")).toHaveTextContent( + "tmuxMonitor.failedToLoad", + ), + ); + expect(host(1).getByText("pane")).toBeInTheDocument(); + app.rerender(); + expect(host(2).queryByRole("status")).not.toBeInTheDocument(); + }); + + it("confirms a destructive action on the host selected from the background tree", async () => { + render(); + await waitFor(() => + expect(host(2).getByText("kill session")).toBeInTheDocument(), + ); + fireEvent.click(host(2).getByText("kill session")); + await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument()); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "tmuxMonitor.kill", + }), + ); + await waitFor(() => + expect(api.killTmuxSession).toHaveBeenCalledExactlyOnceWith(2, "same"), + ); + }); + + it("does not restore a pane removed from the server", async () => { + localStorage.setItem( + "termix-tmux-monitor-pane-1", + JSON.stringify({ paneId: "%99", sessionName: "same", windowIndex: 0 }), + ); + render(); + await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument()); + expect(screen.queryByTestId("preview")).not.toBeInTheDocument(); + expect(api.focusTmuxPane).not.toHaveBeenCalled(); + }); + + it("ignores an old overview after switching away and back to the same host", async () => { + const old = deferred(); + let first = true; + vi.mocked(api.getTmuxOverview).mockImplementation((id) => { + if (id === 1 && first) { + first = false; + return old.promise; + } + return Promise.resolve(overview()); + }); + render(); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + fireEvent.click(host(2).getByText("pane")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"), + ); + fireEvent.click(host(1).getByRole("button", { name: "Host 1" })); + await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument()); + await act(async () => old.resolve({ available: false, sessions: [] })); + expect(host(1).getByText("pane")).toBeInTheDocument(); + }); + + it("honors a repeated host action after switching manually", async () => { + const app = render( + , + ); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + fireEvent.click(host(2).getByText("pane")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"), + ); + app.rerender(); + await waitFor(() => + expect(host(1).getByRole("button", { name: "Host 1" })).toHaveAttribute( + "aria-pressed", + "true", + ), + ); + }); + + it("retains single-host mode without loading other hosts", async () => { + localStorage.setItem("termix-tmux-monitor-scope", '"single"'); + render(); + await waitFor(() => expect(host(1).getByText("pane")).toBeInTheDocument()); + expect(api.getTmuxOverview).not.toHaveBeenCalledWith(2); + fireEvent.change(screen.getByLabelText("tmuxMonitor.selectHost"), { + target: { value: "2" }, + }); + await waitFor(() => expect(host(2).getByText("pane")).toBeInTheDocument()); + fireEvent.click(host(2).getByText("session")); + await waitFor(() => + expect(screen.getByTestId("preview")).toHaveTextContent("2:%0"), + ); + }); +}); diff --git a/plugins/tmux-monitor/tests/frontend/poll-queue.test.ts b/plugins/tmux-monitor/tests/frontend/poll-queue.test.ts new file mode 100644 index 000000000..899ffe862 --- /dev/null +++ b/plugins/tmux-monitor/tests/frontend/poll-queue.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPollQueue } from "../../src/frontend/poll-queue"; + +describe("background host polling queue", () => { + it("bounds concurrent hosts and releases a slot after failure", async () => { + const run = createPollQueue(2); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const first = run(() => held); + const second = run(async () => { + throw new Error("offline"); + }); + const thirdTask = vi.fn(async () => "third"); + const third = run(thirdTask); + expect(thirdTask).not.toHaveBeenCalled(); + await expect(second).rejects.toThrow("offline"); + await expect(third).resolves.toBe("third"); + expect(thirdTask).toHaveBeenCalledOnce(); + release(); + await first; + }); +}); diff --git a/plugins/tmux-monitor/tests/frontend/session-tree-selection.test.tsx b/plugins/tmux-monitor/tests/frontend/session-tree-selection.test.tsx new file mode 100644 index 000000000..3fc570714 --- /dev/null +++ b/plugins/tmux-monitor/tests/frontend/session-tree-selection.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SessionTree } from "../../src/frontend/SessionTree"; +vi.mock("@termix/plugin-sdk/frontend", async (original) => ({ + ...(await original()), + useTranslation: () => ({ t: (key: string) => key }), +})); +afterEach(cleanup); +it("selects a session by its label while the chevron only changes expansion", () => { + const select = vi.fn(); + const toggle = vi.fn(); + const noop = () => {}; + render( + , + ); + fireEvent.click(screen.getByText("same")); + expect(select).toHaveBeenCalledExactlyOnceWith("same"); + expect(toggle).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("tmuxMonitor.toggleSession")); + expect(toggle).toHaveBeenCalledExactlyOnceWith("same"); + expect(select).toHaveBeenCalledOnce(); +});