feat(tmux-monitor): add a unified multi-host view (#1520)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:47:21 -05:00
1 parent cbbb80bc82
commit 4c2f54dac0
12 files changed
+1192 -115

No files matched your search

+23
View File
@@ -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
+9 -1
View File
@@ -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}}"
}
}
@@ -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<TmuxOverview | null>(null);
const [metrics, setMetrics] = useState<TmuxPaneMetrics[]>([]);
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 (
<div className="px-3 py-2 text-xs text-destructive" role="status">
{t("tmuxMonitor.failedToLoad")}
<Button
variant="ghost"
size="sm"
onClick={() => {
void load().catch(() => {});
}}
>
{t("tmuxMonitor.retry")}
</Button>
</div>
);
if (!overview) return <Skeleton className="m-2 h-8" />;
if (!overview.available)
return (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t("tmuxMonitor.tmuxUnavailable")}
</p>
);
if (!overview.sessions.length)
return (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t("tmuxMonitor.noSessions")}
</p>
);
return children(overview, metrics);
}
@@ -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({
<div key={session.name} className="mb-0.5">
<div
className="group flex cursor-pointer items-center gap-1.5 px-2 py-1 hover:bg-muted/40"
onClick={() => onToggleSession(session.name)}
onClick={() =>
onSelectSession
? onSelectSession(session.name)
: onToggleSession(session.name)
}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
<button
aria-label={t("tmuxMonitor.toggleSession", {
session: session.name,
})}
aria-expanded={expanded}
onClick={(event) => {
event.stopPropagation();
onToggleSession(session.name);
}}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</button>
<span
className={`size-2 shrink-0 rounded-full ${session.attachedClients > 0 ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
title={
+507 -106
View File
@@ -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<string> | 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<string | number | null>(
null,
);
const [scope, setScope] = useState<"all" | "single">(() =>
readMonitorValue<string>("scope", "all") === "single" ? "single" : "all",
);
const [hostFilter, setHostFilter] = useState(() => {
const value = readMonitorValue<unknown>("filter", "");
return typeof value === "string" ? value : "";
});
const [collapsedHosts, setCollapsedHosts] = useState<Set<string>>(() => {
const value = readMonitorValue<unknown>("collapsed-hosts", []);
return new Set(
Array.isArray(value)
? value.filter((id): id is string => typeof id === "string")
: [],
);
});
const [expandedByHost, setExpandedByHost] = useState<
Record<string, Set<string>>
>({});
const [pendingAction, setPendingAction] = useState<{
hostId: string | number;
action: TreeAction;
} | null>(null);
const [overviewHostId, setOverviewHostId] = useState<string | number | null>(
null,
);
const [overview, setOverview] = useState<TmuxOverview | null>(null);
const [overviewLoading, setOverviewLoading] = useState(false);
const [overviewError, setOverviewError] = useState<string | null>(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<string | number | null>(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<string | number | null>(
"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<string>) => {
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<string, SessionMetricsAgg>();
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 (
<SessionTree
sessions={data.sessions}
expandedSessions={expanded}
onToggleSession={(name) => {
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 (
<div className="flex h-full w-full bg-background text-foreground">
{/* Left rail: hosts + session tree. Resizable via the right-edge
@@ -904,6 +1179,52 @@ export function TmuxMonitor({
</a>
</span>
</div>
<div className="space-y-2 border-b border-border p-2">
<select
className="h-8 w-full border border-border bg-background px-2 text-sm"
aria-label={t("tmuxMonitor.hostScope")}
value={scope}
onChange={(e) => {
const value = e.target.value === "single" ? "single" : "all";
setScope(value);
saveMonitorValue("scope", value);
}}
>
<option value="all">{t("tmuxMonitor.allHosts")}</option>
<option value="single">{t("tmuxMonitor.singleHost")}</option>
</select>
{scope === "all" ? (
<Input
aria-label={t("tmuxMonitor.filterHosts")}
placeholder={t("tmuxMonitor.filterHosts")}
value={hostFilter}
onChange={(e) => {
setHostFilter(e.target.value);
saveMonitorValue("filter", e.target.value);
}}
className="h-8"
/>
) : (
<select
className="h-8 w-full border border-border bg-background px-2 text-sm"
aria-label={t("tmuxMonitor.selectHost")}
value={selectedHostId ?? ""}
onChange={(e) => {
setPendingAction(null);
setSelectedHostId(
hosts.find((h) => String(h.id) === e.target.value)?.id ??
null,
);
}}
>
{hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</select>
)}
</div>
{/* 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({
</p>
</div>
)}
{overviewLoading && (
<div className="space-y-3 px-2 py-2">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-5 w-full" />
<Skeleton className="ml-6 h-3.5 w-3/4" />
<Skeleton className="ml-6 h-3.5 w-2/3" />
</div>
))}
</div>
)}
{overviewError && (
<div className="space-y-2 px-2 py-4">
<p className="text-sm text-destructive">{overviewError}</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
selectedHostId !== null && loadOverview(selectedHostId)
}
{filteredHosts.map((host) => {
const active = host.id === selectedHostId;
const collapsed =
scope === "all" && collapsedHosts.has(String(host.id));
return (
<section
key={host.id}
aria-label={host.name || String(host.ip)}
>
<RefreshCw className="mr-1 size-3" />
{t("tmuxMonitor.retry")}
</Button>
</div>
)}
{overview && !overview.available && (
<div className="px-2 py-4">
<p className="text-sm text-muted-foreground">
{t("tmuxMonitor.tmuxUnavailable")}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t("tmuxMonitor.tmuxInstallHint")}{" "}
<code className="font-mono">sudo apt install tmux</code>
</p>
</div>
)}
{overview?.available && overview.sessions.length === 0 && (
<p className="px-2 py-4 text-sm text-muted-foreground">
{t("tmuxMonitor.noSessions")}
<div
className={`flex items-center gap-1 px-1 py-2 ${active ? "bg-accent-brand/10" : ""}`}
>
{scope === "all" && (
<button
aria-label={t("tmuxMonitor.toggleHost", {
host: host.name || host.ip,
})}
aria-expanded={!collapsed}
onClick={() => {
const next = new Set(collapsedHosts);
if (collapsed) next.delete(String(host.id));
else next.add(String(host.id));
setCollapsedHosts(next);
saveMonitorValue("collapsed-hosts", [...next]);
}}
>
{collapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
<button
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
aria-pressed={active}
onClick={() => {
setPendingAction(null);
setSelectedHostId(host.id);
}}
>
{host.name || host.ip}
</button>
</div>
{!collapsed &&
(active ? (
<>
{overviewLoading && (
<div className="space-y-3 px-2 py-2">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-5 w-full" />
<Skeleton className="ml-6 h-3.5 w-3/4" />
<Skeleton className="ml-6 h-3.5 w-2/3" />
</div>
))}
</div>
)}
{overviewError && (
<div className="space-y-2 px-2 py-4">
<p className="text-sm text-destructive">
{overviewError}
</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
selectedHostId !== null &&
loadOverview(selectedHostId)
}
>
<RefreshCw className="mr-1 size-3" />
{t("tmuxMonitor.retry")}
</Button>
</div>
)}
{overview && !overview.available && (
<div className="px-2 py-4">
<p className="text-sm text-muted-foreground">
{t("tmuxMonitor.tmuxUnavailable")}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t("tmuxMonitor.tmuxInstallHint")}{" "}
<code className="font-mono">
sudo apt install tmux
</code>
</p>
</div>
)}
{overview?.available &&
overview.sessions.length === 0 && (
<p className="px-2 py-4 text-sm text-muted-foreground">
{t("tmuxMonitor.noSessions")}
</p>
)}
{overview?.available &&
overviewHostId === selectedHostId && (
<SessionTree
sessions={overview.sessions}
expandedSessions={expandedSessions}
onToggleSession={toggleSession}
selectedPaneId={selectedPane?.paneId ?? null}
onSelectPane={selectPane}
metricsByPane={metricsByPane}
metricsBySession={metricsBySession}
onEditTags={openTagsEditor}
onAttachSession={openTerminal}
onSelectSession={attachInline}
onNewWindow={newWindow}
onRenameSession={(name) => {
setRenameDraft(name);
setRenameTarget(name);
}}
onKillSession={setKillTarget}
onKillPane={setKillPaneTarget}
onSplitPane={splitPane}
onKillWindow={(sessionName, windowIndex) =>
setKillWindowTarget({
sessionName,
windowIndex,
})
}
compact={treeWidth < 280}
now={now}
/>
)}
</>
) : (
isVisible &&
selectedHostId !== null && (
<HostOverview hostId={Number(host.id)}>
{(data, values) =>
backgroundTree(host.id, data, values)
}
</HostOverview>
)
))}
</section>
);
})}
{hosts.length > 0 && filteredHosts.length === 0 && (
<p className="p-2 text-xs text-muted-foreground">
{t("tmuxMonitor.noMatchingHosts")}
</p>
)}
{overview?.available && (
<SessionTree
sessions={overview.sessions}
expandedSessions={expandedSessions}
onToggleSession={toggleSession}
selectedPaneId={selectedPane?.paneId ?? null}
onSelectPane={selectPane}
metricsByPane={metricsByPane}
metricsBySession={metricsBySession}
onEditTags={openTagsEditor}
onAttachSession={openTerminal}
onNewWindow={newWindow}
onRenameSession={(name) => {
setRenameDraft(name);
setRenameTarget(name);
}}
onKillSession={setKillTarget}
onKillPane={setKillPaneTarget}
onSplitPane={splitPane}
onKillWindow={(sessionName, windowIndex) =>
setKillWindowTarget({ sessionName, windowIndex })
}
compact={treeWidth < 280}
now={now}
/>
)}
</div>
</ScrollArea>
@@ -1077,7 +1475,7 @@ export function TmuxMonitor({
{/* Pane preview */}
<div className="flex min-h-0 flex-1 flex-col">
{selectedPane && selectedHost ? (
{selectedPane && selectedHost && overviewHostId === selectedHostId ? (
<PanePreview
key={`${selectedHost.id}:${selectedPane.sessionName}`}
host={selectedHost}
@@ -1086,7 +1484,10 @@ export function TmuxMonitor({
terminalRef={previewTermRef}
onSplit={(direction) => splitPane(selectedPane.paneId, direction)}
onKillPane={() => setKillPaneTarget(selectedPane.paneId)}
onClose={() => setSelectedPane(null)}
onClose={() => {
setSelectedPane(null);
saveMonitorValue(`pane-${selectedHostId}`, null);
}}
/>
) : hostsLoaded && hosts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
+10 -2
View File
@@ -14,7 +14,13 @@ const VIEW_ID = "tmux_monitor";
function TmuxMonitorTab({ tab, isVisible }: TabProps) {
const hostId = (tab.data?.hostId as number | undefined) ?? undefined;
return <TmuxMonitor initialHostId={hostId} isVisible={isVisible} />;
return (
<TmuxMonitor
initialHostId={hostId}
initialHostRequest={tab.data?.hostRequest as number | undefined}
isVisible={isVisible}
/>
);
}
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({
@@ -0,0 +1,36 @@
import type { SelectedPane } from "./types";
const PREFIX = "termix-tmux-monitor-";
export function readMonitorValue<T>(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<string> | null {
const value = readMonitorValue<unknown>(`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<Partial<SelectedPane> | null>(
`pane-${hostId}`,
null,
);
return value &&
typeof value.paneId === "string" &&
typeof value.sessionName === "string" &&
Number.isInteger(value.windowIndex)
? (value as SelectedPane)
: null;
}
@@ -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<T>(task: () => Promise<T>): Promise<T> {
if (running >= limit)
await new Promise<void>((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);
@@ -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"]));
});
});
@@ -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<object>()),
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<unknown>,
_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<typeof PanePreview>) => (
<div data-testid="preview">
{host.id}:{pane.paneId}:{metrics?.cpuPercent ?? "none"}
</div>
),
}));
vi.mock("../../src/frontend/SessionTree", () => ({
SessionTree: (props: ComponentProps<typeof SessionTree>) => (
<div>
<span>cpu:{props.metricsByPane.get("%0")?.cpuPercent ?? "none"}</span>
<span>
{props.expandedSessions.has("same") ? "expanded" : "collapsed"}
</span>
<button
onClick={() =>
props.onSelectPane({
paneId: "%0",
sessionName: "same",
windowIndex: 0,
})
}
>
pane
</button>
<button onClick={() => props.onSelectSession?.("same")}>session</button>
<button onClick={() => props.onNewWindow("same")}>new window</button>
<button onClick={() => props.onKillSession("same")}>kill session</button>
<button onClick={() => props.onToggleSession("same")}>
toggle session
</button>
</div>
),
}));
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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(<TmuxMonitor initialHostId={1} />);
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(<TmuxMonitor initialHostId={1} />);
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(<TmuxMonitor />);
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<api.TmuxPaneMetrics[]>();
vi.mocked(api.getTmuxMetrics).mockImplementation((id) =>
id === 1 ? old.promise : Promise.resolve(metrics(id)),
);
render(<TmuxMonitor initialHostId={1} />);
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<api.TmuxOverview>();
render(<TmuxMonitor initialHostId={1} />);
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(<TmuxMonitor initialHostId={1} />);
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(<TmuxMonitor initialHostId={1} isVisible={false} />);
expect(host(2).queryByRole("status")).not.toBeInTheDocument();
});
it("confirms a destructive action on the host selected from the background tree", async () => {
render(<TmuxMonitor initialHostId={1} />);
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(<TmuxMonitor initialHostId={1} />);
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<api.TmuxOverview>();
let first = true;
vi.mocked(api.getTmuxOverview).mockImplementation((id) => {
if (id === 1 && first) {
first = false;
return old.promise;
}
return Promise.resolve(overview());
});
render(<TmuxMonitor initialHostId={1} />);
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(
<TmuxMonitor initialHostId={1} initialHostRequest={1} />,
);
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(<TmuxMonitor initialHostId={1} initialHostRequest={2} />);
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(<TmuxMonitor initialHostId={1} />);
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"),
);
});
});
@@ -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<void>((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;
});
});
@@ -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<object>()),
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(
<SessionTree
sessions={[
{
name: "same",
created: 1,
lastActivity: 1,
attachedClients: 0,
windows: [],
tags: [],
},
]}
expandedSessions={new Set()}
onToggleSession={toggle}
selectedPaneId={null}
onSelectPane={noop}
metricsByPane={new Map()}
metricsBySession={new Map()}
onSelectSession={select}
onEditTags={noop}
onAttachSession={noop}
onNewWindow={noop}
onRenameSession={noop}
onKillSession={noop}
onKillPane={noop}
onSplitPane={noop}
onKillWindow={noop}
compact={false}
now={1}
/>,
);
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();
});