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
|
|
@ -269,3 +269,36 @@
|
|||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Right-click context menu on the pane toolbar. Fixed-positioned popover
|
||||
floating in the viewport; the LeafPane parent renders it inside its
|
||||
own DOM tree so clicks within the menu still get the
|
||||
stop-propagation chain. */
|
||||
.pane-context-menu {
|
||||
z-index: 200;
|
||||
min-width: 180px;
|
||||
background: #1a1a1a;
|
||||
color: #e6e6e6;
|
||||
border: 1px solid #2a5a8c;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.pane-context-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #e6e6e6;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pane-context-menu-item:hover {
|
||||
background: #2a5a8c;
|
||||
color: #fff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,38 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
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
|
||||
|
|
@ -306,6 +338,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
onPointerMove={onToolbarPointerMove}
|
||||
onPointerUp={onToolbarPointerUp}
|
||||
onPointerCancel={onToolbarPointerCancel}
|
||||
onContextMenu={openContextMenu}
|
||||
>
|
||||
{editingLabel ? (
|
||||
<input
|
||||
|
|
@ -482,6 +515,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
{spec ? (
|
||||
<XtermPane
|
||||
spec={spec}
|
||||
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
|
||||
onStatus={onStatus}
|
||||
onSpawn={onPaneSpawned}
|
||||
onInput={onTerminalInput}
|
||||
|
|
@ -500,6 +534,31 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,17 @@ export interface Orchestration {
|
|||
// own quiet-state crosses the threshold; App aggregates so the titlebar
|
||||
// can show an "N idle" count without spamming toast notifications.
|
||||
reportLeafIdle: (leafId: NodeId, idle: boolean) => void;
|
||||
|
||||
// Multi-window pane transfer ---------------------------------------------
|
||||
/** Pop a pane out of the current workspace into a fresh top-level window.
|
||||
* The PTY stays alive across the move (the new window's XtermPane
|
||||
* adopts the existing PaneId; scrollback ring is replayed). */
|
||||
moveToNewWindow: (leafId: NodeId) => void;
|
||||
/** Returns a PaneId only for leaves that just arrived via a window
|
||||
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
|
||||
* the spawn). One-shot — App clears the entry once the pane has
|
||||
* registered. */
|
||||
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
|
||||
}
|
||||
|
||||
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue