tiletopia/src/lib/layout/LeafPane.tsx
megaproxy aab36afce4 Per-pane and global terminal zoom via keyboard
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>
2026-05-22 22:48:35 +01:00

373 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}