import { useState, useEffect, useRef, useCallback, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; import { isWatchProcessRunning, type SpawnSpec } from "../../ipc"; import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; export default function LeafPane({ leaf }: { leaf: LeafNode }) { const orch = useOrchestration(); const isActive = orch.activeLeafId === leaf.id; const isBroadcasting = !!leaf.broadcast; // ---- status (from XtermPane) ------------------------------------------- const [status, setStatus] = useState("starting…"); const [statusOk, setStatusOk] = useState(true); // ---- label editing ----------------------------------------------------- const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const labelInputRef = useRef(null); const startEditLabel = useCallback( (e: MouseEvent) => { e.stopPropagation(); setLabelDraft(leaf.label ?? ""); setEditingLabel(true); // Focus on next tick so input is mounted queueMicrotask(() => labelInputRef.current?.select()); }, [leaf.label], ); const commitLabel = useCallback(() => { if (!editingLabel) return; orch.setLabel(leaf.id, labelDraft); setEditingLabel(false); }, [editingLabel, orch.setLabel, leaf.id, labelDraft]); const cancelLabel = useCallback(() => setEditingLabel(false), []); const onLabelKey = useCallback( (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); commitLabel(); } else if (e.key === "Escape") { e.preventDefault(); cancelLabel(); } }, [commitLabel, cancelLabel], ); // ---- shell-picker popover ---------------------------------------------- // Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH // hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id // (forces respawn). const [shellMenuOpen, setShellMenuOpen] = useState(false); const toggleShellMenu = useCallback((e: MouseEvent) => { e.stopPropagation(); setShellMenuOpen((v) => !v); }, []); const pickShell = useCallback( (spec: LeafShellSpec) => { setShellMenuOpen(false); // Only respawn if the spec is actually different from what's running. if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) { return; } if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") { return; } if ( spec.shellKind === "ssh" && leaf.shellKind === "ssh" && spec.sshHostId === leaf.sshHostId ) { return; } orch.setShell(leaf.id, spec); }, [orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId], ); const onManageHosts = useCallback( (e: MouseEvent) => { e.stopPropagation(); setShellMenuOpen(false); orch.openHostManager(); }, [orch.openHostManager], ); // Dismiss popover on outside click useEffect(() => { if (!shellMenuOpen) return; const onDocClick = () => setShellMenuOpen(false); window.addEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick); }, [shellMenuOpen]); // Label shown on the dropdown chip — tells the user what's currently // running without expanding the menu. const chipLabel = leaf.shellKind === "powershell" ? "PowerShell" : leaf.shellKind === "ssh" ? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}` : (leaf.distro ?? "(default)"); // ---- idle detection ---------------------------------------------------- // Local boolean for the red border + status text on this pane; reported // up to App via orch.reportLeafIdle for the titlebar's "N idle" badge. // // Filter: for WSL panes, before flagging idle we probe the backend to see // if any "watched" process (currently just `claude`) is running in THIS // pane specifically — per-pane, not per-distro. Per-pane is essential for // tiletopia's primary use case (multiple claude sessions across panes in // the same distro). The backend matches by reading `TILETOPIA_PANE_ID` // out of each candidate process's `/proc//environ` (the env var is // injected at spawn time; see src-tauri/src/pty.rs WSLENV setup). // // PowerShell + SSH skip the probe and fall through to legacy behaviour // (PS has no portable `ps`; SSH processes live on a remote box). const lastDataTimeRef = useRef(Date.now()); const [isIdle, setIsIdle] = useState(false); const isWslPane = leaf.shellKind === "wsl"; // Captures the distro name into the interval callback. Empty string when // the leaf doesn't have one yet — the probe returns "not running" for // empty input so the pane goes idle normally. const wslDistro = isWslPane ? (leaf.distro ?? "") : ""; // Backend pane id (PaneId, the u64 used inside Rust). Set by the // XtermPane onSpawn callback; null until the spawn round-trip completes. // Idle ticks before that point pass 0 — won't match any real pane's // TILETOPIA_PANE_ID env, so the probe returns false (no suppression). const paneIdRef = useRef(null); const onDataReceived = useCallback(() => { lastDataTimeRef.current = Date.now(); setIsIdle((cur) => { if (cur) orch.reportLeafIdle(leaf.id, false); return false; }); }, [orch.reportLeafIdle, leaf.id]); useEffect(() => { // Guard against late-resolving probes after unmount or another tick // already shipping a fresher answer. let cancelled = false; let inFlight = false; const tick = () => { const dt = Date.now() - lastDataTimeRef.current; const nowIdle = dt >= IDLE_THRESHOLD_MS; // Transitioning out of idle is unconditional — fresh output beats // any probe answer. if (!nowIdle) { setIsIdle((cur) => { if (!cur) return cur; orch.reportLeafIdle(leaf.id, false); return false; }); return; } // Transitioning into idle. Non-WSL panes: report immediately (legacy // behaviour). WSL panes: gate on the probe; suppress if a watched // process is running in the distro. if (!isWslPane) { setIsIdle((cur) => { if (cur) return cur; orch.reportLeafIdle(leaf.id, true); return true; }); return; } // WSL path. Don't stack probes — one in flight per pane at a time. if (inFlight) return; inFlight = true; const paneIdForProbe = paneIdRef.current ?? 0; void isWatchProcessRunning(wslDistro, paneIdForProbe) .then((suppress) => { if (cancelled) return; // If output arrived while the probe was in flight, the next tick // (or onDataReceived) will reconcile; don't flip-flop here. if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return; if (suppress) { // claude (or another watched proc) is running in THIS pane — // treat the silence as expected; stay out of the idle set. setIsIdle((cur) => { if (!cur) return cur; orch.reportLeafIdle(leaf.id, false); return false; }); } else { setIsIdle((cur) => { if (cur) return cur; orch.reportLeafIdle(leaf.id, true); return true; }); } }) .catch((e) => { // Probe IPC errored — don't flip idle either way; next tick retries. // The Rust side now also fails-safe to "not running" so the pane // will flag idle eventually if the probe stays broken. if (cancelled) return; // eslint-disable-next-line no-console console.debug("idle probe failed", e); }) .finally(() => { inFlight = false; }); }; const id = window.setInterval(tick, 1000); return () => { cancelled = true; clearInterval(id); }; }, [leaf.id, orch.reportLeafIdle, isWslPane, wslDistro]); // Clear from the app-level idle set when this pane unmounts. useEffect(() => { return () => orch.reportLeafIdle(leaf.id, false); }, [leaf.id, orch.reportLeafIdle]); // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { if (isBroadcasting) orch.broadcastFrom(leaf.id, b64); }, [isBroadcasting, orch.broadcastFrom, leaf.id], ); // ---- focus / active highlighting --------------------------------------- const [focusTrigger, setFocusTrigger] = useState(0); // When this leaf becomes active, bump focusTrigger so XtermPane refocuses. useEffect(() => { if (isActive) setFocusTrigger((n) => n + 1); }, [isActive]); const onPaneClick = useCallback(() => { orch.setActive(leaf.id); }, [orch.setActive, leaf.id]); const onPaneSpawned = useCallback( (paneId: number) => { paneIdRef.current = paneId; orch.registerPaneId(leaf.id, paneId); }, [orch.registerPaneId, leaf.id], ); // Unregister on TRUE unmount only — depending on `orch` here would // delete the paneId from App's lookup on every activeLeafId change, // which broke broadcast routing (peers found, but their paneIds // had been silently removed from the map). useEffect(() => { return () => { paneIdRef.current = null; orch.registerPaneId(leaf.id, null); }; }, [orch.registerPaneId, leaf.id]); const onXtermFocus = useCallback( () => orch.setActive(leaf.id), [orch.setActive, leaf.id], ); const onStatus = useCallback((msg: string, ok: boolean) => { setStatus(msg); setStatusOk(ok); }, []); // ---- header-drag swap --------------------------------------------------- // Drag the toolbar onto another pane's toolbar/body to swap their tree // positions. Uses a movement threshold so accidental tiny moves while // clicking a label etc don't initiate a drag. const DRAG_THRESHOLD_PX = 5; const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>( null, ); const isDragSource = orch.dragSourceId === leaf.id; const isDragTarget = orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id; const onToolbarPointerDown = useCallback( (e: ReactPointerEvent) => { const target = e.target as HTMLElement; // Skip if the click landed on an interactive child. if (target.closest("button, input, .distro-menu")) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); dragStartRef.current = { x: e.clientX, y: e.clientY, armed: true, dragging: false, }; // Make this pane active (since clicking the toolbar should focus it). orch.setActive(leaf.id); }, [orch.setActive, leaf.id], ); const onToolbarPointerMove = useCallback( (e: ReactPointerEvent) => { const st = dragStartRef.current; if (!st || !st.armed) return; const dx = e.clientX - st.x; const dy = e.clientY - st.y; if (!st.dragging) { if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; st.dragging = true; orch.beginHeaderDrag(leaf.id); document.body.style.cursor = "grabbing"; } // Find the leaf under the cursor. const el = document.elementFromPoint(e.clientX, e.clientY); const tEl = el?.closest("[data-leaf-id]"); const targetId = tEl?.getAttribute("data-leaf-id") ?? null; orch.setHeaderDragOver(targetId); }, [orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id], ); const onToolbarPointerUp = useCallback( (e: ReactPointerEvent) => { const st = dragStartRef.current; if (!st) return; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; if (wasDragging) { document.body.style.cursor = ""; orch.endHeaderDrag(true); } }, [orch.endHeaderDrag], ); const onToolbarPointerCancel = useCallback( (e: ReactPointerEvent) => { const st = dragStartRef.current; if (!st) return; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; if (wasDragging) { document.body.style.cursor = ""; orch.endHeaderDrag(false); } }, [orch.endHeaderDrag], ); const labelText = leaf.label ?? "(unnamed)"; // Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but // the referenced host was deleted, we surface an error in the toolbar // status instead of spawning an unrelated shell. const spec: SpawnSpec | null = (() => { if (leaf.shellKind === "wsl") { return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd }; } if (leaf.shellKind === "powershell") { return { kind: "powershell" }; } const host = orch.hosts.find((h) => h.id === leaf.sshHostId); if (!host) return null; return { kind: "ssh", host: host.hostname, user: host.user, port: host.port, identityFile: host.identityFile, jumpHost: host.jumpHost, extraArgs: host.extraArgs, hostId: host.id, }; })(); return (
{editingLabel ? ( setLabelDraft(e.target.value)} onKeyDown={onLabelKey} onBlur={commitLabel} placeholder="(label)" /> ) : ( )} {shellMenuOpen && (
e.stopPropagation()} > {orch.distros.length > 0 && ( <>
WSL
{orch.distros.map((d) => { const active = leaf.shellKind === "wsl" && d === leaf.distro; return ( ); })} )}
Windows
SSH
{orch.hosts.length === 0 ? (
(no saved hosts)
) : ( orch.hosts.map((h) => { const active = leaf.shellKind === "ssh" && h.id === leaf.sshHostId; return ( ); }) )}
)}
{isIdle && statusOk ? ( idle ) : ( {status} )}
{spec ? ( ) : (

SSH host not found

Open the shell menu and pick another host, or add this host back via Manage hosts….

)}
); }