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 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. const lastDataTimeRef = useRef(Date.now()); const [isIdle, setIsIdle] = useState(false); const onDataReceived = useCallback(() => { lastDataTimeRef.current = Date.now(); setIsIdle((cur) => { if (cur) orch.reportLeafIdle(leaf.id, false); return false; }); }, [orch.reportLeafIdle, leaf.id]); useEffect(() => { const id = window.setInterval(() => { const dt = Date.now() - lastDataTimeRef.current; const nowIdle = dt >= IDLE_THRESHOLD_MS; setIsIdle((cur) => { if (cur === nowIdle) return cur; orch.reportLeafIdle(leaf.id, nowIdle); return nowIdle; }); }, 1000); return () => clearInterval(id); }, [leaf.id, orch.reportLeafIdle]); // 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) => { 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 () => 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); }, []); // ---- right-click context menu ------------------------------------------ // Single entry in v1: "Move to new window" (pops the pane out into a // fresh top-level tiletopia window without losing the PTY). const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); const openContextMenu = useCallback( (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setMenuPos({ x: e.clientX, y: e.clientY }); }, [], ); const closeContextMenu = useCallback(() => setMenuPos(null), []); useEffect(() => { if (!menuPos) return; const onDocClick = () => setMenuPos(null); const onEsc = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") setMenuPos(null); }; // Defer attaching the click listener so the click that opened the menu // doesn't immediately close it. const t = window.setTimeout(() => { window.addEventListener("click", onDocClick); window.addEventListener("keydown", onEsc, true); }, 0); return () => { clearTimeout(t); window.removeEventListener("click", onDocClick); window.removeEventListener("keydown", onEsc, true); }; }, [menuPos]); // ---- 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], ); /** How far past a viewport edge the cursor must travel before a release * is treated as "drag pane out of window" instead of "drop on empty * space inside this window". Picked so an accidental release on the OS * titlebar (~30px tall) stays inside the threshold. */ const PANE_DRAG_OUT_MARGIN = 60; 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) return; document.body.style.cursor = ""; const releasedFarOutside = e.clientX < -PANE_DRAG_OUT_MARGIN || e.clientX > window.innerWidth + PANE_DRAG_OUT_MARGIN || e.clientY < -PANE_DRAG_OUT_MARGIN || e.clientY > window.innerHeight + PANE_DRAG_OUT_MARGIN; if (releasedFarOutside) { // Cancel any in-flight swap state without committing, then pop // this pane into a fresh window. moveToNewWindow handles the // PTY-handoff + closeLeaf in the source. orch.endHeaderDrag(false); orch.moveToNewWindow(leaf.id); } else { orch.endHeaderDrag(true); } }, [orch.endHeaderDrag, orch.moveToNewWindow, leaf.id], ); 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….

)}
{menuPos && (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()} >
)}
); }