Files
Termix/plugins/tmux-monitor/src/frontend/TmuxMonitor.tsx
T

1287 lines
45 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useHosts,
useToast,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import type { PluginHostRecord, TabHandle } from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Skeleton,
isElectron,
} from "@termix/plugin-sdk/ui";
import {
ChevronsDownUp,
ChevronsUpDown,
ExternalLink,
Layers,
MonitorPlay,
Plus,
RefreshCw,
Search,
Server,
SquareTerminal,
} from "lucide-react";
import {
getTmuxOverview,
getTmuxMetrics,
searchTmux,
setTmuxSessionTags,
focusTmuxPane,
createTmuxSession,
createTmuxWindow,
renameTmuxSession,
killTmuxSession,
killTmuxWindow,
killTmuxPane,
splitTmuxPane,
type TmuxOverview,
type TmuxPaneMetrics,
type TmuxSearchMatch,
type TmuxSearchResult,
} from "./api";
import { tmuxMonitorEnabled } from "./host-tmux-monitor";
import { useAdaptivePolling } from "./use-adaptive-polling";
import { SessionTree, type SessionMetricsAgg } from "./SessionTree";
import { SearchResults } from "./SearchResults";
import { PanePreview } from "./PanePreview";
import type { SelectedPane } from "./types";
const OVERVIEW_POLL_MS = 10_000;
const METRICS_POLL_MS = 10_000;
const TIME_TICK_MS = 30_000;
const LS_PREFIX = "termix-tmux-monitor-";
const LS_LAST_HOST_KEY = `${LS_PREFIX}last-host`;
const LS_TREE_WIDTH_KEY = `${LS_PREFIX}tree-width`;
const TREE_WIDTH_DEFAULT = 288; // matches the old fixed w-72
const TREE_WIDTH_MIN = 200;
const TREE_WIDTH_MAX = 520;
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,
isVisible = true,
}: {
initialHostId?: number;
isVisible?: boolean;
}) {
const { t } = useTranslation();
const toast = useToast();
const { hosts: allHosts, loaded: hostsLoaded } = useHosts();
const hosts = useMemo(
() =>
allHosts.filter(
(h) =>
((h.connectionType as string | undefined) ?? "ssh") === "ssh" &&
tmuxMonitorEnabled(h),
),
[allHosts],
);
const [selectedHostId, setSelectedHostId] = useState<string | number | null>(
null,
);
const [overview, setOverview] = useState<TmuxOverview | null>(null);
const [overviewLoading, setOverviewLoading] = useState(false);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(
new Set(),
);
const [selectedPane, setSelectedPane] = useState<SelectedPane | null>(null);
const [metrics, setMetrics] = useState<TmuxPaneMetrics[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchedQuery, setSearchedQuery] = useState("");
const [searchResults, setSearchResults] = useState<TmuxSearchMatch[] | null>(
null,
);
const [searchLimits, setSearchLimits] = useState<Pick<
TmuxSearchResult,
"truncated" | "searchedLines" | "maxPanes"
> | null>(null);
const [searching, setSearching] = useState(false);
// Bumped every 30s so relative "Xm ago" labels do not go stale.
const [now, setNow] = useState(() => Date.now());
const searchInputRef = useRef<HTMLInputElement>(null);
// Host whose data is currently on screen; responses for any other host are
// 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 overviewSignatureRef = useRef("");
const metricsSignatureRef = useRef("");
// True when the expanded-session set for the current host was restored from
// localStorage (or touched by the user) and must not be overwritten by the
// default expand-all behavior.
const expandedRestoredRef = useRef(false);
// Imperative handle of the preview's embedded terminal. After tmux actions
// that change the layout (split / kill-pane / new-window) the attached
// client can render stale borders; a refit forces a PTY resize which makes
// tmux fully redraw the client.
const previewTermRef = useRef<TabHandle | null>(null);
const nudgePreviewRedraw = useCallback(() => {
setTimeout(() => {
previewTermRef.current?.fit?.();
previewTermRef.current?.notifyResize?.();
previewTermRef.current?.refresh?.();
}, 300);
}, []);
// -- resizable tree panel (same pattern as the AppShell host sidebar) -------
const [treeWidth, setTreeWidth] = useState(() => {
const saved = Number(localStorage.getItem(LS_TREE_WIDTH_KEY));
return Number.isFinite(saved) && saved >= TREE_WIDTH_MIN
? Math.min(saved, TREE_WIDTH_MAX)
: TREE_WIDTH_DEFAULT;
});
const [treeDragging, setTreeDragging] = useState(false);
useEffect(() => {
try {
localStorage.setItem(LS_TREE_WIDTH_KEY, String(treeWidth));
} catch {
// localStorage may be unavailable
}
}, [treeWidth]);
const onTreeResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setTreeDragging(true);
const startX = e.clientX;
const startW = treeWidth;
function onMove(ev: MouseEvent) {
setTreeWidth(
Math.max(
TREE_WIDTH_MIN,
Math.min(TREE_WIDTH_MAX, startW + ev.clientX - startX),
),
);
}
function onUp() {
setTreeDragging(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[treeWidth],
);
// -- host selection ---------------------------------------------------------
const initialHostPicked = useRef(false);
useEffect(() => {
if (!hostsLoaded || initialHostPicked.current) return;
setSelectedHostId((prev) => {
if (prev !== null && hosts.some((h) => h.id === prev)) return prev;
if (hosts.length === 0) return null;
if (
initialHostId != null &&
hosts.some((h) => String(h.id) === String(initialHostId))
) {
initialHostPicked.current = true;
return initialHostId;
}
const stored = localStorage.getItem(LS_LAST_HOST_KEY);
if (stored !== null && hosts.some((h) => String(h.id) === stored)) {
initialHostPicked.current = true;
return stored;
}
initialHostPicked.current = true;
return hosts[0].id;
});
}, [hostsLoaded, hosts, initialHostId]);
// If the selected host disappears (deleted, or the monitor was turned off
// 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;
setSelectedHostId(hosts.length > 0 ? hosts[0].id : null);
}, [hostsLoaded, hosts, selectedHostId]);
// Reopening the singleton tab from a host action updates initialHostId;
// 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);
// 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]);
// -- relative time refresh --------------------------------------------------
useEffect(() => {
if (!isVisible) return;
const interval = setInterval(() => setNow(Date.now()), TIME_TICK_MS);
return () => clearInterval(interval);
}, [isVisible]);
// -- overview polling -----------------------------------------------------
const loadOverview = useCallback(
async (hostId: string | number, silent = false) => {
if (!silent) {
setOverviewLoading(true);
setOverviewError(null);
}
try {
const data = await getTmuxOverview(Number(hostId));
if (activeHostRef.current !== hostId) return true;
const signature = JSON.stringify(data);
const changed = signature !== overviewSignatureRef.current;
overviewSignatureRef.current = signature;
setOverview(data);
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 (!silent) {
const axiosErr = err as {
code?: string;
response?: { data?: { error?: string; code?: string } };
};
const data = axiosErr.response?.data;
// The client request can time out before the backend finishes its
// own SSH connect timeout, so treat that like HOST_UNREACHABLE too.
const unreachable =
data?.code === "HOST_UNREACHABLE" ||
axiosErr.code === "ECONNABORTED";
if (data?.code === "TMUX_NOT_INSTALLED") {
// Same friendly empty-state as the overview's available:false.
setOverview({ available: false, sessions: [] });
setOverviewError(null);
} else {
setOverview(null);
setOverviewError(
unreachable
? t("tmuxMonitor.hostUnreachable")
: data?.code === "TMUX_NO_SERVER"
? t("tmuxMonitor.noServer")
: data?.error ||
(err instanceof Error
? err.message
: t("tmuxMonitor.failedToLoad")),
);
}
}
if (silent) throw err;
return false;
} finally {
if (!silent && activeHostRef.current === hostId)
setOverviewLoading(false);
}
},
[t],
);
// Manual refresh keeps the current tree on screen (no skeleton flash) and
// only spins the refresh icons; the skeleton is reserved for the first load
// of a host. Failures surface as a toast instead of silently keeping stale
// data.
const [refreshing, setRefreshing] = useState(false);
const manualRefresh = useCallback(async () => {
if (selectedHostId === null || refreshing) return;
if (overview === null) {
loadOverview(selectedHostId);
return;
}
setRefreshing(true);
try {
const ok = await loadOverview(selectedHostId, true);
if (!ok) toast.error(t("tmuxMonitor.refreshFailed"));
} finally {
setRefreshing(false);
}
}, [selectedHostId, refreshing, overview, loadOverview, t, toast]);
useEffect(() => {
activeHostRef.current = selectedHostId;
setOverview(null);
setSelectedPane(null);
setSearchResults(null);
setMetrics([]);
if (selectedHostId === null) return;
try {
localStorage.setItem(LS_LAST_HOST_KEY, String(selectedHostId));
} catch {
// localStorage may be unavailable
}
const storedExpanded = readStoredExpanded(selectedHostId);
expandedRestoredRef.current = storedExpanded !== null;
setExpandedSessions(storedExpanded ?? new Set());
loadOverview(selectedHostId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedHostId]);
// Poll only while this app tab is shown: the component stays mounted when
// the user switches tabs (AppShell keeps tab content alive) and would
// otherwise keep running SSH execs against the host forever.
useAdaptivePolling(
() =>
selectedHostId === null ? undefined : loadOverview(selectedHostId, true),
{
minIntervalMs: OVERVIEW_POLL_MS,
maxIntervalMs: 60_000,
stablePollsPerStep: 3,
},
selectedHostId !== null && isVisible,
{ runImmediately: false },
);
// -- metrics polling ------------------------------------------------------
useAdaptivePolling(
async () => {
if (selectedHostId === null) return;
const next = await getTmuxMetrics(Number(selectedHostId));
const signature = JSON.stringify(next);
const changed = signature !== metricsSignatureRef.current;
metricsSignatureRef.current = signature;
setMetrics(next);
return changed;
},
{
minIntervalMs: METRICS_POLL_MS,
maxIntervalMs: 60_000,
stablePollsPerStep: 3,
},
selectedHostId !== null && !!overview?.available && isVisible,
);
// -- keyboard shortcuts -----------------------------------------------------
// Gated on isVisible: the listener is global, and a hidden-but-mounted tab
// must not swallow "/" or Escape typed in other tabs.
useEffect(() => {
if (!isVisible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "/") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable)
return;
e.preventDefault();
searchInputRef.current?.focus();
} else if (e.key === "Escape") {
if (searchResults !== null) setSearchResults(null);
else if (selectedPane) setSelectedPane(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [searchResults, selectedPane, isVisible]);
// -- search ---------------------------------------------------------------
async function runSearch() {
if (selectedHostId === null || !searchQuery.trim()) return;
const query = searchQuery.trim();
setSearching(true);
try {
const result = await searchTmux(Number(selectedHostId), query);
setSearchResults(result.matches);
setSearchLimits(result);
setSearchedQuery(query);
} catch {
toast.error(t("tmuxMonitor.searchFailed"));
} finally {
setSearching(false);
}
}
const persistExpanded = useCallback(
(hostId: string | number, set: Set<string>) => {
try {
localStorage.setItem(
expandedStorageKey(hostId),
JSON.stringify([...set]),
);
} catch {
// localStorage may be unavailable
}
},
[],
);
function toggleSession(name: string) {
const next = new Set(expandedSessions);
if (next.has(name)) next.delete(name);
else next.add(name);
setExpandedSessions(next);
expandedRestoredRef.current = true;
if (selectedHostId !== null) persistExpanded(selectedHostId, next);
}
// Selecting a pane also focuses it on the server so the attached PTY (and
// any other tmux client) switches to that window/pane.
const selectPane = useCallback(
(pane: SelectedPane) => {
setSelectedPane(pane);
if (selectedHostId !== null)
focusTmuxPane(Number(selectedHostId), pane.paneId).catch(() => {});
},
[selectedHostId],
);
function handleSearchSelect(match: TmuxSearchMatch) {
selectPane({
paneId: match.paneId,
sessionName: match.sessionName,
windowIndex: match.windowIndex,
});
if (!expandedSessions.has(match.sessionName)) {
const next = new Set(expandedSessions).add(match.sessionName);
setExpandedSessions(next);
expandedRestoredRef.current = true;
if (selectedHostId !== null) persistExpanded(selectedHostId, next);
}
}
// -- create session ---------------------------------------------------------
const [newSessionName, setNewSessionName] = useState("");
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
// Mirrors the backend's SESSION_NAME_RE (tmux forbids ":" and ".").
const newSessionNameValid = /^[A-Za-z0-9_@%+=-]{1,64}$/.test(
newSessionName.trim(),
);
async function createSession() {
if (selectedHostId === null || !newSessionNameValid || creatingSession)
return;
const name = newSessionName.trim();
setCreatingSession(true);
try {
await createTmuxSession(Number(selectedHostId), name);
toast.success(t("tmuxMonitor.sessionCreated", { name }));
setNewSessionOpen(false);
setNewSessionName("");
const next = new Set(expandedSessions).add(name);
setExpandedSessions(next);
expandedRestoredRef.current = true;
persistExpanded(selectedHostId, next);
loadOverview(selectedHostId, true);
} catch (err) {
const axiosErr = err as { response?: { data?: { error?: string } } };
toast.error(
axiosErr.response?.data?.error || t("tmuxMonitor.sessionCreateFailed"),
);
} finally {
setCreatingSession(false);
}
}
// -- split ------------------------------------------------------------------
// Splits the window containing a pane. If the pane is in the previewed
// session the new pane appears in the attached terminal immediately; the
// silent overview reload updates the tree.
const splitPane = useCallback(
async (paneId: string, direction: "h" | "v") => {
if (selectedHostId === null) return;
try {
await splitTmuxPane(Number(selectedHostId), paneId, direction);
loadOverview(selectedHostId, true);
nudgePreviewRedraw();
} catch {
toast.error(t("tmuxMonitor.splitFailed"));
}
},
[selectedHostId, loadOverview, nudgePreviewRedraw, t, toast],
);
// -- new window ---------------------------------------------------------------
const newWindow = useCallback(
async (sessionName: string) => {
if (selectedHostId === null) return;
try {
await createTmuxWindow(Number(selectedHostId), sessionName);
loadOverview(selectedHostId, true);
nudgePreviewRedraw();
} catch {
toast.error(t("tmuxMonitor.windowCreateFailed"));
}
},
[selectedHostId, loadOverview, nudgePreviewRedraw, t, toast],
);
// -- collapse / expand all ----------------------------------------------------
const anyExpanded = expandedSessions.size > 0;
function toggleAllSessions() {
if (selectedHostId === null || !overview) return;
const next = anyExpanded
? new Set<string>()
: new Set(overview.sessions.map((s) => s.name));
setExpandedSessions(next);
expandedRestoredRef.current = true;
persistExpanded(selectedHostId, next);
}
// -- rename / kill ------------------------------------------------------------
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [renaming, setRenaming] = useState(false);
const renameDraftValid = /^[A-Za-z0-9_@%+=-]{1,64}$/.test(renameDraft.trim());
const [killTarget, setKillTarget] = useState<string | null>(null);
const [killing, setKilling] = useState(false);
async function confirmRename() {
if (
selectedHostId === null ||
renameTarget === null ||
!renameDraftValid ||
renaming
)
return;
const newName = renameDraft.trim();
setRenaming(true);
try {
await renameTmuxSession(Number(selectedHostId), renameTarget, newName);
toast.success(t("tmuxMonitor.sessionRenamed", { name: newName }));
if (expandedSessions.has(renameTarget)) {
const next = new Set(expandedSessions);
next.delete(renameTarget);
next.add(newName);
setExpandedSessions(next);
persistExpanded(selectedHostId, next);
}
// Remounts the preview (keyed by session name) so it re-attaches under
// the new name.
if (selectedPane?.sessionName === renameTarget) {
setSelectedPane({ ...selectedPane, sessionName: newName });
}
setRenameTarget(null);
loadOverview(selectedHostId, true);
} catch (err) {
const axiosErr = err as { response?: { data?: { error?: string } } };
toast.error(
axiosErr.response?.data?.error || t("tmuxMonitor.sessionRenameFailed"),
);
} finally {
setRenaming(false);
}
}
async function confirmKill() {
if (selectedHostId === null || killTarget === null || killing) return;
setKilling(true);
try {
await killTmuxSession(Number(selectedHostId), killTarget);
toast.success(t("tmuxMonitor.sessionKilled", { name: killTarget }));
if (selectedPane?.sessionName === killTarget) setSelectedPane(null);
if (expandedSessions.has(killTarget)) {
const next = new Set(expandedSessions);
next.delete(killTarget);
setExpandedSessions(next);
persistExpanded(selectedHostId, next);
}
setKillTarget(null);
loadOverview(selectedHostId, true);
} catch (err) {
const axiosErr = err as { response?: { data?: { error?: string } } };
toast.error(
axiosErr.response?.data?.error || t("tmuxMonitor.sessionKillFailed"),
);
} finally {
setKilling(false);
}
}
// After killing the selected pane/window, keep the preview on the same
// session by selecting its now-active pane (tmux focuses a sibling
// automatically); only fall back to the empty state when the whole session
// died with it.
const selectSurvivor = useCallback(
(data: TmuxOverview, sessionName: string) => {
const session = data.sessions.find((s) => s.name === sessionName);
const win = session?.windows.find((w) => w.active) ?? session?.windows[0];
const pane = win?.panes.find((p) => p.active) ?? win?.panes[0];
if (session && win && pane) {
setSelectedPane({
paneId: pane.id,
sessionName: session.name,
windowIndex: win.index,
});
} else {
setSelectedPane(null);
}
},
[],
);
// -- kill window ------------------------------------------------------------
const [killWindowTarget, setKillWindowTarget] = useState<{
sessionName: string;
windowIndex: number;
} | null>(null);
const [killingWindow, setKillingWindow] = useState(false);
async function confirmKillWindow() {
if (selectedHostId === null || killWindowTarget === null || killingWindow)
return;
setKillingWindow(true);
try {
await killTmuxWindow(
Number(selectedHostId),
killWindowTarget.sessionName,
killWindowTarget.windowIndex,
);
const wasViewing =
selectedPane?.sessionName === killWindowTarget.sessionName &&
selectedPane?.windowIndex === killWindowTarget.windowIndex;
setKillWindowTarget(null);
const data = await getTmuxOverview(Number(selectedHostId));
setOverview(data);
if (wasViewing) selectSurvivor(data, killWindowTarget.sessionName);
nudgePreviewRedraw();
} catch (err) {
const axiosErr = err as { response?: { data?: { error?: string } } };
toast.error(
axiosErr.response?.data?.error || t("tmuxMonitor.windowKillFailed"),
);
} finally {
setKillingWindow(false);
}
}
// -- kill pane ------------------------------------------------------------
const [killPaneTarget, setKillPaneTarget] = useState<string | null>(null);
const [killingPane, setKillingPane] = useState(false);
async function confirmKillPane() {
if (selectedHostId === null || killPaneTarget === null || killingPane)
return;
setKillingPane(true);
try {
await killTmuxPane(Number(selectedHostId), killPaneTarget);
const viewedSession =
selectedPane?.paneId === killPaneTarget
? selectedPane.sessionName
: null;
setKillPaneTarget(null);
const data = await getTmuxOverview(Number(selectedHostId));
setOverview(data);
if (viewedSession) selectSurvivor(data, viewedSession);
nudgePreviewRedraw();
} catch (err) {
const axiosErr = err as { response?: { data?: { error?: string } } };
toast.error(
axiosErr.response?.data?.error || t("tmuxMonitor.paneKillFailed"),
);
} finally {
setKillingPane(false);
}
}
// -- tags -----------------------------------------------------------------
const [tagsTarget, setTagsTarget] = useState<string | null>(null);
const [tagsDraft, setTagsDraft] = useState("");
const [savingTags, setSavingTags] = useState(false);
function openTagsEditor(sessionName: string) {
const session = overview?.sessions.find((s) => s.name === sessionName);
setTagsDraft(session?.tags.join(", ") ?? "");
setTagsTarget(sessionName);
}
async function confirmTags() {
if (selectedHostId === null || tagsTarget === null || savingTags) return;
const tags = tagsDraft
.split(",")
.map((s) => s.trim())
.filter(Boolean);
setSavingTags(true);
try {
await setTmuxSessionTags(Number(selectedHostId), tagsTarget, tags);
toast.success(t("tmuxMonitor.tagsSaved"));
setTagsTarget(null);
loadOverview(selectedHostId, true);
} catch {
toast.error(t("tmuxMonitor.tagsSaveFailed"));
} finally {
setSavingTags(false);
}
}
// Attach inside the monitor: expand the session and select its active pane
// so the embedded terminal (a real tmux attach) shows it.
const attachInline = useCallback(
(sessionName: string) => {
const session = overview?.sessions.find((s) => s.name === sessionName);
if (!session) return;
const activeWindow =
session.windows.find((w) => w.active) ?? session.windows[0];
const activePane =
activeWindow?.panes.find((p) => p.active) ?? activeWindow?.panes[0];
if (!activePane || !activeWindow) return;
if (!expandedSessions.has(sessionName)) {
const next = new Set(expandedSessions).add(sessionName);
setExpandedSessions(next);
expandedRestoredRef.current = true;
if (selectedHostId !== null) persistExpanded(selectedHostId, next);
}
selectPane({
paneId: activePane.id,
sessionName,
windowIndex: activeWindow.index,
});
},
[overview, expandedSessions, selectedHostId, persistExpanded, selectPane],
);
function openTerminal(sessionName?: string) {
if (selectedHostId === null) return;
const session = sessionName ?? selectedPane?.sessionName;
// window.open is denied by Electron's window-open handler (internal
// file:// URLs never reach the browser), so the desktop app attaches in
// the monitor's own terminal instead.
if (isElectron()) {
if (session) attachInline(session);
return;
}
const params = new URLSearchParams({
view: "terminal",
hostId: String(selectedHostId),
});
if (session) params.set("tmuxSession", session);
window.open(`${window.location.pathname}?${params.toString()}`, "_blank");
}
const metricsByPane = useMemo(() => {
const map = new Map<string, TmuxPaneMetrics>();
for (const m of metrics) map.set(m.paneId, m);
return map;
}, [metrics]);
const metricsBySession = useMemo(() => {
const map = new Map<string, SessionMetricsAgg>();
for (const m of metrics) {
const agg = map.get(m.sessionName) || { cpu: 0, memKb: 0, gpuMb: 0 };
agg.cpu += m.cpuPercent;
agg.memKb += m.memRssKb;
agg.gpuMb += m.gpuMemMb;
map.set(m.sessionName, agg);
}
return map;
}, [metrics]);
const selectedHost: PluginHostRecord | undefined = hosts.find(
(h) => h.id === selectedHostId,
);
const hostLabel = selectedHost
? selectedHost.name ||
`${selectedHost.username as string}@${selectedHost.ip}`
: "";
const selectedPaneMetrics = selectedPane
? metricsByPane.get(selectedPane.paneId)
: undefined;
return (
<div className="flex h-full w-full bg-background text-foreground">
{/* Left rail: hosts + session tree. Resizable via the right-edge
handle; double-click resets to the default width. */}
<div
className="relative flex shrink-0 flex-col border-r border-border bg-card"
style={{ width: treeWidth }}
>
{/* VSCode tmux-manager style header: title + new-session / refresh */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Layers className="size-4" />
<span className="text-sm font-semibold">
{t("tmuxMonitor.title")}
</span>
<span className="ml-auto flex items-center gap-2">
<Popover
open={newSessionOpen}
onOpenChange={(open) => {
setNewSessionOpen(open);
if (open) setNewSessionName("");
}}
>
<PopoverTrigger asChild>
<button
className="text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={selectedHostId === null || !overview?.available}
title={t("tmuxMonitor.newSession")}
aria-label={t("tmuxMonitor.newSession")}
>
<Plus className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-64 rounded-none border-0 p-2 ring-1 ring-border"
align="start"
>
<p className="mb-1 text-xs text-muted-foreground">
{t("tmuxMonitor.newSessionHint")}
</p>
<div className="flex gap-1">
<Input
className="h-7 text-xs"
value={newSessionName}
placeholder={t("tmuxMonitor.newSessionPlaceholder")}
autoFocus
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createSession();
}}
/>
<Button
size="sm"
className="h-7 px-2 text-xs"
disabled={!newSessionNameValid || creatingSession}
onClick={createSession}
>
{t("tmuxMonitor.create")}
</Button>
</div>
</PopoverContent>
</Popover>
<button
className="text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!overview?.available}
title={
anyExpanded
? t("tmuxMonitor.collapseAll")
: t("tmuxMonitor.expandAll")
}
aria-label={
anyExpanded
? t("tmuxMonitor.collapseAll")
: t("tmuxMonitor.expandAll")
}
onClick={toggleAllSessions}
>
{anyExpanded ? (
<ChevronsDownUp className="size-3.5" />
) : (
<ChevronsUpDown className="size-3.5" />
)}
</button>
<button
className="text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={selectedHostId === null || overviewLoading}
title={t("tmuxMonitor.refresh")}
aria-label={t("tmuxMonitor.refresh")}
onClick={manualRefresh}
>
<RefreshCw
className={`size-3.5 ${overviewLoading || refreshing ? "animate-spin" : ""}`}
/>
</button>
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground"
title={t("hosts.docsLink")}
aria-label={t("hosts.docsLink")}
>
<ExternalLink className="size-3.5" />
</a>
</span>
</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
truncate instead. */}
<ScrollArea className="flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block">
<div className="p-2">
{hostsLoaded && hosts.length === 0 && (
<div className="px-2 py-4">
<p className="text-sm text-muted-foreground">
{t("tmuxMonitor.noHosts")}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t("tmuxMonitor.noHostsHint")}
</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)
}
>
<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 && (
<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>
<div
onMouseDown={onTreeResizeMouseDown}
onDoubleClick={() => setTreeWidth(TREE_WIDTH_DEFAULT)}
title={t("tmuxMonitor.resizeTree")}
className={`absolute bottom-0 right-0 top-0 z-30 w-1 cursor-col-resize transition-colors ${treeDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
/>
</div>
{/* Main area */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Toolbar */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Server className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">
{selectedHost ? hostLabel : t("tmuxMonitor.noHostSelected")}
</span>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
className="h-8 w-64 pl-7 text-sm"
placeholder={t("tmuxMonitor.searchPlaceholder")}
value={searchQuery}
disabled={!overview?.available}
title={
overview && !overview.available
? t("tmuxMonitor.tmuxUnavailable")
: undefined
}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") runSearch();
}}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={selectedHostId === null || overviewLoading}
title={t("tmuxMonitor.refresh")}
aria-label={t("tmuxMonitor.refresh")}
onClick={manualRefresh}
>
<RefreshCw
className={`size-3.5 ${overviewLoading || refreshing ? "animate-spin" : ""}`}
/>
</Button>
<Button
size="sm"
className="h-8"
disabled={selectedHostId === null}
title={
selectedHost
? selectedPane
? t("tmuxMonitor.attachTooltipPane", {
host: hostLabel,
session: selectedPane.sessionName,
})
: t("tmuxMonitor.attachTooltip", { host: hostLabel })
: undefined
}
onClick={() => openTerminal()}
>
<SquareTerminal className="mr-1 size-3.5" />
{t("tmuxMonitor.attach")}
</Button>
</div>
</div>
{/* Search results */}
{searchResults !== null && (
<SearchResults
results={searchResults}
searching={searching}
query={searchedQuery}
limits={searchLimits}
onSelect={handleSearchSelect}
onClose={() => setSearchResults(null)}
/>
)}
{/* Pane preview */}
<div className="flex min-h-0 flex-1 flex-col">
{selectedPane && selectedHost ? (
<PanePreview
key={`${selectedHost.id}:${selectedPane.sessionName}`}
host={selectedHost}
pane={selectedPane}
metrics={selectedPaneMetrics}
terminalRef={previewTermRef}
onSplit={(direction) => splitPane(selectedPane.paneId, direction)}
onKillPane={() => setKillPaneTarget(selectedPane.paneId)}
onClose={() => setSelectedPane(null)}
/>
) : hostsLoaded && hosts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="max-w-sm text-center text-muted-foreground">
<Server className="mx-auto mb-2 size-8 opacity-50" />
<p className="text-sm">{t("tmuxMonitor.noHosts")}</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t("tmuxMonitor.noHostsHint")}
</p>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-muted-foreground">
<MonitorPlay className="mx-auto mb-2 size-8 opacity-50" />
<p className="text-sm">{t("tmuxMonitor.selectPaneHint")}</p>
</div>
</div>
)}
</div>
</div>
{/* Rename session dialog */}
<Dialog
open={renameTarget !== null}
onOpenChange={(open) => {
if (!open) setRenameTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("tmuxMonitor.renameSessionTitle", { name: renameTarget })}
</DialogTitle>
</DialogHeader>
<Input
value={renameDraft}
placeholder={t("tmuxMonitor.newSessionPlaceholder")}
autoFocus
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") confirmRename();
}}
/>
<p className="text-xs text-muted-foreground">
{t("tmuxMonitor.newSessionHint")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameTarget(null)}>
{t("common.cancel")}
</Button>
<Button
disabled={!renameDraftValid || renaming}
onClick={confirmRename}
>
{t("tmuxMonitor.rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit tags dialog */}
<Dialog
open={tagsTarget !== null}
onOpenChange={(open) => {
if (!open) setTagsTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("tmuxMonitor.editTagsTitle", { name: tagsTarget })}
</DialogTitle>
</DialogHeader>
<Input
value={tagsDraft}
placeholder="YOLO, lab, training"
autoFocus
onChange={(e) => setTagsDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") confirmTags();
}}
/>
<p className="text-xs text-muted-foreground">
{t("tmuxMonitor.tagsHint")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setTagsTarget(null)}>
{t("common.cancel")}
</Button>
<Button disabled={savingTags} onClick={confirmTags}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Kill window confirmation */}
<Dialog
open={killWindowTarget !== null}
onOpenChange={(open) => {
if (!open) setKillWindowTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("tmuxMonitor.killWindowTitle", {
index: killWindowTarget?.windowIndex,
session: killWindowTarget?.sessionName,
})}
</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("tmuxMonitor.killWindowBody")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setKillWindowTarget(null)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
disabled={killingWindow}
onClick={confirmKillWindow}
>
{t("tmuxMonitor.kill")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Kill pane confirmation */}
<Dialog
open={killPaneTarget !== null}
onOpenChange={(open) => {
if (!open) setKillPaneTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("tmuxMonitor.killPaneTitle", { id: killPaneTarget })}
</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("tmuxMonitor.killPaneBody")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setKillPaneTarget(null)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
disabled={killingPane}
onClick={confirmKillPane}
>
{t("tmuxMonitor.kill")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Kill session confirmation */}
<Dialog
open={killTarget !== null}
onOpenChange={(open) => {
if (!open) setKillTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("tmuxMonitor.killSessionTitle", { name: killTarget })}
</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("tmuxMonitor.killSessionBody")}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setKillTarget(null)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
disabled={killing}
onClick={confirmKill}
>
{t("tmuxMonitor.kill")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default TmuxMonitor;