Phase 2: drag-/right-click-a-pane-to-new-window
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>
This commit is contained in:
parent
1a035ad0a6
commit
8ad51787fc
12 changed files with 797 additions and 48 deletions
|
|
@ -15,6 +15,8 @@ import {
|
|||
killPane,
|
||||
onPaneData,
|
||||
onPaneExit,
|
||||
getPaneRing,
|
||||
claimPane,
|
||||
type PaneId,
|
||||
type SpawnSpec,
|
||||
} from "../ipc";
|
||||
|
|
@ -50,6 +52,12 @@ interface XtermPaneProps {
|
|||
* changing it later does NOT respawn — callers force a respawn by
|
||||
* changing the React `key` (see Pane.svelte / LeafPane). */
|
||||
spec: SpawnSpec;
|
||||
/** Attach to an existing PTY (transferred from another window) instead of
|
||||
* spawning a new one. When set: spec is ignored at the spawn step, the
|
||||
* scrollback ring is replayed into xterm.js, the live data listener is
|
||||
* attached, and the transfer refcount is claimed (decremented) so the
|
||||
* source window's killPane is no longer suppressed. */
|
||||
existingPaneId?: PaneId;
|
||||
onStatus?: (msg: string, ok: boolean) => void;
|
||||
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
||||
onSpawn?: (paneId: PaneId) => void;
|
||||
|
|
@ -73,6 +81,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13;
|
|||
|
||||
export default function XtermPane({
|
||||
spec,
|
||||
existingPaneId,
|
||||
onStatus,
|
||||
onSpawn,
|
||||
onInput,
|
||||
|
|
@ -153,33 +162,78 @@ export default function XtermPane({
|
|||
const cols = term!.cols;
|
||||
const rows = term!.rows;
|
||||
|
||||
try {
|
||||
paneId = await spawnPane({ spec, cols, rows });
|
||||
if (destroyed) {
|
||||
void killPane(paneId);
|
||||
if (existingPaneId != null) {
|
||||
// Adoption path: a window-transfer landed us here with an existing
|
||||
// PTY id. Don't spawn — replay the scrollback ring first (so the
|
||||
// user sees recent output like a thinking Claude session), then
|
||||
// attach the live listener, resize the PTY to this window's grid,
|
||||
// and release the transfer-refcount.
|
||||
paneId = existingPaneId;
|
||||
paneIdRef.current = paneId;
|
||||
onStatusRef.current?.(`pane ${paneId} adopted`, true);
|
||||
onSpawnRef.current?.(paneId);
|
||||
try {
|
||||
const ringB64 = await getPaneRing(paneId);
|
||||
if (destroyed) return;
|
||||
if (ringB64) {
|
||||
term?.write(b64ToBytes(ringB64));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("getPaneRing failed:", e);
|
||||
}
|
||||
if (destroyed) return;
|
||||
unlistenData = await onPaneData(paneId, (b64) => {
|
||||
term?.write(b64ToBytes(b64));
|
||||
onDataReceivedRef.current?.();
|
||||
});
|
||||
if (destroyed) return;
|
||||
unlistenExit = await onPaneExit(paneId, () => {
|
||||
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
||||
});
|
||||
// Match the PTY to our cell grid (the source window may have had
|
||||
// different dimensions).
|
||||
try {
|
||||
await resizePane(paneId, cols, rows);
|
||||
} catch (e) {
|
||||
console.warn("resizePane on adopt failed:", e);
|
||||
}
|
||||
// Release the transfer refcount so future killPane calls on this
|
||||
// id are no longer suppressed.
|
||||
try {
|
||||
await claimPane(paneId);
|
||||
} catch (e) {
|
||||
console.warn("claimPane failed:", e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
paneId = await spawnPane({ spec, cols, rows });
|
||||
if (destroyed) {
|
||||
void killPane(paneId);
|
||||
return;
|
||||
}
|
||||
paneIdRef.current = paneId;
|
||||
onStatusRef.current?.(`pane ${paneId} alive`, true);
|
||||
onSpawnRef.current?.(paneId);
|
||||
} catch (e) {
|
||||
if (destroyed) return;
|
||||
const msg = `spawn_pane failed: ${e}`;
|
||||
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
||||
onStatusRef.current?.(msg, false);
|
||||
return;
|
||||
}
|
||||
paneIdRef.current = paneId;
|
||||
onStatusRef.current?.(`pane ${paneId} alive`, true);
|
||||
onSpawnRef.current?.(paneId);
|
||||
} catch (e) {
|
||||
if (destroyed) return;
|
||||
const msg = `spawn_pane failed: ${e}`;
|
||||
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
||||
onStatusRef.current?.(msg, false);
|
||||
return;
|
||||
|
||||
unlistenData = await onPaneData(paneId, (b64) => {
|
||||
term?.write(b64ToBytes(b64));
|
||||
onDataReceivedRef.current?.();
|
||||
});
|
||||
|
||||
unlistenExit = await onPaneExit(paneId, () => {
|
||||
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
||||
});
|
||||
}
|
||||
|
||||
unlistenData = await onPaneData(paneId, (b64) => {
|
||||
term?.write(b64ToBytes(b64));
|
||||
onDataReceivedRef.current?.();
|
||||
});
|
||||
|
||||
unlistenExit = await onPaneExit(paneId, () => {
|
||||
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
||||
});
|
||||
|
||||
term?.onData((data) => {
|
||||
if (paneId == null) return;
|
||||
const b64 = stringToB64(data);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue