Migrate frontend from Svelte 5 to React 18
After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.
Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)
Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx
Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e9015b2790
commit
774b8633dc
32 changed files with 2087 additions and 1825 deletions
170
src/lib/layout/LeafPane.css
Normal file
170
src/lib/layout/LeafPane.css
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
.leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.leaf.active {
|
||||
border-color: #5a8cd8;
|
||||
}
|
||||
.leaf.broadcasting {
|
||||
border-color: #e09838;
|
||||
}
|
||||
.leaf.active.broadcasting {
|
||||
border-color: #ffb840;
|
||||
}
|
||||
|
||||
.pane-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 8px;
|
||||
background: #181818;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
user-select: none;
|
||||
min-height: 24px;
|
||||
}
|
||||
.pane-label {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
.pane-label:hover {
|
||||
background: #222;
|
||||
border-color: #2a2a2a;
|
||||
}
|
||||
.label-input {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #3a5a8c;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
outline: none;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.distro-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.distro-chip,
|
||||
.bcast-chip {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 10px;
|
||||
background: #222;
|
||||
color: #88c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-chip:hover,
|
||||
.bcast-chip:hover {
|
||||
background: #2a2a3a;
|
||||
color: #aac;
|
||||
}
|
||||
.bcast-chip {
|
||||
color: #777;
|
||||
background: #1c1c1c;
|
||||
border-color: #2a2a2a;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.bcast-chip.on {
|
||||
background: #4a3010;
|
||||
color: #f0c060;
|
||||
border-color: #c98a1f;
|
||||
}
|
||||
|
||||
.distro-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 2px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
.distro-menu-item {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-menu-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.distro-menu-item.active {
|
||||
background: #1a3a5c;
|
||||
color: #cce6ff;
|
||||
}
|
||||
|
||||
.pane-status {
|
||||
margin-left: auto;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
color: #777;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane-status.ok { color: #6c6; }
|
||||
.pane-status.err { color: #d66; }
|
||||
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.pane-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pane-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
.pane-btn.close:hover {
|
||||
background: #5a1a1a;
|
||||
color: #fcc;
|
||||
}
|
||||
.xterm-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import type { LeafNode } from "./tree";
|
||||
import { useOrchestration } from "./orchestration.svelte";
|
||||
import XtermPane from "../../components/XtermPane.svelte";
|
||||
|
||||
let {
|
||||
leaf,
|
||||
activeLeafId,
|
||||
}: {
|
||||
leaf: LeafNode;
|
||||
activeLeafId: string | null;
|
||||
} = $props();
|
||||
|
||||
const orch = useOrchestration();
|
||||
|
||||
|
||||
let status = $state("starting…");
|
||||
let statusOk = $state(true);
|
||||
|
||||
// ---- label editing -------------------------------------------------------
|
||||
let editingLabel = $state(false);
|
||||
let labelDraft = $state("");
|
||||
let labelInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
function startEditLabel(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
labelDraft = leaf.label ?? "";
|
||||
editingLabel = true;
|
||||
queueMicrotask(() => labelInputEl?.select());
|
||||
}
|
||||
|
||||
function commitLabel() {
|
||||
if (!editingLabel) return;
|
||||
orch.setLabel(leaf.id, labelDraft);
|
||||
editingLabel = false;
|
||||
}
|
||||
|
||||
function cancelLabel() {
|
||||
editingLabel = false;
|
||||
}
|
||||
|
||||
function onLabelKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitLabel();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelLabel();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- distro popover ------------------------------------------------------
|
||||
let distroOpen = $state(false);
|
||||
|
||||
function toggleDistroMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
distroOpen = !distroOpen;
|
||||
}
|
||||
|
||||
function pickDistro(d: string) {
|
||||
distroOpen = false;
|
||||
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!distroOpen) return;
|
||||
const onDocClick = () => (distroOpen = false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
});
|
||||
|
||||
// ---- idle detection ------------------------------------------------------
|
||||
const IDLE_THRESHOLD_MS = 5000;
|
||||
let lastDataTime = Date.now();
|
||||
let notifiedThisIdle = false;
|
||||
|
||||
function onDataReceived() {
|
||||
lastDataTime = Date.now();
|
||||
notifiedThisIdle = false;
|
||||
}
|
||||
|
||||
function checkIdle() {
|
||||
if (notifiedThisIdle) return;
|
||||
const sinceLast = Date.now() - lastDataTime;
|
||||
if (sinceLast >= IDLE_THRESHOLD_MS) {
|
||||
notifiedThisIdle = true;
|
||||
const name = leaf.label ?? leaf.distro ?? "pane";
|
||||
console.log("[tiletopia] notifying idle:", leaf.id, "quietForMs:", sinceLast);
|
||||
orch.notify(`${name} is idle`);
|
||||
}
|
||||
}
|
||||
|
||||
const idleTimer = window.setInterval(checkIdle, 1000);
|
||||
onDestroy(() => clearInterval(idleTimer));
|
||||
|
||||
// ---- broadcast -----------------------------------------------------------
|
||||
function onTerminalInput(b64: string) {
|
||||
if (leaf.broadcast) orch.broadcastFrom(leaf.id, b64);
|
||||
}
|
||||
|
||||
// ---- focus / active ------------------------------------------------------
|
||||
let focusTrigger = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (activeLeafId === leaf.id) focusTrigger += 1;
|
||||
});
|
||||
|
||||
// Backup setActive for toolbar clicks (which can't reach the document
|
||||
// capture listener if a bubble-phase handler stops propagation). Cheap;
|
||||
// idempotent if the document listener also fired.
|
||||
function onPaneClick() {
|
||||
orch.setActive(leaf.id);
|
||||
}
|
||||
|
||||
// ---- pane id registration ------------------------------------------------
|
||||
function onPaneSpawned(paneId: number) {
|
||||
orch.registerPaneId(leaf.id, paneId);
|
||||
}
|
||||
onDestroy(() => orch.registerPaneId(leaf.id, null));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="leaf"
|
||||
class:active={activeLeafId === leaf.id}
|
||||
data-debug-active={activeLeafId === leaf.id ? "yes" : "no"}
|
||||
data-debug-prop={(activeLeafId ?? "null").slice(0, 8)}
|
||||
class:broadcasting={leaf.broadcast}
|
||||
role="group"
|
||||
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
|
||||
data-leaf-id={leaf.id}
|
||||
onpointerdown={onPaneClick}
|
||||
>
|
||||
<div class="pane-toolbar">
|
||||
{#if editingLabel}
|
||||
<input
|
||||
class="label-input"
|
||||
bind:this={labelInputEl}
|
||||
bind:value={labelDraft}
|
||||
onkeydown={onLabelKey}
|
||||
onblur={commitLabel}
|
||||
placeholder="(label)"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-label"
|
||||
onclick={startEditLabel}
|
||||
title="Click to rename pane"
|
||||
>
|
||||
{leaf.label ?? "(unnamed)"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="distro-wrap">
|
||||
<button
|
||||
class="distro-chip"
|
||||
onclick={toggleDistroMenu}
|
||||
title="Change distro (respawns the pane)"
|
||||
>
|
||||
{leaf.distro ?? "(default)"} ▾
|
||||
</button>
|
||||
{#if distroOpen}
|
||||
<div
|
||||
class="distro-menu"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
{#each orch.distros as d}
|
||||
<button
|
||||
class="distro-menu-item"
|
||||
class:active={d === leaf.distro}
|
||||
onclick={() => pickDistro(d)}
|
||||
>{d}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="bcast-chip"
|
||||
class:on={leaf.broadcast}
|
||||
onclick={(e) => { e.stopPropagation(); orch.toggleBroadcast(leaf.id); }}
|
||||
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
|
||||
aria-pressed={leaf.broadcast ? "true" : "false"}
|
||||
>📡</button>
|
||||
|
||||
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||
|
||||
<span class="pane-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split right"
|
||||
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "h"); }}
|
||||
aria-label="Split right"
|
||||
>⇥</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split down"
|
||||
onclick={(e) => { e.stopPropagation(); orch.split(leaf.id, "v"); }}
|
||||
aria-label="Split down"
|
||||
>⇣</button>
|
||||
<button
|
||||
class="pane-btn close"
|
||||
title="Close pane"
|
||||
onclick={(e) => { e.stopPropagation(); orch.close(leaf.id); }}
|
||||
aria-label="Close pane"
|
||||
>×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xterm-wrap">
|
||||
<XtermPane
|
||||
distro={leaf.distro}
|
||||
cwd={leaf.cwd}
|
||||
onStatus={(msg, ok) => {
|
||||
status = msg;
|
||||
statusOk = ok;
|
||||
}}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
onFocus={() => orch.setActive(leaf.id)}
|
||||
{onDataReceived}
|
||||
{focusTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.leaf.active {
|
||||
border-color: #5a8cd8;
|
||||
}
|
||||
.leaf.broadcasting {
|
||||
border-color: #e09838;
|
||||
}
|
||||
.leaf.active.broadcasting {
|
||||
border-color: #ffb840;
|
||||
}
|
||||
|
||||
.pane-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 8px;
|
||||
background: #181818;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
user-select: none;
|
||||
min-height: 24px;
|
||||
}
|
||||
.pane-label {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
.pane-label:hover {
|
||||
background: #222;
|
||||
border-color: #2a2a2a;
|
||||
}
|
||||
.label-input {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #3a5a8c;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
outline: none;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.distro-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.distro-chip,
|
||||
.bcast-chip {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 10px;
|
||||
background: #222;
|
||||
color: #88c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-chip:hover,
|
||||
.bcast-chip:hover {
|
||||
background: #2a2a3a;
|
||||
color: #aac;
|
||||
}
|
||||
.bcast-chip {
|
||||
color: #777;
|
||||
background: #1c1c1c;
|
||||
border-color: #2a2a2a;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.bcast-chip.on {
|
||||
background: #4a3010;
|
||||
color: #f0c060;
|
||||
border-color: #c98a1f;
|
||||
}
|
||||
|
||||
.distro-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 2px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
.distro-menu-item {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-menu-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.distro-menu-item.active {
|
||||
background: #1a3a5c;
|
||||
color: #cce6ff;
|
||||
}
|
||||
|
||||
.pane-status {
|
||||
margin-left: auto;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
color: #777;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane-status.ok { color: #6c6; }
|
||||
.pane-status.err { color: #d66; }
|
||||
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.pane-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pane-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
.pane-btn.close:hover {
|
||||
background: #5a1a1a;
|
||||
color: #fcc;
|
||||
}
|
||||
.xterm-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
263
src/lib/layout/LeafPane.tsx
Normal file
263
src/lib/layout/LeafPane.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import type { LeafNode } 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, 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, 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 ----------------------------------------------------
|
||||
const lastDataTimeRef = useRef(Date.now());
|
||||
const notifiedThisIdleRef = useRef(false);
|
||||
const onDataReceived = useCallback(() => {
|
||||
lastDataTimeRef.current = Date.now();
|
||||
notifiedThisIdleRef.current = false;
|
||||
}, []);
|
||||
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`);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [leaf.label, leaf.distro, orch]);
|
||||
|
||||
// ---- broadcast ---------------------------------------------------------
|
||||
const onTerminalInput = useCallback(
|
||||
(b64: string) => {
|
||||
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
|
||||
},
|
||||
[isBroadcasting, orch, 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, leaf.id]);
|
||||
|
||||
const onPaneSpawned = useCallback(
|
||||
(paneId: number) => {
|
||||
orch.registerPaneId(leaf.id, paneId);
|
||||
},
|
||||
[orch, leaf.id],
|
||||
);
|
||||
// Unregister on unmount
|
||||
useEffect(() => {
|
||||
return () => orch.registerPaneId(leaf.id, null);
|
||||
}, [orch, leaf.id]);
|
||||
|
||||
const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]);
|
||||
|
||||
const onStatus = useCallback((msg: string, ok: boolean) => {
|
||||
setStatus(msg);
|
||||
setStatusOk(ok);
|
||||
}, []);
|
||||
|
||||
const labelText = leaf.label ?? "(unnamed)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
|
||||
role="group"
|
||||
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||||
data-leaf-id={leaf.id}
|
||||
onPointerDown={onPaneClick}
|
||||
>
|
||||
<div className="pane-toolbar">
|
||||
{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 to leave group)"
|
||||
: "Click to broadcast input to other broadcast panes"
|
||||
}
|
||||
aria-pressed={isBroadcasting ? "true" : "false"}
|
||||
>
|
||||
📡
|
||||
</button>
|
||||
|
||||
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
||||
|
||||
<span className="pane-actions">
|
||||
<button
|
||||
className="pane-btn"
|
||||
title="Split right"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.split(leaf.id, "h");
|
||||
}}
|
||||
aria-label="Split right"
|
||||
>
|
||||
⇥
|
||||
</button>
|
||||
<button
|
||||
className="pane-btn"
|
||||
title="Split down"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.split(leaf.id, "v");
|
||||
}}
|
||||
aria-label="Split down"
|
||||
>
|
||||
⇣
|
||||
</button>
|
||||
<button
|
||||
className="pane-btn close"
|
||||
title="Close pane"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { TreeNode, NodeId } from "./tree";
|
||||
import SplitNode from "./SplitNode.svelte";
|
||||
import LeafPane from "./LeafPane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
activeLeafId,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
activeLeafId: NodeId | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if node.kind === "split"}
|
||||
<SplitNode {node} {activeLeafId} />
|
||||
{:else}
|
||||
<LeafPane leaf={node} {activeLeafId} />
|
||||
{/if}
|
||||
16
src/lib/layout/Pane.tsx
Normal file
16
src/lib/layout/Pane.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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 <SplitNode node={node} />;
|
||||
}
|
||||
return <LeafPane key={node.id} leaf={node} />;
|
||||
}
|
||||
36
src/lib/layout/SplitNode.css
Normal file
36
src/lib/layout/SplitNode.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { SplitNode, NodeId } from "./tree";
|
||||
import Pane from "./Pane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
activeLeafId,
|
||||
}: {
|
||||
node: SplitNode;
|
||||
activeLeafId: NodeId | null;
|
||||
} = $props();
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let dragging = $state(false);
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragging = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// rAF-throttle the DOM flex update so we don't spam SIGWINCH to PTYs.
|
||||
let pendingRaf: number | null = null;
|
||||
let pendingRatio = 0;
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging || !containerEl) return;
|
||||
const rect = containerEl.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));
|
||||
node.ratio = r;
|
||||
pendingRatio = r;
|
||||
if (pendingRaf == null) {
|
||||
pendingRaf = requestAnimationFrame(() => {
|
||||
pendingRaf = null;
|
||||
const sides = containerEl.querySelectorAll(":scope > .side");
|
||||
if (sides[0]) (sides[0] as HTMLElement).style.flex = String(pendingRatio);
|
||||
if (sides[1]) (sides[1] as HTMLElement).style.flex = String(1 - pendingRatio);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
dragging = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="split"
|
||||
class:horizontal={node.orientation === "h"}
|
||||
class:vertical={node.orientation === "v"}
|
||||
bind:this={containerEl}
|
||||
>
|
||||
<div class="side" style="flex: {node.ratio}">
|
||||
<Pane node={node.a} {activeLeafId} />
|
||||
</div>
|
||||
<div
|
||||
class="gutter"
|
||||
class:active={dragging}
|
||||
role="separator"
|
||||
aria-orientation={node.orientation === "h" ? "vertical" : "horizontal"}
|
||||
aria-valuenow={Math.round(node.ratio * 100)}
|
||||
tabindex="-1"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
></div>
|
||||
<div class="side" style="flex: {1 - node.ratio}">
|
||||
<Pane node={node.b} {activeLeafId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
79
src/lib/layout/SplitNode.tsx
Normal file
79
src/lib/layout/SplitNode.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
setDragging(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const isH = node.orientation === "h";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`split ${isH ? "horizontal" : "vertical"}`}
|
||||
>
|
||||
<div className="side" style={{ flex: ratio }}>
|
||||
<Pane node={node.a} />
|
||||
</div>
|
||||
<div
|
||||
className={`gutter${dragging ? " active" : ""}`}
|
||||
role="separator"
|
||||
aria-orientation={isH ? "vertical" : "horizontal"}
|
||||
aria-valuenow={Math.round(ratio * 100)}
|
||||
tabIndex={-1}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
/>
|
||||
<div className="side" style={{ flex: 1 - ratio }}>
|
||||
<Pane node={node.b} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* Orchestration store — all shared reactive state + operations the
|
||||
* pane tree needs. Lives in a class with `$state` fields so Svelte 5
|
||||
* reactivity tracks per-property access; provided via context so any
|
||||
* descendant component can `useOrchestration()` without prop drilling.
|
||||
*
|
||||
* (File must be `.svelte.ts` because `$state` can only be used in
|
||||
* Svelte components or files with the `.svelte.{js,ts}` extension.)
|
||||
*/
|
||||
|
||||
import { setContext, getContext } from "svelte";
|
||||
import type { NodeId, Orientation } from "./tree";
|
||||
import type { PaneId } from "../../ipc";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks App provides at construction time. These do tree mutations
|
||||
* (which require access to App's `tree = $state(...)`) plus broadcast
|
||||
* routing (which also needs the tree). Kept as an injection seam rather
|
||||
* than living inside the store so the store doesn't need to own the tree.
|
||||
*/
|
||||
export interface TreeOps {
|
||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||
close: (leafId: NodeId) => void;
|
||||
setDistro: (leafId: NodeId, distro: string) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
toggleBroadcast: (leafId: NodeId) => void;
|
||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||
}
|
||||
|
||||
export class Orchestration {
|
||||
// ---- shared reactive state ----------------------------------------------
|
||||
// (activeLeafId lives at App level and is drilled as a prop — Svelte 5
|
||||
// doesn't seem to track class-field $state reads from child components
|
||||
// that obtain the instance via getContext. Tested empirically.)
|
||||
notifications = $state<Toast[]>([]);
|
||||
distros = $state<string[]>([]);
|
||||
|
||||
// ---- non-reactive lookups -----------------------------------------------
|
||||
// Plain Map: broadcast routing reads this from an event handler, not
|
||||
// from reactive context. No need for $state.
|
||||
paneIdByLeaf = new Map<NodeId, PaneId>();
|
||||
|
||||
// ---- internal -----------------------------------------------------------
|
||||
#nextNotifId = 1;
|
||||
#dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
#ops: TreeOps;
|
||||
|
||||
constructor(ops: TreeOps) {
|
||||
this.#ops = ops;
|
||||
}
|
||||
|
||||
// ---- active pane (delegated to App) -------------------------------------
|
||||
// These point at App-level $state mutators set via configure().
|
||||
setActive: (leafId: NodeId) => void = () => {};
|
||||
clearActiveIf: (leafId: NodeId) => void = () => {};
|
||||
|
||||
configureActiveHandlers(
|
||||
setActive: (leafId: NodeId) => void,
|
||||
clearActiveIf: (leafId: NodeId) => void,
|
||||
): void {
|
||||
this.setActive = setActive;
|
||||
this.clearActiveIf = clearActiveIf;
|
||||
}
|
||||
|
||||
// ---- notifications ------------------------------------------------------
|
||||
notify(message: string): void {
|
||||
const id = this.#nextNotifId++;
|
||||
console.log("[orch] notify", message);
|
||||
this.notifications.push({ id, message });
|
||||
const timer = setTimeout(() => {
|
||||
this.notifications = this.notifications.filter((n) => n.id !== id);
|
||||
this.#dismissTimers.delete(id);
|
||||
}, 5000);
|
||||
this.#dismissTimers.set(id, timer);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
const t = this.#dismissTimers.get(id);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
this.#dismissTimers.delete(id);
|
||||
}
|
||||
this.notifications = this.notifications.filter((n) => n.id !== id);
|
||||
}
|
||||
|
||||
// ---- pane id registry ---------------------------------------------------
|
||||
registerPaneId(leafId: NodeId, paneId: PaneId | null): void {
|
||||
if (paneId == null) this.paneIdByLeaf.delete(leafId);
|
||||
else this.paneIdByLeaf.set(leafId, paneId);
|
||||
}
|
||||
|
||||
// ---- delegated tree ops -------------------------------------------------
|
||||
// Thin pass-through so consumers only need one object.
|
||||
split(leafId: NodeId, orientation: Orientation): void {
|
||||
console.log("[orch] split", leafId, orientation);
|
||||
this.#ops.split(leafId, orientation);
|
||||
}
|
||||
|
||||
close(leafId: NodeId): void {
|
||||
console.log("[orch] close", leafId);
|
||||
this.#ops.close(leafId);
|
||||
}
|
||||
|
||||
setDistro(leafId: NodeId, distro: string): void {
|
||||
this.#ops.setDistro(leafId, distro);
|
||||
}
|
||||
|
||||
setLabel(leafId: NodeId, label: string | undefined): void {
|
||||
this.#ops.setLabel(leafId, label);
|
||||
}
|
||||
|
||||
toggleBroadcast(leafId: NodeId): void {
|
||||
console.log("[orch] toggleBroadcast", leafId);
|
||||
this.#ops.toggleBroadcast(leafId);
|
||||
}
|
||||
|
||||
broadcastFrom(originLeafId: NodeId, dataB64: string): void {
|
||||
this.#ops.broadcastFrom(originLeafId, dataB64);
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = Symbol("tiletopia.orchestration");
|
||||
|
||||
export function provideOrchestration(ops: TreeOps): Orchestration {
|
||||
const o = new Orchestration(ops);
|
||||
setContext(KEY, o);
|
||||
return o;
|
||||
}
|
||||
|
||||
export function useOrchestration(): Orchestration {
|
||||
const o = getContext<Orchestration | undefined>(KEY);
|
||||
if (!o) {
|
||||
throw new Error(
|
||||
"useOrchestration() called outside a provideOrchestration() ancestor",
|
||||
);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
58
src/lib/layout/orchestration.tsx
Normal file
58
src/lib/layout/orchestration.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Orientation, NodeId } from "./tree";
|
||||
import type { PaneId } from "../../ipc";
|
||||
|
||||
/**
|
||||
* Orchestration context — every piece of shared state and every operation
|
||||
* that a Pane / SplitNode / LeafPane might call. Lives in React context so
|
||||
* descendants can `useOrchestration()` without prop drilling.
|
||||
*
|
||||
* activeLeafId comes in as a plain value (re-derived by App's useState).
|
||||
* React's context is reactive: when the App-level Provider updates the
|
||||
* value, ALL consumers re-render. No Svelte-style props-don't-propagate
|
||||
* trap here.
|
||||
*/
|
||||
export interface Orchestration {
|
||||
// Read-only state
|
||||
activeLeafId: NodeId | null;
|
||||
distros: string[];
|
||||
|
||||
// Tree mutations
|
||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||
close: (leafId: NodeId) => void;
|
||||
setDistro: (leafId: NodeId, distro: string) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
toggleBroadcast: (leafId: NodeId) => void;
|
||||
|
||||
// Per-pane orchestration
|
||||
setActive: (leafId: NodeId) => void;
|
||||
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||
notify: (message: string) => void;
|
||||
}
|
||||
|
||||
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||
|
||||
export function OrchestrationProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: Orchestration;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<OrchestrationContext.Provider value={value}>
|
||||
{children}
|
||||
</OrchestrationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrchestration(): Orchestration {
|
||||
const orch = useContext(OrchestrationContext);
|
||||
if (!orch) {
|
||||
throw new Error(
|
||||
"useOrchestration() must be called inside <OrchestrationProvider>",
|
||||
);
|
||||
}
|
||||
return orch;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue