mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from session.json synchronously. The window opens empty and a background pull (MachineGet — the workspace's structure joined with the pane registry, which is where the revival facts live) rebuilds it the moment the daemon answers; against the local daemon that is milliseconds, so the empty state is effectively one frame — the same shape a remote workspace's connect-driven rebuild has always had. The lowering from tree to window is the revival decision: a leaf whose pane record says live re-attaches by id, a dead one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent resume — the exact shape that makes the existing builder spawn a successor and type the agent's --resume. The save that follows diffs the successor against the mirror and sends PaneReplace, spending the old record; revival needed no op code of its own. Restored tabs keep their daemon tab ids (SessionTab grows a never- persisted tree_id), so the first save addresses the daemon's tabs instead of churning them. A tree with nothing for the workspace falls back once to the client's cached layout, whose adoption re-populates the tree through the ordinary diff — the whole of the best-effort import.
This commit is contained in:
@@ -101,6 +101,14 @@ pub struct SessionTab {
|
||||
/// model does not permit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sidebar_group: Option<std::path::PathBuf>,
|
||||
/// 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<crate::core::machine::TabId>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -541,6 +541,7 @@ mod tests {
|
||||
Session {
|
||||
tabs: vec![SessionTab {
|
||||
name: None,
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf("/Users/me/work"),
|
||||
}],
|
||||
|
||||
+36
-6
@@ -907,6 +907,17 @@ impl Tty7App {
|
||||
let restore = cx.global::<Config>().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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
+343
-2
@@ -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<SessionTab> = 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::<TreeSync>()
|
||||
.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::<TreeSync>().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<String>,
|
||||
) -> 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::<TreeSync>().windows.get_mut(&client_ws)
|
||||
&& let SyncPhase::Unprimed { priming, .. } = &mut state.sync
|
||||
{
|
||||
*priming = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let was_dirty = {
|
||||
let state = cx
|
||||
.default_global::<TreeSync>()
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user