Right-click any pane's title bar → "Move to new window" pops it into a
fresh tiletopia window with its PTY intact. Same Tauri process; the
PtyManager is shared, so the existing PaneId stays valid and Tauri 2's
process-wide event routing keeps pane://{id}/data flowing into the new
window's XtermPane.
Mechanism (Rust-side, plan-agent's main correction over my draft):
- pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane
becomes a no-op while it's >0. Source window's React unmount calls
kill_pane → silently dropped while in flight; target window's
claim_pane decrements after it has subscribed.
- window_state.rs: per-window workspaces snapshot map +
debounced-by-tokio aggregate save. Each window pushes its tabs via
push_window_workspaces; backend writes the merged
{ version: 2, workspaces: [...] } envelope. Non-main windows have
their entries dropped on CloseRequested so closing a detached window
discards its tabs (Chrome-style).
- commands: mark_pane_transferring, claim_pane, get_pane_ring (base64
scrollback ring snapshot), create_pane_window, take_pending_window_init,
push_window_workspaces.
Frontend:
- XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring
snapshot via term.write before attaching the live data listener,
resize PTY to this window's grid, claim_pane. Scrollback replay was
the plan agent's other ship-in-v1 call — without it a transferred
Claude session looks blank until next prompt repaint.
- LeafPane: onContextMenu opens a fixed-positioned "Move to new
window" popover. Esc / outside-click dismiss.
- orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a
one-shot transferredPaneIdsRef cleared in registerPaneId.
- App mount branches on getCurrentWebviewWindow().label: main loads
workspace.json as before; non-main calls take_pending_window_init
and builds a singleton workspace around the adopted leaf.
- MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per-
window; Claude sees the main window's current tab as the single
workspace surface).
pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in
WSL; cargo build needs verification on Windows host before this is
runnable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
import {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useCallback,
|
||
type KeyboardEvent,
|
||
type MouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react";
|
||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||
import { useOrchestration } from "./orchestration";
|
||
import XtermPane from "../../components/XtermPane";
|
||
import type { SpawnSpec } from "../../ipc";
|
||
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],
|
||
);
|
||
|
||
// ---- shell-picker popover ----------------------------------------------
|
||
// Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
|
||
// hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
|
||
// (forces respawn).
|
||
const [shellMenuOpen, setShellMenuOpen] = useState(false);
|
||
const toggleShellMenu = useCallback((e: MouseEvent) => {
|
||
e.stopPropagation();
|
||
setShellMenuOpen((v) => !v);
|
||
}, []);
|
||
const pickShell = useCallback(
|
||
(spec: LeafShellSpec) => {
|
||
setShellMenuOpen(false);
|
||
// Only respawn if the spec is actually different from what's running.
|
||
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
|
||
return;
|
||
}
|
||
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
|
||
return;
|
||
}
|
||
if (
|
||
spec.shellKind === "ssh" &&
|
||
leaf.shellKind === "ssh" &&
|
||
spec.sshHostId === leaf.sshHostId
|
||
) {
|
||
return;
|
||
}
|
||
orch.setShell(leaf.id, spec);
|
||
},
|
||
[orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId],
|
||
);
|
||
const onManageHosts = useCallback(
|
||
(e: MouseEvent) => {
|
||
e.stopPropagation();
|
||
setShellMenuOpen(false);
|
||
orch.openHostManager();
|
||
},
|
||
[orch.openHostManager],
|
||
);
|
||
// Dismiss popover on outside click
|
||
useEffect(() => {
|
||
if (!shellMenuOpen) return;
|
||
const onDocClick = () => setShellMenuOpen(false);
|
||
window.addEventListener("click", onDocClick);
|
||
return () => window.removeEventListener("click", onDocClick);
|
||
}, [shellMenuOpen]);
|
||
|
||
// Label shown on the dropdown chip — tells the user what's currently
|
||
// running without expanding the menu.
|
||
const chipLabel =
|
||
leaf.shellKind === "powershell"
|
||
? "PowerShell"
|
||
: leaf.shellKind === "ssh"
|
||
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
|
||
: (leaf.distro ?? "(default)");
|
||
|
||
// ---- 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);
|
||
}, []);
|
||
|
||
// ---- right-click context menu ------------------------------------------
|
||
// Single entry in v1: "Move to new window" (pops the pane out into a
|
||
// fresh top-level tiletopia window without losing the PTY).
|
||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||
const openContextMenu = useCallback(
|
||
(e: MouseEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||
},
|
||
[],
|
||
);
|
||
const closeContextMenu = useCallback(() => setMenuPos(null), []);
|
||
useEffect(() => {
|
||
if (!menuPos) return;
|
||
const onDocClick = () => setMenuPos(null);
|
||
const onEsc = (e: globalThis.KeyboardEvent) => {
|
||
if (e.key === "Escape") setMenuPos(null);
|
||
};
|
||
// Defer attaching the click listener so the click that opened the menu
|
||
// doesn't immediately close it.
|
||
const t = window.setTimeout(() => {
|
||
window.addEventListener("click", onDocClick);
|
||
window.addEventListener("keydown", onEsc, true);
|
||
}, 0);
|
||
return () => {
|
||
clearTimeout(t);
|
||
window.removeEventListener("click", onDocClick);
|
||
window.removeEventListener("keydown", onEsc, true);
|
||
};
|
||
}, [menuPos]);
|
||
|
||
// ---- 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)";
|
||
|
||
// Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but
|
||
// the referenced host was deleted, we surface an error in the toolbar
|
||
// status instead of spawning an unrelated shell.
|
||
const spec: SpawnSpec | null = (() => {
|
||
if (leaf.shellKind === "wsl") {
|
||
return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd };
|
||
}
|
||
if (leaf.shellKind === "powershell") {
|
||
return { kind: "powershell" };
|
||
}
|
||
const host = orch.hosts.find((h) => h.id === leaf.sshHostId);
|
||
if (!host) return null;
|
||
return {
|
||
kind: "ssh",
|
||
host: host.hostname,
|
||
user: host.user,
|
||
port: host.port,
|
||
identityFile: host.identityFile,
|
||
jumpHost: host.jumpHost,
|
||
extraArgs: host.extraArgs,
|
||
hostId: host.id,
|
||
};
|
||
})();
|
||
|
||
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}
|
||
onContextMenu={openContextMenu}
|
||
>
|
||
{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={toggleShellMenu}
|
||
title="Change shell (respawns the pane)"
|
||
>
|
||
{chipLabel} ▾
|
||
</button>
|
||
{shellMenuOpen && (
|
||
<div
|
||
className="distro-menu shell-menu"
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{orch.distros.length > 0 && (
|
||
<>
|
||
<div className="shell-menu-header">WSL</div>
|
||
{orch.distros.map((d) => {
|
||
const active = leaf.shellKind === "wsl" && d === leaf.distro;
|
||
return (
|
||
<button
|
||
key={`wsl-${d}`}
|
||
className={`distro-menu-item${active ? " active" : ""}`}
|
||
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
|
||
>
|
||
{d}
|
||
</button>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
|
||
<div className="shell-menu-header">Windows</div>
|
||
<button
|
||
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
|
||
onClick={() => pickShell({ shellKind: "powershell" })}
|
||
>
|
||
PowerShell
|
||
</button>
|
||
|
||
<div className="shell-menu-header">SSH</div>
|
||
{orch.hosts.length === 0 ? (
|
||
<div className="shell-menu-empty">(no saved hosts)</div>
|
||
) : (
|
||
orch.hosts.map((h) => {
|
||
const active =
|
||
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
|
||
return (
|
||
<button
|
||
key={`ssh-${h.id}`}
|
||
className={`distro-menu-item${active ? " active" : ""}`}
|
||
onClick={() =>
|
||
pickShell({ shellKind: "ssh", sshHostId: h.id })
|
||
}
|
||
title={
|
||
h.user
|
||
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
|
||
: `${h.hostname}${h.port ? ":" + h.port : ""}`
|
||
}
|
||
>
|
||
{h.label || h.hostname}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
<button
|
||
className="distro-menu-item shell-menu-manage"
|
||
onClick={onManageHosts}
|
||
>
|
||
Manage hosts…
|
||
</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>
|
||
|
||
<button
|
||
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.toggleMcpAllow(leaf.id);
|
||
}}
|
||
title={
|
||
leaf.mcpAllow
|
||
? "MCP can see this pane — click to revoke"
|
||
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
|
||
}
|
||
aria-pressed={leaf.mcpAllow ? "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">
|
||
{spec ? (
|
||
<XtermPane
|
||
spec={spec}
|
||
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
|
||
onStatus={onStatus}
|
||
onSpawn={onPaneSpawned}
|
||
onInput={onTerminalInput}
|
||
onDataReceived={onDataReceived}
|
||
onFocus={onXtermFocus}
|
||
focusTrigger={focusTrigger}
|
||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||
/>
|
||
) : (
|
||
<div className="leaf-missing-host">
|
||
<p>SSH host not found</p>
|
||
<p className="hint">
|
||
Open the shell menu and pick another host, or add this host back
|
||
via Manage hosts….
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{menuPos && (
|
||
<div
|
||
className="pane-context-menu"
|
||
style={{
|
||
position: "fixed",
|
||
top: menuPos.y,
|
||
left: menuPos.x,
|
||
}}
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="pane-context-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
closeContextMenu();
|
||
orch.moveToNewWindow(leaf.id);
|
||
}}
|
||
>
|
||
Move to new window
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|