diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index bcff558c..37a5b224 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -101,6 +101,14 @@ pub struct SessionTab { /// model does not permit. #[serde(default, skip_serializing_if = "Option::is_none")] pub sidebar_group: Option, + /// The tab's identity in the daemon's machine tree, when this session was + /// derived *from* that tree — so a window rebuilt from it addresses the + /// daemon's tabs rather than minting new ids and churning them. **Never + /// persisted**: the tree is the authority on its own ids, and a stale one + /// written to disk would collide with a tab the daemon has since reused it + /// for. `None` (every other source) mints a fresh id. + #[serde(skip)] + pub tree_id: Option, } /// One workspace's contents: the open tabs and which one was active. @@ -978,6 +986,7 @@ mod tests { tabs: vec![ SessionTab { name: Some("build".into()), + tree_id: None, sidebar_group: None, pane: SessionPane::Leaf { cwd: Some(PathBuf::from("/work")), @@ -990,6 +999,7 @@ mod tests { }, SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: SessionPane::Split { axis: SessionAxis::Vertical, @@ -1128,6 +1138,7 @@ mod tests { active: 0, tabs: vec![SessionTab { name: Some("main".into()), + tree_id: None, sidebar_group: None, pane: SessionPane::Leaf { cwd: Some(PathBuf::from("/home/u")), @@ -1165,6 +1176,7 @@ mod tests { fn tab(pane: SessionPane, group: Option<&str>) -> SessionTab { SessionTab { name: None, + tree_id: None, sidebar_group: group.map(PathBuf::from), pane, } diff --git a/src/core/session.rs b/src/core/session.rs index e50212ed..45cece41 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -541,6 +541,7 @@ mod tests { Session { tabs: vec![SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf("/Users/me/work"), }], diff --git a/src/ui/app.rs b/src/ui/app.rs index 245bb2c3..f438de84 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -907,6 +907,17 @@ impl Tty7App { let restore = cx.global::().restore_session; let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some()); let (workspace, saved) = WorkspaceStore::claim(cx, id); + // A local workspace's layout now lives in the daemon's machine tree, + // so a restore *asks* rather than reads: the window opens empty and + // `hydrate_window_from_tree` rebuilds it the moment the pull answers — + // against the local daemon that is milliseconds, so the empty state is + // effectively one frame. A remote workspace already worked this way + // (its machine has to answer first); it keeps its existing connect- + // driven rebuild for now. + let is_remote = WorkspaceStore::all(cx) + .get(workspace) + .is_some_and(|w| w.is_remote()); + let hydrate = known && restore && !is_remote; // A workspace that was already on file restores its tab/split layout and // each pane's cwd, unless the user turned restore off — then it starts // fresh. A *brand-new* one has no tabs to restore, so what it comes up @@ -916,16 +927,25 @@ impl Tty7App { // through instead lands on the home page, for the launch that exists to // show the workspace picker. let session = match (known, fresh) { + (true, _) if hydrate => Some(Session::default()), (true, _) => restore.then_some(saved), (false, crate::ui::windows::FreshStart::Shell) => None, (false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()), }; let app = Self::with_session(Some(workspace), session, window, cx); - // Persist right away. The leaves just spawned (or reattached) now carry - // daemon pane ids, and nothing else writes them until the next - // *structural* change — so a crash before the user happens to open a - // tab would strand every one of those panes in the daemon. - app.save_session(cx); + if hydrate { + // No immediate save: the window is deliberately empty, and + // recording that would both clobber the cached layout the + // hydration may fall back to and race the pull with a diff that + // reads as "close everything". + crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace); + } else { + // Persist right away. The leaves just spawned (or reattached) now + // carry daemon pane ids, and nothing else writes them until the + // next *structural* change — so a crash before the user happens to + // open a tab would strand every one of those panes in the daemon. + app.save_session(cx); + } // If startup reused a daemon that speaks a different wire protocol // (an app upgrade while the old service kept running), the sessions // just restored above are living on that old dialect. Surface the @@ -7152,6 +7172,10 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { name: tab.name.clone(), pane: pane_to_session(&tab.pane, cx), sidebar_group: tab.sidebar_group.borrow().clone(), + // Deliberately not the live tab's tree id. This snapshot outlives the + // daemon tab it mirrors (the closed-tab stack, the session file), and + // rebuilding from it is a *new* tab everywhere it matters. + tree_id: None, } } @@ -7355,7 +7379,13 @@ fn tabs_from_session( // renders grouped on the first frame; the first landed probe // corrects it if the tab's repo changed while we were gone. sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), - tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), + // A session lowered from the machine's tree names its daemon tabs; + // keeping those ids is what stops the first save from closing and + // recreating every one of them. + tree_id: std::cell::Cell::new( + st.tree_id + .unwrap_or_else(tty7_core::core::machine::TabId::new), + ), }); } // Clamp the saved active index into the rebuilt range (which can be empty diff --git a/src/ui/home.rs b/src/ui/home.rs index 94583b55..6c961e7f 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -318,6 +318,7 @@ mod tests { fn closed_tab_label_prefers_the_user_set_name() { let tab = SessionTab { name: Some("build".into()), + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -328,6 +329,7 @@ mod tests { fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() { let tab = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -336,6 +338,7 @@ mod tests { // Whitespace-only names don't count as names. let tab = SessionTab { name: Some(" ".into()), + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -346,6 +349,7 @@ mod tests { fn closed_tab_label_searches_splits_for_the_first_cwd() { let tab = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, @@ -362,12 +366,14 @@ mod tests { // No name, no cwd — and "/" has no file name either. let unnamed = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); let root = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(Some("/")), }; @@ -378,6 +384,7 @@ mod tests { fn closed_tab_label_clamps_runaway_names() { let tab = SessionTab { name: Some("a".repeat(40)), + tree_id: None, sidebar_group: None, pane: leaf(None), }; diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index e028c3ef..bbefbac7 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -52,12 +52,13 @@ use std::sync::Arc; use gpui::{App, Global}; use tty7_core::core::machine::{ - AgentFacts, Axis as TreeAxis, PaneNode, PaneSeed, Side, Tab as TreeTab, TabId, + AgentFacts, Axis as TreeAxis, Machine, PaneNode, PaneRecord, PaneSeed, Side, Tab as TreeTab, + TabId, }; use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; use tty7_core::host::HostId; -use crate::core::session::{WorkspaceId, WorkspaceStore}; +use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; use crate::ui::app::Tty7App; use crate::ui::pane::{Pane, PaneSlot}; @@ -1025,6 +1026,250 @@ fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) { start_prime(cx, client_ws); } +// --------------------------------------------------------------------------- +// The read path: a window rebuilt from the machine's tree +// --------------------------------------------------------------------------- + +/// One workspace of a pulled [`Machine`], lowered into the `Session` shape the +/// window builder already consumes — the tree's leaves joined with their pane +/// registry records. +/// +/// The lowering *is* the revival decision, made per leaf by the daemon's own +/// liveness fact: a `live` pane keeps its id (the builder re-attaches), a dead +/// one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent +/// resume — exactly the leaf shape that makes the builder spawn a successor. +/// The save that follows then diffs the successor's id against the mirror and +/// sends the `PaneReplace` that spends the old record. +pub(crate) fn session_from_tree( + ws: &tty7_core::core::machine::Workspace, + panes: &[PaneRecord], +) -> Session { + let tabs: Vec = ws + .tabs + .iter() + .map(|tab| SessionTab { + name: tab.name.clone(), + tree_id: Some(tab.id), + sidebar_group: tab.sidebar_group.clone().map(std::path::PathBuf::from), + pane: session_pane_from_node(&tab.root, panes), + }) + .collect(); + let active = ws + .active_tab + .and_then(|id| ws.tabs.iter().position(|t| t.id == id)) + .unwrap_or(0); + Session { active, tabs } +} + +fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane { + match node { + PaneNode::Leaf { pane } => { + let record = panes.iter().find(|p| p.id == *pane); + let live = record.is_some_and(|r| r.live); + let (cwd, ssh_spec, agent) = match record { + Some(r) => ( + r.cwd.clone().map(std::path::PathBuf::from), + r.ssh_spec.clone(), + r.agent.clone(), + ), + None => (None, None, None), + }; + SessionPane::Leaf { + cwd, + // The daemon's liveness fact is the whole of the revival + // decision: an id is only worth keeping if the daemon holds a + // PTY for it *right now*. + pane_id: live.then_some(*pane), + ssh_spec, + agent: agent.as_ref().map(|a| a.agent), + agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()), + agent_launch_argv: agent.as_ref().and_then(|a| a.launch_argv.clone()), + } + } + PaneNode::Split { axis, ratio, a, b } => SessionPane::Split { + axis: match axis { + TreeAxis::Horizontal => crate::core::session::SessionAxis::Horizontal, + TreeAxis::Vertical => crate::core::session::SessionAxis::Vertical, + }, + ratio: *ratio, + a: Box::new(session_pane_from_node(a, panes)), + b: Box::new(session_pane_from_node(b, panes)), + }, + } +} + +/// How long an opening window waits for its machine's link before giving up on +/// the pull and staying empty. Generous against a slow daemon start; the local +/// link is normally up within one supervision tick. +const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); +const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200); + +/// Fill an (empty) window from the machine's tree: pull `MachineGet`, prime +/// the mirror with the workspace's tabs, and rebuild the window from them — +/// re-attaching live panes, spawning successors for dead ones. +/// +/// The window opens first and this runs behind it, because the pull is a round +/// trip that may have to wait out the link coming up; against the local daemon +/// it lands within milliseconds, so in practice the empty state is one frame. +/// +/// A workspace the machine has never heard of is created (empty) — and, as a +/// one-time courtesy to trees that predate the migration, an *empty* pull +/// falls back to the client's cached `session` copy: adopting it re-populates +/// the tree through the ordinary diff, which is the whole import. +pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { + let host = WorkspaceStore::host_of(cx, client_ws); + let machine_ws = tree_workspace_id(cx, client_ws); + let name = WorkspaceStore::all(cx) + .get(client_ws) + .and_then(|w| w.name.clone()); + { + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + state.sync = SyncPhase::Unprimed { + dirty: false, + priming: true, + }; + } + cx.spawn(async move |cx| { + // At launch the link is usually still dialing; wait it out briefly + // rather than failing an open the supervisor will fix in a second. + let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE; + let client = loop { + let client = cx.update(|cx| control_for(cx, host)); + match client { + Some(client) => break Some(client), + None if std::time::Instant::now() > deadline => break None, + None => cx.background_executor().timer(HYDRATE_LINK_POLL).await, + } + }; + let Some(client) = client else { + log::warn!("workspace {client_ws}: no link to its machine; opening empty"); + cx.update(|cx| { + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { + if let SyncPhase::Unprimed { priming, .. } = &mut state.sync { + *priming = false; + } + } + }); + return; + }; + let outcome = cx + .background_executor() + .spawn(async move { pull_workspace(&client, machine_ws, name) }) + .await; + cx.update(|cx| finish_hydration(cx, client_ws, outcome)); + }) + .detach(); +} + +/// The blocking half: the whole machine (the tree plus the pane registry — +/// `WorkspaceTree` alone answers structure without the pane facts revival +/// needs), reduced to this workspace's mirror and session. A machine that has +/// no such workspace gets it created, empty. +fn pull_workspace( + client: &ControlClient, + machine_ws: WorkspaceId, + name: Option, +) -> io::Result<(WsMirror, Session)> { + let machine: Machine = match client.call(ControlRequest::MachineGet)? { + ReplyOk::MachineTree(m) => *m, + other => return Err(io::Error::other(format!("MachineGet answered {other:?}"))), + }; + match machine.workspaces.iter().find(|w| w.id == machine_ws) { + Some(ws) => Ok(( + WsMirror { + tabs: ws.tabs.clone(), + active: ws.active_tab, + }, + session_from_tree(ws, &machine.panes), + )), + None => { + client.call(ControlRequest::WorkspaceCreate { + name, + workspace: Some(machine_ws), + })?; + Ok((WsMirror::default(), Session::default())) + } + } +} + +fn finish_hydration( + cx: &mut App, + client_ws: WorkspaceId, + outcome: io::Result<(WsMirror, Session)>, +) { + let (mirror, session) = match outcome { + Ok(pulled) => pulled, + Err(e) => { + log::warn!("could not hydrate workspace {client_ws} from its machine: {e}"); + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) + && let SyncPhase::Unprimed { priming, .. } = &mut state.sync + { + *priming = false; + } + return; + } + }; + let was_dirty = { + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); + state.sync = SyncPhase::Primed(mirror); + dirty + }; + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) + else { + return; + }; + if !app.read(cx).tabs.is_empty() { + // The user got there first (opened a tab into the empty window); their + // window wins, and the sync below reconciles the tree to it. + if was_dirty { + app.update(cx, |app, cx| sync_window(app, cx)); + } + return; + } + // The one-time import: a tree with nothing for this workspace, a client + // with a cached layout — adopt the cache, and the adopt's own save + // populates the tree through the ordinary diff. + let session = if session.tabs.is_empty() { + WorkspaceStore::all(cx) + .get(client_ws) + .map(|w| w.session.clone()) + .unwrap_or(session) + } else { + session + }; + if session.tabs.is_empty() { + if was_dirty + && let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + { + app.update(cx, |app, cx| sync_window(app, cx)); + } + return; + } + let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { + return; + }; + log::info!( + "rebuilding {} tab(s) of workspace {client_ws} from its machine's tree", + session.tabs.len() + ); + let _ = handle.update(cx, move |_, window, cx| { + app.update(cx, |app, cx| { + app.adopt_workspace(client_ws, session, window, cx) + }); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -1424,6 +1669,102 @@ mod tests { assert_eq!(diff(ws, &mut mirror, &want, Some(id)), Vec::new()); } + #[test] + fn a_live_leaf_keeps_its_pane_id_and_a_dead_one_lowers_to_a_revival_leaf() { + use tty7_core::core::cli_agent::CLIAgent; + let tab_id = TabId::new(); + let ws = tty7_core::core::machine::Workspace { + tabs: vec![TreeTab { + id: tab_id, + name: Some("build".into()), + sidebar_group: Some("/repo".into()), + root: PaneNode::Split { + axis: TreeAxis::Vertical, + ratio: 0.3, + a: Box::new(PaneNode::Leaf { pane: 1 }), + b: Box::new(PaneNode::Leaf { pane: 2 }), + }, + }], + active_tab: Some(tab_id), + ..Default::default() + }; + let panes = vec![ + PaneRecord { + id: 1, + cwd: Some("/work".into()), + live: true, + ..PaneRecord::new(1) + }, + PaneRecord { + id: 2, + cwd: Some("/work/api".into()), + live: false, + agent: Some(AgentFacts { + agent: CLIAgent::Claude, + session_id: Some("sid".into()), + launch_argv: Some(vec!["claude".into()]), + status: None, + }), + ..PaneRecord::new(2) + }, + ]; + + let session = session_from_tree(&ws, &panes); + assert_eq!(session.tabs.len(), 1); + assert_eq!(session.active, 0); + let tab = &session.tabs[0]; + assert_eq!( + tab.tree_id, + Some(tab_id), + "the daemon tab's identity rides along" + ); + assert_eq!(tab.name.as_deref(), Some("build")); + let SessionPane::Split { ratio, a, b, .. } = &tab.pane else { + panic!("the split survives the lowering"); + }; + assert!((ratio - 0.3).abs() < 1e-6); + match &**a { + SessionPane::Leaf { pane_id, cwd, .. } => { + assert_eq!(*pane_id, Some(1), "a live pane re-attaches by its id"); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work"))); + } + _ => panic!("leaf"), + } + match &**b { + SessionPane::Leaf { + pane_id, + cwd, + agent, + agent_session_id, + .. + } => { + assert_eq!( + *pane_id, None, + "a dead pane's leaf takes the fresh-spawn path — that is the revival" + ); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); + assert_eq!(*agent, Some(CLIAgent::Claude)); + assert_eq!(agent_session_id.as_deref(), Some("sid")); + } + _ => panic!("leaf"), + } + } + + #[test] + fn a_dangling_active_tab_in_the_pulled_tree_falls_back_to_the_first() { + let ws = tty7_core::core::machine::Workspace { + tabs: vec![TreeTab { + id: TabId::new(), + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + }], + active_tab: Some(TabId::new()), + ..Default::default() + }; + assert_eq!(session_from_tree(&ws, &[]).active, 0); + } + #[test] fn a_pane_id_reused_in_another_tab_is_never_read_as_a_replace() { let ws = WorkspaceId::new();