diff --git a/README.md b/README.md index 6f06417..d6e09c9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab | | `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 | +**Multi-window** + +| Key | Action | +|---|---| +| `Right-click pane toolbar → Move to new window` | Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays) | + **Navigation** | Key | Action | diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dd21f6a..732ad35 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use tokio::sync::RwLock; use crate::creds; @@ -11,6 +11,7 @@ use crate::hosts::{self, SshHost, SshHostView}; use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer}; use crate::mcp_policy::McpPolicy; use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; +use crate::window_state::{PendingInit, PendingInits, WindowsState}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -62,6 +63,165 @@ pub async fn kill_pane( manager.kill(id).map_err(|e| e.to_string()) } +/// Bump the per-pane "do not kill during transfer" refcount. Called by the +/// source window just before removing the leaf from its tree (which triggers +/// React to unmount XtermPane, which calls `kill_pane`). The kill is then a +/// no-op until {@link claim_pane} drops the refcount. +#[tauri::command] +pub async fn mark_pane_transferring( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result<(), String> { + manager.mark_transferring(id); + Ok(()) +} + +/// Drop the transfer refcount one. Called by the target window's XtermPane +/// mount once it has subscribed to the pane's events and replayed the +/// scrollback ring — at which point the PTY is safely "owned" by the +/// target. +#[tauri::command] +pub async fn claim_pane( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result<(), String> { + manager.claim(id); + Ok(()) +} + +/// Return the per-pane scrollback ring snapshot as base64. The target +/// window's XtermPane writes it into xterm.js BEFORE attaching the live +/// pane://{id}/data listener, so the user sees recent output (covers +/// "Claude is in the middle of a thought" — a transferred pane that's +/// idle shouldn't look blank). Bounded by PANE_RING_CAPACITY (~256 KiB). +#[tauri::command] +pub async fn get_pane_ring( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result { + let ring = manager + .ring(id) + .ok_or_else(|| format!("no pane with id {id}"))?; + let (bytes, _seq) = ring.lock().snapshot(); + Ok(B64.encode(&bytes)) +} + +/// Spawn a new app window and stash the pending-init payload keyed by the +/// new window's label. The target window pulls it via +/// {@link take_pending_window_init} during App mount. +/// +/// Returns the new window's label so the caller can correlate. +#[tauri::command] +pub async fn create_pane_window( + app: AppHandle, + pendings: tauri::State<'_, Arc>, + payload: PendingInit, +) -> Result { + // Generate a label that's deterministic-but-unique. Tauri requires + // labels to be ASCII-alphanumeric + dashes/underscores. + let label = format!( + "pane-window-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0) + ); + + // Stash BEFORE building the window — the target may finish bootstrapping + // and call take_pending_window_init before we return from build(). + pendings.by_label.lock().insert(label.clone(), payload); + + // Position the new window offset from the source's outer rect so it + // doesn't land exactly on top. If we can't query the source, fall back + // to the OS-default (center). + let (px, py, w, h) = source_window_geometry(&app); + + let mut builder = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::App("index.html".into()), + ) + .title("tiletopia") + .inner_size(w, h) + .min_inner_size(480.0, 320.0) + .resizable(true) + .decorations(true) + .visible(true); + if let (Some(x), Some(y)) = (px, py) { + builder = builder.position(x + 60.0, y + 60.0); + } else { + builder = builder.center(); + } + if let Err(e) = builder.build() { + // Clean up our pending entry so we don't leak it. + pendings.by_label.lock().remove(&label); + return Err(format!("create webview window: {e}")); + } + + Ok(label) +} + +/// Read and remove the pending-init for the current window. Returns None +/// when there is no pending payload (main window startup; window opened +/// without a transfer; second call after the first consumed it). +#[tauri::command] +pub async fn take_pending_window_init( + pendings: tauri::State<'_, Arc>, + label: String, +) -> Result, String> { + Ok(pendings.by_label.lock().remove(&label)) +} + +/// Push this window's workspaces snapshot to the backend aggregator. Called +/// every time the React state changes (debounced inside Rust); the next +/// debounce tick writes the aggregated envelope to disk. +/// +/// `workspaces_json` is the per-window list as JSON (an array of +/// `{ id, name, tree }` objects — matches the frontend's envelope.workspaces +/// shape). Stored as serde Values so this module doesn't need to know +/// anything about the tree shape. +#[tauri::command] +pub async fn push_window_workspaces( + app: AppHandle, + state: tauri::State<'_, Arc>, + label: String, + workspaces_json: String, +) -> Result<(), String> { + let parsed: serde_json::Value = serde_json::from_str(&workspaces_json) + .map_err(|e| format!("invalid workspaces JSON: {e}"))?; + let arr = parsed + .as_array() + .ok_or_else(|| "workspaces JSON must be an array".to_string())?; + let owned = arr.to_vec(); + let state_arc: Arc = (*state).clone(); + state_arc.push(app, label, owned); + Ok(()) +} + +/// Best-effort: read outer position + inner size of the main window so the +/// new window opens nearby instead of slamming the OS default. Returns +/// (Some(x), Some(y), w, h) when available; falls back to a reasonable +/// default size when the main window query fails. +fn source_window_geometry(app: &AppHandle) -> (Option, Option, f64, f64) { + // Try the focused window first, then fall back to the main one. + let win = app + .webview_windows() + .into_iter() + .find_map(|(_, w)| if w.is_focused().unwrap_or(false) { Some(w) } else { None }) + .or_else(|| app.get_webview_window("main")); + let Some(win) = win else { + return (None, None, 1100.0, 700.0); + }; + let pos = win.outer_position().ok(); + let size = win.inner_size().ok(); + let scale = win.scale_factor().unwrap_or(1.0); + let w = size.as_ref().map(|s| s.width as f64 / scale).unwrap_or(1100.0); + let h = size.as_ref().map(|s| s.height as f64 / scale).unwrap_or(700.0); + let px = pos.as_ref().map(|p| p.x as f64 / scale); + let py = pos.as_ref().map(|p| p.y as f64 / scale); + (px, py, w, h) +} + /// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`. /// Writes to a `.tmp` and renames over the real file so a crash mid-write /// can't leave a partial file readable. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40ec343..51e3053 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,11 +6,13 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; +mod window_state; use std::sync::Arc; use crate::mcp::{McpServerHandle, McpState, PendingActions}; use crate::pty::PtyManager; +use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL}; pub fn run() { let _ = tracing_subscriber::fmt() @@ -40,6 +42,15 @@ pub fn run() { // Pending action registry — separate managed state so mcp_action_reply can // grab it without needing to lock McpState or reach into TileService. let pending_actions: Arc = Arc::new(PendingActions::default()); + // Cross-window workspace aggregator: every window pushes its tab list + // here; backend debounces + writes the merged envelope to workspace.json. + let windows_state: Arc = Arc::new(WindowsState::default()); + // Pane-transfer pending-init registry: source window stashes a payload + // keyed by the new window's label; target window pulls it during mount. + let pending_inits: Arc = Arc::new(PendingInits::default()); + + let windows_state_for_event = Arc::clone(&windows_state); + let pending_inits_for_event = Arc::clone(&pending_inits); tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) @@ -48,12 +59,34 @@ pub fn run() { .manage(mcp_state) .manage(McpServerHandle::default()) .manage(pending_actions) + .manage(windows_state) + .manage(pending_inits) + .on_window_event(move |window, event| { + // When a non-main window closes, drop its workspaces from the + // aggregator AND any unconsumed pending-init payload so neither + // resurrect on next launch. Matches Chrome-style "closing a + // detached window discards its tabs" intent. + if let tauri::WindowEvent::CloseRequested { .. } = event { + let label = window.label().to_string(); + if label != MAIN_WINDOW_LABEL { + pending_inits_for_event.by_label.lock().remove(&label); + windows_state_for_event + .forget(window.app_handle().clone(), &label); + } + } + }) .invoke_handler(tauri::generate_handler![ commands::list_distros, commands::spawn_pane, commands::write_to_pane, commands::resize_pane, commands::kill_pane, + commands::mark_pane_transferring, + commands::claim_pane, + commands::get_pane_ring, + commands::create_pane_window, + commands::take_pending_window_init, + commands::push_window_workspaces, commands::save_workspace, commands::load_workspace, commands::list_ssh_hosts, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 2f90930..c404fdf 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -109,6 +109,16 @@ struct PaneHandle { pub struct PtyManager { panes: Mutex>, next_id: AtomicU64, + /// Per-pane "this PTY is mid-transfer between windows; do not kill it + /// even if some window's XtermPane unmounts" refcount. Incremented by + /// {@link mark_transferring} when a transfer begins; decremented by + /// {@link claim} when the target window finishes mounting. While >0, + /// {@link kill} is a no-op for that id. + /// + /// Refcount (vs. plain flag) so concurrent transfers — or the rare + /// case where a transfer is retried before the previous one fully + /// releases — don't drop the suppression early. + transferring: Mutex>, } impl PtyManager { @@ -116,6 +126,27 @@ impl PtyManager { Self { panes: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + transferring: Mutex::new(HashMap::new()), + } + } + + /// Bump the transferring refcount for a pane. While >0, {@link kill} is + /// a no-op so the source window's React unmount-cleanup can't tear + /// down the PTY mid-transfer. + pub fn mark_transferring(&self, id: PaneId) { + *self.transferring.lock().entry(id).or_insert(0) += 1; + } + + /// Decrement the transferring refcount. When it reaches zero the entry + /// is removed and {@link kill} can act on this pane again. + pub fn claim(&self, id: PaneId) { + let mut map = self.transferring.lock(); + if let Some(rc) = map.get_mut(&id) { + if *rc > 1 { + *rc -= 1; + } else { + map.remove(&id); + } } } @@ -258,6 +289,14 @@ impl PtyManager { } pub fn kill(&self, id: PaneId) -> Result<()> { + // If a transfer is in flight for this pane, suppress the kill so + // the source window's unmount-cleanup can't race the target + // window's mount-claim. The target's claim() will decrement the + // refcount; the next caller of kill() (if any) will actually kill. + if self.transferring.lock().contains_key(&id) { + tracing::debug!("pty kill suppressed during transfer for pane {id}"); + return Ok(()); + } let mut panes = self.panes.lock(); if let Some(mut pane) = panes.remove(&id) { // Best-effort: ask the child to die. Dropping `master` after this diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs new file mode 100644 index 0000000..0529464 --- /dev/null +++ b/src-tauri/src/window_state.rs @@ -0,0 +1,153 @@ +//! Cross-window workspace state aggregator. +//! +//! Each window owns its own list of workspaces (tabs) in its React state. +//! When that list changes, the window calls `push_window_workspaces` to +//! ship a snapshot down here. This module merges every window's snapshot +//! into one envelope and persists it to `workspace.json` on a debounced +//! timer — same `{ version: 2, workspaces: [...] }` shape the frontend +//! reads at startup. +//! +//! The Rust side stays agnostic of the per-tree shape: workspaces are +//! stored as `serde_json::Value` so this module never needs to be updated +//! when LeafNode / SplitNode fields change. +//! +//! Lifetime of per-window entries: +//! - Created/updated on every `push_window_workspaces` call. +//! - The main window pushes initially after loading from disk; detached +//! windows push after takeing their pending-init payload. +//! - On detached-window close (handled in lib.rs), the entry is removed +//! so the next save doesn't resurrect tabs the user explicitly closed. +//! The main window's entry persists across the app lifetime. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +const WORKSPACE_FILE: &str = "workspace.json"; +const SAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +/// The label of the main (boot) window. Matches `tauri.conf.json`'s +/// `windows[0].label`. Used to decide whether a window-close should +/// retain or discard that window's tabs. +pub const MAIN_WINDOW_LABEL: &str = "main"; + +#[derive(Default)] +pub struct WindowsState { + per_window: Mutex>>, + save_task: Mutex>>, +} + +impl WindowsState { + /// Replace this window's workspaces snapshot and schedule a debounced + /// save. Subsequent calls within the debounce window cancel the + /// previous save task — so a flurry of UI mutations only writes once. + pub fn push( + self: &Arc, + app: AppHandle, + label: String, + workspaces: Vec, + ) { + self.per_window.lock().insert(label, workspaces); + self.schedule_save(app); + } + + /// Drop a window's snapshot from the aggregate. Called on close of a + /// non-main window so its tabs don't reappear on next launch. + pub fn forget(self: &Arc, app: AppHandle, label: &str) { + let removed = self.per_window.lock().remove(label).is_some(); + if removed { + self.schedule_save(app); + } + } + + /// Build the on-disk envelope by concatenating every window's + /// workspaces in stable label order (main first when present, then + /// the rest sorted alphabetically by label — deterministic so the + /// file diff stays stable across no-op saves). + fn build_envelope(&self) -> Value { + let map = self.per_window.lock(); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_by(|a, b| { + // main first, then alpha + match (a.as_str(), b.as_str()) { + (MAIN_WINDOW_LABEL, _) => std::cmp::Ordering::Less, + (_, MAIN_WINDOW_LABEL) => std::cmp::Ordering::Greater, + (x, y) => x.cmp(y), + } + }); + let mut workspaces: Vec = Vec::new(); + for k in keys { + if let Some(list) = map.get(k) { + for w in list { + workspaces.push(w.clone()); + } + } + } + serde_json::json!({ + "version": 2, + "workspaces": workspaces, + }) + } + + fn schedule_save(self: &Arc, app: AppHandle) { + let me = Arc::clone(self); + let mut slot = self.save_task.lock(); + if let Some(prev) = slot.take() { + prev.abort(); + } + let handle = tokio::spawn(async move { + sleep(SAVE_DEBOUNCE).await; + if let Err(e) = me.save_now(&app).await { + tracing::warn!("debounced workspace save failed: {e:#}"); + } + }); + *slot = Some(handle); + } + + async fn save_now(&self, app: &AppHandle) -> Result<()> { + let envelope = self.build_envelope(); + let json = serde_json::to_string(&envelope).context("serialize envelope")?; + let dir = app + .path() + .app_config_dir() + .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; + std::fs::create_dir_all(&dir).context("create_dir_all")?; + let path = dir.join(WORKSPACE_FILE); + let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp")); + std::fs::write(&tmp, json.as_bytes()).context("write tmp")?; + std::fs::rename(&tmp, &path).context("rename tmp -> final")?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Pane-transfer pending-init registry +// --------------------------------------------------------------------------- + +/// Payload the source window stashes in the backend before opening a new +/// window; the target window pulls it during App mount via +/// `take_pending_window_init`. +/// +/// `leaf_json` and `workspace_name` are owned by the source — the backend +/// doesn't parse the leaf shape. `pane_id` is the existing PTY id the +/// target window's XtermPane should attach to (instead of spawning). +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PendingInit { + #[serde(rename = "leafJson")] + pub leaf_json: String, + #[serde(rename = "paneId")] + pub pane_id: crate::pty::PaneId, + #[serde(rename = "workspaceName")] + pub workspace_name: String, +} + +#[derive(Default)] +pub struct PendingInits { + pub by_label: Mutex>, +} diff --git a/src/App.tsx b/src/App.tsx index f552e49..69a3113 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,11 @@ import { mcpPolicySave, writeToPane, killPane, + markPaneTransferring, + claimPane, + createPaneWindow, + takePendingWindowInit, + pushWindowWorkspaces, type PaneId, type SpawnSpec, type SshHost, @@ -29,6 +34,14 @@ import { type McpAuditEntry, } from "./ipc"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +const MAIN_WINDOW_LABEL = "main"; +/** Current window label, captured once at module load — used to decide + * load path (load_workspace vs take_pending_window_init) and to push + * this window's state to the cross-window aggregator. */ +const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label; +const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL; import { type TreeNode, type NodeId, @@ -83,7 +96,6 @@ import "./App.css"; import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; -const SAVE_DEBOUNCE_MS = 500; /** Picker default for *new* panes. SSH never lives here — SSH connections * are always explicit, never a default. */ @@ -220,6 +232,10 @@ export default function App() { // ---- non-reactive lookups ----------------------------------------------- const paneIdByLeafRef = useRef>(new Map()); const nextNotifIdRef = useRef(1); + /** Leaves that just arrived via a window transfer, mapped to the + * existing PaneId their XtermPane should adopt. One-shot: cleared in + * registerPaneId once the pane registers. */ + const transferredPaneIdsRef = useRef>(new Map()); const treeRef = useRef(tree); useEffect(() => { treeRef.current = tree; @@ -237,25 +253,62 @@ export default function App() { useEffect(() => { let cancelled = false; (async () => { - let loadedEnvelope: ReturnType = null; - try { - const json = await loadWorkspace(); - if (json) loadedEnvelope = deserializeWorkspaces(json); - } catch (e) { - console.warn("loadWorkspace failed:", e); - } - if (!loadedEnvelope) { + // First: is this a detached window with a pending transfer payload? + // Non-main windows ALWAYS go through this path (they never read + // workspace.json — only main owns it). A detached window with no + // pending init is the dev-reload / edge case; we boot with a blank + // default workspace. + let initialEnvelope: ReturnType = null; + let adoptedLeafId: NodeId | null = null; + + if (!IS_MAIN_WINDOW) { try { - const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacy) { - loadedEnvelope = deserializeWorkspaces(legacy); - if (loadedEnvelope) { - void saveWorkspace(serializeWorkspaces(loadedEnvelope)); + const pending = await takePendingWindowInit(CURRENT_WINDOW_LABEL); + if (pending) { + try { + const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode; + if (adoptedLeaf && adoptedLeaf.kind === "leaf") { + transferredPaneIdsRef.current.set(adoptedLeaf.id, pending.paneId); + adoptedLeafId = adoptedLeaf.id; + initialEnvelope = { + version: 2, + workspaces: [ + { + id: newId(), + name: pending.workspaceName || "Detached", + tree: adoptedLeaf, + }, + ], + }; + } + } catch (e) { + console.warn("invalid pending leafJson:", e); } - localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch (e) { - console.warn("legacy localStorage migration failed:", e); + console.warn("takePendingWindowInit failed:", e); + } + } else { + // Main window: load workspace.json (and legacy fallback). + try { + const json = await loadWorkspace(); + if (json) initialEnvelope = deserializeWorkspaces(json); + } catch (e) { + console.warn("loadWorkspace failed:", e); + } + if (!initialEnvelope) { + try { + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy) { + initialEnvelope = deserializeWorkspaces(legacy); + if (initialEnvelope) { + void saveWorkspace(serializeWorkspaces(initialEnvelope)); + } + localStorage.removeItem(LEGACY_STORAGE_KEY); + } + } catch (e) { + console.warn("legacy localStorage migration failed:", e); + } } } @@ -283,7 +336,7 @@ export default function App() { if (cancelled) return; - let envelope = loadedEnvelope; + let envelope = initialEnvelope; if (!envelope) { envelope = singletonEnvelope( newLeaf(defaultShellAsLeafProps(initialDefault)), @@ -296,6 +349,13 @@ export default function App() { } setWorkspaces(envelope.workspaces); setCurrentWorkspaceId(envelope.workspaces[0].id); + if (adoptedLeafId) { + setActiveLeafByWorkspace((prev) => { + const m = new Map(prev); + m.set(envelope!.workspaces[0].id, adoptedLeafId); + return m; + }); + } setDistros(resolvedDistros); setHosts(resolvedHosts); setDefaultShell(initialDefault); @@ -306,15 +366,16 @@ export default function App() { }; }, []); - // ---- debounced save ------------------------------------------------------ + // ---- workspace sync to backend aggregator ------------------------------- + // Every window pushes its own workspaces snapshot; the backend merges + // across windows and debounces the actual workspace.json write (500ms + // tokio sleep inside Rust). This replaces the v0.3.0 per-window + // saveWorkspace path which would race when two windows wrote at once. useEffect(() => { if (!ready) return; - const id = window.setTimeout(() => { - saveWorkspace( - serializeWorkspaces({ version: 2, workspaces }), - ).catch((e) => console.warn("saveWorkspace failed:", e)); - }, SAVE_DEBOUNCE_MS); - return () => clearTimeout(id); + pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch( + (e) => console.warn("pushWindowWorkspaces failed:", e), + ); }, [workspaces, ready]); // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- @@ -899,6 +960,9 @@ export default function App() { return; } paneIdByLeafRef.current.set(leafId, paneId); + // One-shot: now that the pane has registered, the transferred-id + // hint is consumed. + transferredPaneIdsRef.current.delete(leafId); const waiter = pendingPaneRegistrations.current.get(leafId); if (waiter) { pendingPaneRegistrations.current.delete(leafId); @@ -908,6 +972,71 @@ export default function App() { [], ); + const getInitialPaneIdFor = useCallback( + (leafId: NodeId): PaneId | undefined => + transferredPaneIdsRef.current.get(leafId), + [], + ); + + /** Pop the given leaf into a fresh top-level window. The source's + * XtermPane will unmount as the leaf leaves this window's tree; + * markPaneTransferring keeps the underlying PTY alive until the new + * window's XtermPane adopts it via existingPaneId. */ + const moveToNewWindow = useCallback( + async (leafId: NodeId) => { + const leaf = findLeaf(treeRef.current, leafId); + if (!leaf || leaf.kind !== "leaf") { + notify("Cannot move — pane not found"); + return; + } + const paneId = paneIdByLeafRef.current.get(leafId); + if (paneId == null) { + notify("Cannot move — PTY not ready yet"); + return; + } + + try { + await markPaneTransferring(paneId); + } catch (e) { + notify(`mark_pane_transferring failed: ${e}`); + return; + } + + // Snapshot the leaf BEFORE removing — closeLeaf may produce a tree + // where this leaf is no longer present, breaking findLeaf later. + const leafJson = JSON.stringify(leaf); + const workspaceName = leaf.label ?? `Pane ${paneId}`; + + // Remove from current tree (sibling promotes naturally via closeLeaf). + // If this leaf was the entire tree, fall back to a fresh default so + // the source workspace never becomes empty (matches close behavior). + setTree( + (t) => + closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)), + ); + paneIdByLeafRef.current.delete(leafId); + setActiveLeafByWorkspace((prev) => { + const wsId = currentWorkspaceIdRef.current; + if (!wsId) return prev; + if (prev.get(wsId) !== leafId) return prev; + const m = new Map(prev); + m.set(wsId, null); + return m; + }); + + try { + await createPaneWindow({ leafJson, paneId, workspaceName }); + } catch (e) { + notify(`Failed to open new window: ${e}`); + // The leaf is already gone from our tree and the PTY is orphaned + // in transferring state. Drop the refcount so a manual kill could + // eventually succeed; but the leaf no longer exists in any tree. + void claimPane(paneId).catch(() => {}); + } + }, + [defaultShell, notify], + ); + /** Insert a new leaf into the tree from a SpawnSpec — used by the MCP * spawn_pane and connect_host handlers. Returns the new leaf's id * (caller awaits waitForPaneRegistration on it for the paneId). @@ -1097,6 +1226,8 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, + moveToNewWindow, + getInitialPaneIdFor, }), [ activeLeafId, @@ -1119,6 +1250,8 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, + moveToNewWindow, + getInitialPaneIdFor, ], ); @@ -1126,11 +1259,18 @@ export default function App() { // Whenever the tree, hosts, or active selection change AND the MCP server // is running, push a fresh mirror down to the backend. Per-leaf mcpAllow // gates whether each leaf appears in the mirror (default-deny). + // + // Multi-window scoping: only the MAIN window pushes the mirror. Detached + // windows have their own current-workspace tree but Claude sees ONE + // workspace surface — main's current tab. Otherwise two windows would + // overwrite each other's mirrors on every keystroke and Claude's view + // would flap unpredictably. const allowedPaneCount = useMemo( () => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length, [tree], ); useEffect(() => { + if (!IS_MAIN_WINDOW) return; if (!mcpStatus.running) return; const leaves: Record = {}; for (const leaf of walkLeaves(tree)) { @@ -1590,6 +1730,10 @@ export default function App() { ); useEffect(() => { + // Only the main window handles MCP requests — paneIdByLeafRef is + // per-window so a request targeting a leaf in another window would + // fail anyway. Keeps responsibility clean: MCP sees main, period. + if (!IS_MAIN_WINDOW) return; let cancelled = false; let unlisten: (() => void) | undefined; void onMcpRequest(async (req: McpActionRequest) => { diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 3eee830..3b90015 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -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); diff --git a/src/ipc.ts b/src/ipc.ts index e1d48c8..6660ed8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -53,6 +53,53 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise => invoke("kill_pane", { id }); +/** Increment the "do not kill" transfer refcount for a pane. Source window + * calls this BEFORE removing the leaf from its tree so the unmount-driven + * kill_pane on the source becomes a no-op until the target window's + * XtermPane has claimed it. */ +export const markPaneTransferring = (id: PaneId): Promise => + invoke("mark_pane_transferring", { id }); + +/** Decrement the transfer refcount. Target window's XtermPane calls this + * after subscribing to pane://{id}/data and replaying the ring snapshot. */ +export const claimPane = (id: PaneId): Promise => + invoke("claim_pane", { id }); + +/** Snapshot of the per-pane scrollback ring as base64. Target window's + * XtermPane writes it into xterm.js before attaching the live data + * listener so a transferred pane doesn't open blank. */ +export const getPaneRing = (id: PaneId): Promise => + invoke("get_pane_ring", { id }); + +// ---- multi-window pane transfer ------------------------------------------- + +export interface PendingInit { + leafJson: string; + paneId: PaneId; + workspaceName: string; +} + +/** Open a new window and stash the pending-init payload keyed by the new + * window's label. Returns the new label. */ +export const createPaneWindow = (payload: PendingInit): Promise => + invoke("create_pane_window", { payload }); + +/** Read and remove the pending-init for the current window. Null when there + * is no pending payload (main window startup, or this call already + * consumed it). */ +export const takePendingWindowInit = ( + label: string, +): Promise => + invoke("take_pending_window_init", { label }); + +/** Push this window's workspaces snapshot to the backend aggregator. The + * backend debounces and writes the merged envelope to workspace.json. */ +export const pushWindowWorkspaces = ( + label: string, + workspacesJson: string, +): Promise => + invoke("push_window_workspaces", { label, workspacesJson }); + export const onPaneData = ( id: PaneId, cb: (b64: string) => void, diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 97e6074..f5ff85f 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -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; +} diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index d02f13f..ef1f8c2 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -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) => { + 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 ? ( )} + {menuPos && ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + +
+ )} ); } diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index 754c9b7..cd381ff 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -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(null); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 3e0941b..0222f14 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -45,6 +45,16 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" }, ], }, + { + title: "Multi-window", + items: [ + { + keys: "Right-click pane toolbar → Move to new window", + description: + "Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)", + }, + ], + }, { title: "Navigation", items: [