Each leaf now carries an optional fontSizeOffset, persisted in workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0 adjust the active pane; adding Shift escalates to every pane (the mirror of the broadcast Shift+Alt convention, with shift alone since the keys are otherwise unused). Bindings match on e.code so layouts that don't have "=" / "-" / "0" in the same spot still work. XtermPane gained a fontSize prop. A secondary effect reacts to changes: set term.options.fontSize, fit() to recompute cols/rows for the new cell size, refresh(), then resizePane so bash redraws the prompt at the right width. No remount, so PTY + scrollback survive zoom changes. The new tree helpers (resolveFontSize / adjustFontSize / adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so nothing respawns. reshapeToPreset also carries the offset across when splicing existing leaves into a new layout. 12 new vitest cases pin those invariants plus the clamp and reset-to-default behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
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<HTMLInputElement | null>(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<HTMLInputElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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<HTMLDivElement>) => {
|
||
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 (
|
||
<div
|
||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||
role="group"
|
||
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||
data-leaf-id={leaf.id}
|
||
onPointerDown={onPaneClick}
|
||
>
|
||
<div
|
||
className="pane-toolbar"
|
||
onPointerDown={onToolbarPointerDown}
|
||
onPointerMove={onToolbarPointerMove}
|
||
onPointerUp={onToolbarPointerUp}
|
||
onPointerCancel={onToolbarPointerCancel}
|
||
>
|
||
{editingLabel ? (
|
||
<input
|
||
ref={labelInputRef}
|
||
className="label-input"
|
||
value={labelDraft}
|
||
onChange={(e) => setLabelDraft(e.target.value)}
|
||
onKeyDown={onLabelKey}
|
||
onBlur={commitLabel}
|
||
placeholder="(label)"
|
||
/>
|
||
) : (
|
||
<button
|
||
className="pane-label"
|
||
onClick={startEditLabel}
|
||
title="Click to rename pane"
|
||
>
|
||
{labelText}
|
||
</button>
|
||
)}
|
||
|
||
<span className="distro-wrap">
|
||
<button
|
||
className="distro-chip"
|
||
onClick={toggleDistroMenu}
|
||
title="Change distro (respawns the pane)"
|
||
>
|
||
{leaf.distro ?? "(default)"} ▾
|
||
</button>
|
||
{distroOpen && (
|
||
<div
|
||
className="distro-menu"
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{orch.distros.map((d) => (
|
||
<button
|
||
key={d}
|
||
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
|
||
onClick={() => pickDistro(d)}
|
||
>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</span>
|
||
|
||
<button
|
||
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.toggleBroadcast(leaf.id);
|
||
}}
|
||
title={
|
||
isBroadcasting
|
||
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
|
||
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
|
||
}
|
||
aria-pressed={isBroadcasting ? "true" : "false"}
|
||
>
|
||
📡
|
||
</button>
|
||
|
||
{isIdle && statusOk ? (
|
||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||
idle
|
||
</span>
|
||
) : (
|
||
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
||
)}
|
||
|
||
<span className="pane-actions">
|
||
<button
|
||
className="pane-btn"
|
||
title="Split right (Ctrl+Shift+E)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.split(leaf.id, "h");
|
||
}}
|
||
aria-label="Split right"
|
||
>
|
||
⇥
|
||
</button>
|
||
<button
|
||
className="pane-btn"
|
||
title="Split down (Ctrl+Shift+O)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.split(leaf.id, "v");
|
||
}}
|
||
aria-label="Split down"
|
||
>
|
||
⇣
|
||
</button>
|
||
<button
|
||
className="pane-btn close"
|
||
title="Close pane (Ctrl+Shift+W)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.close(leaf.id);
|
||
}}
|
||
aria-label="Close pane"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div className="xterm-wrap">
|
||
<XtermPane
|
||
distro={leaf.distro}
|
||
cwd={leaf.cwd}
|
||
onStatus={onStatus}
|
||
onSpawn={onPaneSpawned}
|
||
onInput={onTerminalInput}
|
||
onDataReceived={onDataReceived}
|
||
onFocus={onXtermFocus}
|
||
focusTrigger={focusTrigger}
|
||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|