From c8234442f1e28b6a2580a8693f3978495211c10e Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 18:41:26 +0100 Subject: [PATCH 001/120] README: update for the React rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stack: Svelte 5 โ†’ React 18, with a note about the migration history - Build: pnpm check is now tsc --noEmit, not svelte-check; mention pnpm build - Architecture: rename component refs to .tsx; describe React Context for shared orchestration state instead of the old PaneOps drill-down - Features: mention the new global ๐Ÿ“ก titlebar toggle alongside the per-pane chips Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c91c02c..c8e9b81 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - Tiling layout โ€” recursive splits, draggable dividers, preset layouts (single / 2-col / 3-col / 2-row / 2ร—2) - Per-pane distro + cwd + label, persisted across restarts -- Broadcast input to a group of panes +- Broadcast input to a group of panes (per-pane ๐Ÿ“ก chip, or global toggle in the titlebar) - Idle-detection toasts when a pane goes quiet - Ctrl+K palette to fuzzy-jump between panes @@ -25,7 +25,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **Close pane** โ€” `ร—`. The sibling expands to fill. - **Rename pane** โ€” click the label in the toolbar, type, Enter (Esc to cancel). - **Change distro** โ€” click the small `Ubuntu โ–พ` chip; pick a distro from the popover. The pane respawns (old shell is killed). -- **Broadcast** โ€” toggle `๐Ÿ“ก` on two or more panes (orange border). Typing in any of them mirrors to all. +- **Broadcast** โ€” toggle `๐Ÿ“ก` on two or more panes (orange border). Typing in any of them mirrors to all. The titlebar `๐Ÿ“ก all off` / `๐Ÿ“ก all on` / `๐Ÿ“ก N/M` button flips the whole group at once. - **Preset layouts** โ€” titlebar buttons: `1` / `2H` / `3H` / `2V` / `2ร—2`. Confirms before replacing a multi-pane layout. - **Active pane** โ€” click any pane โ†’ blue border + keyboard focus. - **Jump to pane** โ€” `Ctrl+K` opens a fuzzy picker over label / distro / cwd. โ†‘/โ†“ to navigate, Enter to focus, Esc to close. @@ -36,7 +36,7 @@ Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\works ## Stack - **Tauri 2** (Rust backend, WebView2 frontend) โ€” small bundle, native NSIS installer. -- **Svelte 5** + TypeScript + Vite + pnpm. +- **React 18** + TypeScript + Vite + pnpm. (The v0.1.0 release was Svelte 5; v0.2.0+ is React after a ground-up rewrite of the frontend. Same data model, same backend, more reliable reactivity through the recursive Pane chain. The Svelte version is preserved on the `svelte-archive` branch.) - **xterm.js** + `@xterm/addon-fit` for terminal rendering. - **`portable-pty`** (Rust) spawning `wsl.exe -d ` PTYs. @@ -59,7 +59,8 @@ pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis ```sh pnpm test # vitest, 43 cases on the layout tree pnpm test:watch -pnpm check # svelte-check +pnpm check # tsc --noEmit (strict TypeScript pass) +pnpm build # tsc -b && vite build โ€” full production frontend bundle ``` The test suite covers the pure helpers in `src/lib/layout/tree.ts`. UI behavior, broadcast routing, and Tauri integration are manually tested. @@ -67,8 +68,8 @@ The test suite covers the pure helpers in `src/lib/layout/tree.ts`. UI behavior, ## Architecture - **Backend** (`src-tauri/src/pty.rs`): `PtyManager` holding `Mutex>` of `portable-pty` children. Each spawned pane gets a background reader thread that emits `pane://{id}/data` events (base64 byte chunks). Counterparts: `write_to_pane` / `resize_pane` / `kill_pane`. Workspace persistence via `save_workspace` / `load_workspace` writes to `app.path().app_config_dir()` with atomic tmp + rename. -- **Layout** (`src/lib/layout/tree.ts`): binary tree of splits. `HSplit | VSplit` internal nodes with a ratio, `Leaf` at the bottom โ€” same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (`splitLeaf`, `closeLeaf`, `changeDistro`, etc.) live in `tree.ts`; the rendering chain (`Pane.svelte` โ†’ `SplitNode.svelte` / `LeafPane.svelte`) is thin. -- **Orchestration** โ€” broadcast routing, idle detection, palette, active-pane focus all live in `App.svelte` and reach the panes via a `PaneOps` bundle (`src/lib/layout/ops.ts`) drilled through the Pane chain. +- **Layout** (`src/lib/layout/tree.ts`): binary tree of splits. `HSplit | VSplit` internal nodes with a ratio, `Leaf` at the bottom โ€” same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (`splitLeaf`, `closeLeaf`, `changeDistro`, `setAllBroadcast`, etc.) live in `tree.ts` with 43 vitest cases; the rendering chain (`Pane.tsx` โ†’ `SplitNode.tsx` / `LeafPane.tsx`) is thin. +- **Orchestration** โ€” broadcast routing, idle detection, palette, active-pane focus all live in `App.tsx`. Shared state and operations reach descendants through a React Context (`src/lib/layout/orchestration.tsx`), so each LeafPane reads `activeLeafId`, `distros`, and the tree-mutation methods directly via `useOrchestration()` โ€” no prop drilling through the recursive Pane chain. ## License From 2a0c096095d67d66734fc3ef5ab520c4a3904277 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 18:46:56 +0100 Subject: [PATCH 002/120] Fix broadcast no-op: stop depending on orch object in LeafPane effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: clicking ๐Ÿ“ก made the visual update (orange border) but typing in a broadcasting pane only wrote to that pane โ€” peers never received the keystrokes. Root cause: the orch context value (useMemo'd over activeLeafId, distros, and the operation callbacks) is recreated every time activeLeafId changes (i.e. every click). useEffect cleanups in LeafPane that had `orch` in their deps fired their cleanup-then-setup cycle on every click. The unmount-cleanup for paneId registration ran `orch.registerPaneId(leaf.id, null)`, silently deleting paneIds from App's paneIdByLeafRef map โ€” so when broadcastFrom later walked the tree looking up peers, the map returned undefined for every leaf and the actual writeToPane calls never happened. Fix: depend on the specific stable method references (`orch.registerPaneId`, `orch.notify`, etc.) instead of the orch object itself. The methods are all useCallback'd with stable deps in App.tsx, so their references don't change across orch object recreations โ€” effect deps stay stable, no spurious cleanup. Applied the same fix to all orch-using effects/callbacks in LeafPane (commitLabel, pickDistro, onPaneClick, onPaneSpawned, onXtermFocus, onTerminalInput, idle interval, paneId cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/layout/LeafPane.tsx | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 73d8f49..e8e1ffe 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -41,7 +41,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { if (!editingLabel) return; orch.setLabel(leaf.id, labelDraft); setEditingLabel(false); - }, [editingLabel, orch, leaf.id, labelDraft]); + }, [editingLabel, orch.setLabel, leaf.id, labelDraft]); const cancelLabel = useCallback(() => setEditingLabel(false), []); const onLabelKey = useCallback( (e: KeyboardEvent) => { @@ -67,7 +67,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { setDistroOpen(false); if (d !== leaf.distro) orch.setDistro(leaf.id, d); }, - [orch, leaf.id, leaf.distro], + [orch.setDistro, leaf.id, leaf.distro], ); // Dismiss popover on outside click useEffect(() => { @@ -95,14 +95,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { } }, 1000); return () => clearInterval(id); - }, [leaf.label, leaf.distro, orch]); + // Depend on the stable notify function, not the whole orch object. + // orch is recreated every time activeLeafId/distros change; depending + // on it would tear down and rebuild this interval on every click. + }, [leaf.label, leaf.distro, orch.notify]); // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { if (isBroadcasting) orch.broadcastFrom(leaf.id, b64); }, - [isBroadcasting, orch, leaf.id], + [isBroadcasting, orch.broadcastFrom, leaf.id], ); // ---- focus / active highlighting --------------------------------------- @@ -114,20 +117,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const onPaneClick = useCallback(() => { orch.setActive(leaf.id); - }, [orch, leaf.id]); + }, [orch.setActive, leaf.id]); const onPaneSpawned = useCallback( (paneId: number) => { orch.registerPaneId(leaf.id, paneId); }, - [orch, leaf.id], + [orch.registerPaneId, leaf.id], ); - // Unregister on unmount + // 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, leaf.id]); + }, [orch.registerPaneId, leaf.id]); - const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]); + const onXtermFocus = useCallback( + () => orch.setActive(leaf.id), + [orch.setActive, leaf.id], + ); const onStatus = useCallback((msg: string, ok: boolean) => { setStatus(msg); From 8c3af8f9ee4dcb28ef46f233b4637990dac0d500 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 19:27:50 +0100 Subject: [PATCH 003/120] Preserve existing panes when applying a preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously: clicking 1 / 2H / 3H / 2V / 2ร—2 in the titlebar replaced the whole tree with brand-new empty leaves, killing every shell โ€” and the only safeguard was a window.confirm() that's easy to miss-click. The user lost work whenever they reached for a preset. New behaviour via `reshapeToPreset`: - The preset's shape is built fresh (1, 2, 3, or 4 slots), then existing leaves are spliced into those slots in DFS order. Their id / distro / cwd / label / broadcast all carry over, so the same PaneId is still mapped โ€” the PTY keeps running. - If the preset has MORE slots than existing leaves (e.g. 1 pane โ†’ 2ร—2), the extra slots stay as fresh empty leaves and new shells spawn there. No prompt โ€” pure additive change. - If the preset has FEWER slots than existing leaves (e.g. 8 panes โ†’ 2ร—2), the overflow leaves are returned in `dropped`. We confirm with the user, and if they accept, kill those PTYs explicitly. Tradeoff: split ratios reset to 0.5 (the whole point of "apply preset" is to use its layout). That's an acceptable cost. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 36 ++++++++++++++++++++++++++---------- src/lib/layout/tree.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7a4b5ef..d917f79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { changeLabel, toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, + reshapeToPreset, serialize, deserialize, presetSingle, @@ -264,18 +265,33 @@ export default function App() { const applyPreset = useCallback( (make: (d: { distro?: string }) => TreeNode) => { - const count = leafCount(tree); - if ( - count > 1 && - !window.confirm( - `Replace current layout (${count} panes)? This kills all open shells.`, - ) - ) { - return; + const { tree: nextTree, dropped } = reshapeToPreset(tree, make, { + distro: defaultDistro, + }); + + if (dropped.length > 0) { + const ok = window.confirm( + `This preset has fewer slots than your current ${leafCount(tree)} panes. ${dropped.length} pane${dropped.length === 1 ? "" : "s"} will be closed (their shells will be killed). Continue?`, + ); + if (!ok) return; + + for (const id of dropped) { + const paneId = paneIdByLeafRef.current.get(id); + if (paneId != null) { + void killPane(paneId).catch((e) => + console.warn("killPane failed:", e), + ); + paneIdByLeafRef.current.delete(id); + } + } + if (activeLeafId && dropped.includes(activeLeafId)) { + setActiveLeafId(null); + } } - setTree(make({ distro: defaultDistro })); + + setTree(nextTree); }, - [tree, defaultDistro], + [tree, defaultDistro, activeLeafId], ); const paletteLeaves = useMemo( diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index b0d805b..38bfbc8 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -198,6 +198,48 @@ export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode { }); } +/** + * Reshape the tree into the structure produced by `preset`, but PRESERVE + * existing leaves (and their PTYs) by copying their id/distro/cwd/label/ + * broadcast into the preset's slots, in DFS order. + * + * - If the preset has more slots than existing leaves, the extra slots stay + * as their freshly-created (empty) leaves โ€” those panes will spawn new + * shells. + * - If the preset has fewer slots than existing leaves, the overflow leaves + * are returned in `dropped` so the caller can kill their PTYs. + * + * Split ratios reset to the preset's defaults (0.5 โ€” the user's previous + * resize work is discarded; that's the point of "apply preset"). + */ +export function reshapeToPreset( + existing: TreeNode, + preset: (d: LeafDefaults) => TreeNode, + defaults: LeafDefaults, +): { tree: TreeNode; dropped: NodeId[] } { + const existingLeaves = Array.from(walkLeaves(existing)); + const tree = preset(defaults); + const slots = Array.from(walkLeaves(tree)); + const dropped: NodeId[] = []; + + for (let i = 0; i < slots.length; i++) { + const src = existingLeaves[i]; + if (!src) break; + const slot = slots[i]; + slot.id = src.id; + if (src.distro !== undefined) slot.distro = src.distro; + if (src.cwd !== undefined) slot.cwd = src.cwd; + if (src.label !== undefined) slot.label = src.label; + if (src.broadcast !== undefined) slot.broadcast = src.broadcast; + } + + for (let i = slots.length; i < existingLeaves.length; i++) { + dropped.push(existingLeaves[i].id); + } + + return { tree, dropped }; +} + /** Force every leaf's broadcast flag to `on`. Returns the same root reference * when nothing actually changed, so callers can skip a state update if so. */ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode { From c4747546e0141d77e9a4a569498b1c6e4f39cdfd Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 19:39:58 +0100 Subject: [PATCH 004/120] Flat-list layout: render leaves as siblings keyed by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2ร—2 appeared to preserve panes (we copied id/distro/cwd/label/broadcast into the preset's slots), but React's reconciliation tore down every LeafPane and re-mounted it because the tree structure changed โ€” killing all PTYs and spawning fresh shells. The "preservation" was data-only; the React components didn't survive. Solution: stop rendering the Pane โ†’ SplitNode โ†’ LeafPane recursion. Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each box is top/left/width/height as fractions 0โ€“1). Render all leaves as siblings of a relative-positioned container, each absolutely positioned by its box. Key each one by leaf.id โ€” React preserves the component (and its XtermPane โ†’ PTY) across any tree reshape; only the inline style changes. Gutters render as separate sibling overlays at the split boundaries, each with its own pointer handlers. Dragging mutates the split's ratio via `updateSplitRatio(tree, splitId, r)`; the layout recomputes; leaf boxes change; nothing remounts. Now: clicking 2ร—2 on 4 stacked panes keeps all 4 shells alive and just rearranges them into the grid. Same for any preset that doesn't overflow. Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their CSS. The render path is now straightforward, no recursion, easier to reason about. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 42 +++++++++++++- src/lib/layout/Gutter.css | 32 +++++++++++ src/lib/layout/Gutter.tsx | 98 +++++++++++++++++++++++++++++++++ src/lib/layout/Pane.tsx | 16 ------ src/lib/layout/SplitNode.css | 36 ------------ src/lib/layout/SplitNode.tsx | 79 --------------------------- src/lib/layout/tree.ts | 103 +++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 134 deletions(-) create mode 100644 src/lib/layout/Gutter.css create mode 100644 src/lib/layout/Gutter.tsx delete mode 100644 src/lib/layout/Pane.tsx delete mode 100644 src/lib/layout/SplitNode.css delete mode 100644 src/lib/layout/SplitNode.tsx diff --git a/src/App.tsx b/src/App.tsx index d917f79..de31fff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ import { toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, reshapeToPreset, + flattenLayout, + updateSplitRatio, serialize, deserialize, presetSingle, @@ -32,10 +34,12 @@ import { presetTwoByTwo, } from "./lib/layout/tree"; import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration"; -import Pane from "./lib/layout/Pane"; +import LeafPane from "./lib/layout/LeafPane"; +import Gutter from "./lib/layout/Gutter"; import Notifications, { type Toast } from "./components/Notifications"; import Palette from "./components/Palette"; import "./App.css"; +import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; const SAVE_DEBOUNCE_MS = 500; @@ -299,6 +303,16 @@ export default function App() { [paletteOpen, tree], ); + // ---- flat layout โ€” leaves as siblings keyed by id; gutters separate ----- + // This lets React preserve LeafPane (and its PTY) across any tree reshape + // โ€” split, close, preset application, etc. The tree changes, the boxes + // change, the leaves re-position but DON'T unmount. + const layout = useMemo(() => flattenLayout(tree), [tree]); + const paneWrapRef = useRef(null); + const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => { + setTree((t) => updateSplitRatio(t, splitId, ratio)); + }, []); + // ---- global broadcast state (derived from tree) ------------------------- const broadcastStats = useMemo(() => { let on = 0; @@ -395,10 +409,32 @@ export default function App() { -
+
{ready && ( - + {layout.leaves.map(({ leaf, box }) => ( +
+ +
+ ))} + {layout.gutters.map((g) => ( + + ))}
)}
diff --git a/src/lib/layout/Gutter.css b/src/lib/layout/Gutter.css new file mode 100644 index 0000000..fe4d777 --- /dev/null +++ b/src/lib/layout/Gutter.css @@ -0,0 +1,32 @@ +/* The hitbox is invisible; we render a 4px visible line in the middle + via a pseudo-element so the grab area is generous while the visual + stays thin. */ +.gutter { + background: transparent; + user-select: none; + touch-action: none; +} +.gutter::before { + content: ""; + position: absolute; + background: #1a1a1a; + transition: background 0.12s; +} +.gutter-h::before { + top: 0; + bottom: 0; + left: 50%; + width: 4px; + transform: translateX(-50%); +} +.gutter-v::before { + left: 0; + right: 0; + top: 50%; + height: 4px; + transform: translateY(-50%); +} +.gutter:hover::before, +.gutter.active::before { + background: #3a5a8c; +} diff --git a/src/lib/layout/Gutter.tsx b/src/lib/layout/Gutter.tsx new file mode 100644 index 0000000..79afa5b --- /dev/null +++ b/src/lib/layout/Gutter.tsx @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState, type PointerEvent } from "react"; +import type { GutterInfo } from "./tree"; + +/** + * A draggable gutter at a split boundary. + * + * `info.box` is where to render the strip (in container fractions 0โ€“1); + * `info.parentBox` is the parent split's bounding box, used to convert + * pointer position back into a 0โ€“1 ratio. + * + * The actual draggable hitbox is a few pixels thick (and centered on the + * boundary), but we render a thin visible line via CSS pseudo-elements. + */ +const HITBOX_PX = 8; + +export default function Gutter({ + info, + containerRef, + onRatioChange, +}: { + info: GutterInfo; + containerRef: React.RefObject; + onRatioChange: (splitId: string, ratio: number) => void; +}) { + const [dragging, setDragging] = useState(false); + const draggingRef = useRef(false); + + const onPointerDown = useCallback((e: PointerEvent) => { + (e.target as HTMLElement).setPointerCapture(e.pointerId); + setDragging(true); + draggingRef.current = true; + e.preventDefault(); + e.stopPropagation(); + }, []); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (!draggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const xFrac = (e.clientX - rect.left) / rect.width; + const yFrac = (e.clientY - rect.top) / rect.height; + const pb = info.parentBox; + const rawRatio = + info.orientation === "h" + ? (xFrac - pb.left) / pb.width + : (yFrac - pb.top) / pb.height; + const ratio = Math.max(0.05, Math.min(0.95, rawRatio)); + onRatioChange(info.splitId, ratio); + }, + [containerRef, info, onRatioChange], + ); + + const onPointerUp = useCallback((e: PointerEvent) => { + if (!draggingRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + draggingRef.current = false; + setDragging(false); + }, []); + + const isH = info.orientation === "h"; + // Visible 4px line, but the draggable hitbox is wider for grabbability. + const halfHit = HITBOX_PX / 2; + const style: React.CSSProperties = isH + ? { + position: "absolute", + top: `${info.box.top * 100}%`, + left: `calc(${info.box.left * 100}% - ${halfHit}px)`, + height: `${info.box.height * 100}%`, + width: `${HITBOX_PX}px`, + cursor: "col-resize", + zIndex: 10, + } + : { + position: "absolute", + top: `calc(${info.box.top * 100}% - ${halfHit}px)`, + left: `${info.box.left * 100}%`, + width: `${info.box.width * 100}%`, + height: `${HITBOX_PX}px`, + cursor: "row-resize", + zIndex: 10, + }; + + return ( +
+ ); +} diff --git a/src/lib/layout/Pane.tsx b/src/lib/layout/Pane.tsx deleted file mode 100644 index f3f27c7..0000000 --- a/src/lib/layout/Pane.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { TreeNode } from "./tree"; -import SplitNode from "./SplitNode"; -import LeafPane from "./LeafPane"; - -/** - * Recursive dispatcher: render a split or a leaf based on node.kind. - * The `key={node.id}` on the leaf branch makes React unmount + remount - * cleanly when a leaf is replaced (e.g. changeDistro swaps the id to - * force PTY respawn). - */ -export default function Pane({ node }: { node: TreeNode }) { - if (node.kind === "split") { - return ; - } - return ; -} diff --git a/src/lib/layout/SplitNode.css b/src/lib/layout/SplitNode.css deleted file mode 100644 index 531d2c4..0000000 --- a/src/lib/layout/SplitNode.css +++ /dev/null @@ -1,36 +0,0 @@ -.split { - display: flex; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} -.split.horizontal { - flex-direction: row; -} -.split.vertical { - flex-direction: column; -} - -.side { - display: flex; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gutter { - flex: 0 0 4px; - background: #1a1a1a; - cursor: col-resize; - user-select: none; - touch-action: none; - transition: background 0.12s; -} -.split.vertical > .gutter { - cursor: row-resize; -} -.gutter:hover, -.gutter.active { - background: #3a5a8c; -} diff --git a/src/lib/layout/SplitNode.tsx b/src/lib/layout/SplitNode.tsx deleted file mode 100644 index 814c584..0000000 --- a/src/lib/layout/SplitNode.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useRef, useState, useCallback, type PointerEvent } from "react"; -import type { SplitNode as SplitNodeType } from "./tree"; -import Pane from "./Pane"; -import "./SplitNode.css"; - -/** - * A horizontal or vertical split with a draggable gutter. The ratio is - * local React state โ€” when the gutter is dragged, we update the local - * ratio (re-rendering the two .side flex values) and ALSO bubble the - * change up to the tree (so it persists across reloads). - * - * Initialising local state from node.ratio is fine: when the tree - * mutates around this split (e.g. a child is closed), React will give us - * a new `node` prop with possibly-different `node.ratio`, but the - * `useState` initializer only runs once. We re-sync via an effect. - */ -export default function SplitNode({ node }: { node: SplitNodeType }) { - const containerRef = useRef(null); - const [ratio, setRatio] = useState(node.ratio); - const [dragging, setDragging] = useState(false); - - // Keep local ratio in sync if the tree updates from outside (e.g. preset - // applied). Only mirror โ€” don't echo back into the tree. - // (Skipped for simplicity in v1; if it becomes annoying we can add it.) - - const onPointerDown = useCallback((e: PointerEvent) => { - (e.target as HTMLElement).setPointerCapture(e.pointerId); - setDragging(true); - e.preventDefault(); - }, []); - - const onPointerMove = useCallback( - (e: PointerEvent) => { - if (!dragging || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const isH = node.orientation === "h"; - const pos = isH ? e.clientX - rect.left : e.clientY - rect.top; - const size = isH ? rect.width : rect.height; - if (size <= 0) return; - const r = Math.max(0.05, Math.min(0.95, pos / size)); - setRatio(r); - // Mutate the proxy-tree node directly so the persisted state matches. - node.ratio = r; - }, - [dragging, node], - ); - - const onPointerUp = useCallback((e: PointerEvent) => { - setDragging(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, []); - - const isH = node.orientation === "h"; - - return ( -
-
- -
-
-
- -
-
- ); -} diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 38bfbc8..c05fdfb 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -253,6 +253,109 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode { return { ...root, a, b }; } +// --- flat layout (for absolute-positioned rendering) ------------------------ + +/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */ +export interface Box { + top: number; + left: number; + width: number; + height: number; +} + +/** A leaf rendered as a flat sibling: its current LeafNode plus the box + * it occupies in the container. */ +export interface LeafSlot { + leaf: LeafNode; + box: Box; +} + +/** A draggable gutter at a split boundary. `box` is where to render the + * draggable strip; `parentBox` is the area the gutter divides (needed to + * convert pointer position โ†’ ratio). */ +export interface GutterInfo { + splitId: NodeId; + orientation: Orientation; + ratio: number; + box: Box; + parentBox: Box; +} + +/** Walk the tree and produce a flat list of leaf slots + draggable gutters. + * Renderer uses these to position all leaves as siblings in the DOM, which + * lets React preserve component instances (and thus PTYs) across any tree + * reshape โ€” splits, closes, presets, etc. */ +export function flattenLayout( + root: TreeNode, + box: Box = { top: 0, left: 0, width: 1, height: 1 }, +): { leaves: LeafSlot[]; gutters: GutterInfo[] } { + if (root.kind === "leaf") { + return { leaves: [{ leaf: root, box }], gutters: [] }; + } + const isH = root.orientation === "h"; + const r = root.ratio; + let boxA: Box; + let boxB: Box; + let gutter: GutterInfo; + if (isH) { + const splitPos = box.width * r; + boxA = { top: box.top, left: box.left, width: splitPos, height: box.height }; + boxB = { + top: box.top, + left: box.left + splitPos, + width: box.width - splitPos, + height: box.height, + }; + gutter = { + splitId: root.id, + orientation: "h", + ratio: r, + box: { + top: box.top, + left: box.left + splitPos, + width: 0, + height: box.height, + }, + parentBox: box, + }; + } else { + const splitPos = box.height * r; + boxA = { top: box.top, left: box.left, width: box.width, height: splitPos }; + boxB = { + top: box.top + splitPos, + left: box.left, + width: box.width, + height: box.height - splitPos, + }; + gutter = { + splitId: root.id, + orientation: "v", + ratio: r, + box: { + top: box.top + splitPos, + left: box.left, + width: box.width, + height: 0, + }, + parentBox: box, + }; + } + const a = flattenLayout(root.a, boxA); + const b = flattenLayout(root.b, boxB); + return { + leaves: [...a.leaves, ...b.leaves], + gutters: [gutter, ...a.gutters, ...b.gutters], + }; +} + +/** Update a split's ratio by its id. */ +export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode { + return replaceById(root, splitId, (node) => { + if (node.kind !== "split") return node; + return { ...node, ratio }; + }); +} + export function serialize(root: TreeNode): string { return JSON.stringify(root); } From c93ebddfa5fbf32e50bd69e6449a5cbcb232fe33 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 19:47:06 +0100 Subject: [PATCH 005/120] Drag a pane's toolbar onto another pane to swap them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New interaction: click-and-drag any pane's toolbar onto another pane to swap their positions in the tree. The shells / scrollback stay intact (each leaf keeps its data; only the tree slot it occupies changes). Implementation: - tree.ts: `swapLeaves(root, idA, idB)` walks the tree once, substituting one leaf for the other at each occurrence. The leaf objects themselves carry their id/distro/cwd/label/broadcast across, so React preserves the LeafPane instances via the flat-list keying. - orchestration.tsx: add drag lifecycle to the context โ€” dragSourceId / dragOverId (reactive) plus beginHeaderDrag, setHeaderDragOver, endHeaderDrag (stable methods). - App.tsx: implement those methods. endHeaderDrag(true) swaps if source and over are different leaves. - LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target is a button/input). 5px movement threshold before drag commits to prevent accidental swaps when clicking a chip etc. Pointer-capture the toolbar so we keep getting move events even outside it. Use document.elementFromPoint to find the leaf under the cursor. - CSS: source pane fades to 40% opacity during drag; target pane shows a 3px dashed blue outline; toolbar shows grab/grabbing cursors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 37 +++++++++++++ src/lib/layout/LeafPane.css | 15 ++++++ src/lib/layout/LeafPane.tsx | 92 +++++++++++++++++++++++++++++++- src/lib/layout/orchestration.tsx | 8 +++ src/lib/layout/tree.ts | 22 ++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index de31fff..f5fc214 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { reshapeToPreset, flattenLayout, updateSplitRatio, + swapLeaves, serialize, deserialize, presetSingle, @@ -238,6 +239,32 @@ export default function App() { setNotifications((ns) => ns.filter((n) => n.id !== id)); }, []); + // ---- header-drag swap --------------------------------------------------- + const [dragSourceId, setDragSourceId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const beginHeaderDrag = useCallback((leafId: NodeId) => { + setDragSourceId(leafId); + setDragOverId(null); + }, []); + const setHeaderDragOver = useCallback((leafId: NodeId | null) => { + setDragOverId(leafId); + }, []); + const endHeaderDrag = useCallback( + (commitSwap: boolean) => { + if ( + commitSwap && + dragSourceId && + dragOverId && + dragSourceId !== dragOverId + ) { + setTree((t) => swapLeaves(t, dragSourceId, dragOverId)); + } + setDragSourceId(null); + setDragOverId(null); + }, + [dragSourceId, dragOverId], + ); + const orch = useMemo( () => ({ activeLeafId, @@ -251,6 +278,11 @@ export default function App() { registerPaneId, broadcastFrom, notify, + dragSourceId, + dragOverId, + beginHeaderDrag, + setHeaderDragOver, + endHeaderDrag, }), [ activeLeafId, @@ -264,6 +296,11 @@ export default function App() { registerPaneId, broadcastFrom, notify, + dragSourceId, + dragOverId, + beginHeaderDrag, + setHeaderDragOver, + endHeaderDrag, ], ); diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index f66b272..fede6ce 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -17,6 +17,21 @@ .leaf.active.broadcasting { border-color: #ffb840; } +.leaf.drag-source { + opacity: 0.4; +} +.leaf.drag-target { + outline: 3px dashed #5a8cd8; + outline-offset: -3px; +} + +/* Drag handle hint on the toolbar */ +.pane-toolbar { + cursor: grab; +} +.pane-toolbar:active { + cursor: grabbing; +} .pane-toolbar { flex: 0 0 auto; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index e8e1ffe..62a1996 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -5,6 +5,7 @@ import { useCallback, type KeyboardEvent, type MouseEvent, + type PointerEvent as ReactPointerEvent, } from "react"; import type { LeafNode } from "./tree"; import { useOrchestration } from "./orchestration"; @@ -143,17 +144,104 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { 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 ? ( void; broadcastFrom: (originLeafId: NodeId, dataB64: string) => void; notify: (message: string) => void; + + // Drag-header-to-swap. dragSourceId / dragOverId are reactive so leaves + // can apply hover/source styling. The lifecycle methods are stable. + dragSourceId: NodeId | null; + dragOverId: NodeId | null; + beginHeaderDrag: (leafId: NodeId) => void; + setHeaderDragOver: (leafId: NodeId | null) => void; + endHeaderDrag: (commitSwap: boolean) => void; } const OrchestrationContext = createContext(null); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index c05fdfb..761cf76 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -356,6 +356,28 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number) }); } +/** Swap two leaves' tree positions. Each leaf carries its own data + * (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay + * alive because React keys on leaf.id and our renderer is flat. */ +export function swapLeaves(root: TreeNode, idA: NodeId, idB: NodeId): TreeNode { + if (idA === idB) return root; + const a = findLeaf(root, idA); + const b = findLeaf(root, idB); + if (!a || !b) return root; + function walk(n: TreeNode): TreeNode { + if (n.kind === "leaf") { + if (n.id === idA) return b!; + if (n.id === idB) return a!; + return n; + } + const na = walk(n.a); + const nb = walk(n.b); + if (na === n.a && nb === n.b) return n; + return { ...n, a: na, b: nb }; + } + return walk(root); +} + export function serialize(root: TreeNode): string { return JSON.stringify(root); } From d9ddf526995447e516a04369267384764208b981 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 19:54:20 +0100 Subject: [PATCH 006/120] Replace idle toasts with pane border + titlebar badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old behaviour: every pane fired orch.notify("X is idle") after 5s of silence, stacking up to N toasts that took ages to dismiss. New behaviour: - LeafPane tracks its own isIdle state locally and reports up via orch.reportLeafIdle(leafId, idle). - App aggregates into a Set and renders "N idle" in red after the "N panes" count in the titlebar (hidden when zero). - The pane itself gets a red border (.leaf.idle) โ€” but active and broadcasting borders still take precedence, so the focus indicator isn't masked by idle status. - The pane's "alive" status text in the toolbar swaps to red "idle" while it's quiet (reverts to "alive" the moment output arrives). - Idle clears immediately on the next byte of output (no 1-second lag) AND when the pane unmounts (cleanup effect). No more flood of toasts. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.css | 3 +++ src/App.tsx | 21 ++++++++++++++++ src/lib/layout/LeafPane.css | 15 ++++++++++++ src/lib/layout/LeafPane.tsx | 42 ++++++++++++++++++++------------ src/lib/layout/orchestration.tsx | 5 ++++ 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/App.css b/src/App.css index 702340e..60dc3d9 100644 --- a/src/App.css +++ b/src/App.css @@ -70,6 +70,9 @@ color: #777; font-size: 11px; } +.layout-info .idle-info { + color: #d96060; +} .pane-wrap { flex: 1 1 auto; diff --git a/src/App.tsx b/src/App.tsx index f5fc214..51d0c7c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -239,6 +239,19 @@ export default function App() { setNotifications((ns) => ns.filter((n) => n.id !== id)); }, []); + // ---- per-pane idle aggregation (replaces toast spam) -------------------- + const [idleLeafIds, setIdleLeafIds] = useState>(() => new Set()); + const reportLeafIdle = useCallback((leafId: NodeId, idle: boolean) => { + setIdleLeafIds((prev) => { + if (idle && prev.has(leafId)) return prev; + if (!idle && !prev.has(leafId)) return prev; + const next = new Set(prev); + if (idle) next.add(leafId); + else next.delete(leafId); + return next; + }); + }, []); + // ---- header-drag swap --------------------------------------------------- const [dragSourceId, setDragSourceId] = useState(null); const [dragOverId, setDragOverId] = useState(null); @@ -283,6 +296,7 @@ export default function App() { beginHeaderDrag, setHeaderDragOver, endHeaderDrag, + reportLeafIdle, }), [ activeLeafId, @@ -301,6 +315,7 @@ export default function App() { beginHeaderDrag, setHeaderDragOver, endHeaderDrag, + reportLeafIdle, ], ); @@ -443,6 +458,12 @@ export default function App() { {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} + {idleLeafIds.size > 0 && ( + + {" ยท "} + {idleLeafIds.size} idle + + )} diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index fede6ce..390de89 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -17,6 +17,20 @@ .leaf.active.broadcasting { border-color: #ffb840; } +.leaf.idle { + border-color: #c84040; +} +/* active / broadcasting beats idle visually โ€” when you're focused on a + pane (active), the blue tells you "you're here"; idle is implied. */ +.leaf.active.idle { + border-color: #5a8cd8; +} +.leaf.broadcasting.idle { + border-color: #e09838; +} +.leaf.active.broadcasting.idle { + border-color: #ffb840; +} .leaf.drag-source { opacity: 0.4; } @@ -154,6 +168,7 @@ } .pane-status.ok { color: #6c6; } .pane-status.err { color: #d66; } +.pane-status.idle { color: #d96060; } .pane-actions { display: flex; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 62a1996..f98dfa6 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -79,27 +79,33 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }, [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 notifiedThisIdleRef = useRef(false); + const [isIdle, setIsIdle] = useState(false); const onDataReceived = useCallback(() => { lastDataTimeRef.current = Date.now(); - notifiedThisIdleRef.current = false; - }, []); + setIsIdle((cur) => { + if (cur) orch.reportLeafIdle(leaf.id, false); + return false; + }); + }, [orch.reportLeafIdle, leaf.id]); useEffect(() => { const id = window.setInterval(() => { - if (notifiedThisIdleRef.current) return; const dt = Date.now() - lastDataTimeRef.current; - if (dt >= IDLE_THRESHOLD_MS) { - notifiedThisIdleRef.current = true; - const name = leaf.label ?? leaf.distro ?? "pane"; - orch.notify(`${name} is idle`); - } + const nowIdle = dt >= IDLE_THRESHOLD_MS; + setIsIdle((cur) => { + if (cur === nowIdle) return cur; + orch.reportLeafIdle(leaf.id, nowIdle); + return nowIdle; + }); }, 1000); return () => clearInterval(id); - // Depend on the stable notify function, not the whole orch object. - // orch is recreated every time activeLeafId/distros change; depending - // on it would tear down and rebuild this interval on every click. - }, [leaf.label, leaf.distro, orch.notify]); + }, [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( @@ -229,7 +235,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return (
- {status} + {isIdle && statusOk ? ( + + idle + + ) : ( + {status} + )}