mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(tmux-monitor): add a unified multi-host view (#1520)
This commit is contained in:
1 parent
cbbb80bc82
commit
4c2f54dac0
12 files changed
+1192
-115
No files matched your search
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user