import { useState, useEffect, useRef, useCallback, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { type LeafNode, resolveFontSize } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; 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], ); // ---- distro popover ---------------------------------------------------- const [distroOpen, setDistroOpen] = useState(false); const toggleDistroMenu = useCallback((e: MouseEvent) => { e.stopPropagation(); setDistroOpen((v) => !v); }, []); const pickDistro = useCallback( (d: string) => { setDistroOpen(false); if (d !== leaf.distro) orch.setDistro(leaf.id, d); }, [orch.setDistro, leaf.id, leaf.distro], ); // Dismiss popover on outside click useEffect(() => { if (!distroOpen) return; const onDocClick = () => setDistroOpen(false); window.addEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick); }, [distroOpen]); // ---- 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); }, []); // ---- 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)"; return (
{editingLabel ? ( setLabelDraft(e.target.value)} onKeyDown={onLabelKey} onBlur={commitLabel} placeholder="(label)" /> ) : ( )} {distroOpen && (
e.stopPropagation()} > {orch.distros.map((d) => ( ))}
)}
{isIdle && statusOk ? ( idle ) : ( {status} )}
); }