From bd0dd22bfa4e43646cf4c79c25d31943c76d3ec5 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:41:12 +0800 Subject: [PATCH] feat(tabs): hibernate a tab to free its memory and wake it later (#954) A tab can be put to sleep from its context menu or the command palette: its panes are stopped (screens kept on disk), the tab keeps its place in the sidebar, and selecting it wakes it through the same restore a reboot runs, resuming a supported agent's session. The sleep mark lives on the machine tree, so it survives app and daemon restarts. Closes #762 --- CHANGELOG.md | 13 + crates/tty7-cli/src/commands.rs | 34 ++ crates/tty7-cli/src/output.rs | 36 ++- crates/tty7-cli/src/testbed.rs | 3 + crates/tty7-core/src/core/machine.rs | 148 +++++++++ crates/tty7-core/src/core/session.rs | 10 + crates/tty7-core/src/daemon/control.rs | 15 + crates/tty7-core/src/daemon/server.rs | 20 ++ crates/tty7-core/src/host/server.rs | 185 +++++++++++ docs/cli/reference.mdx | 4 +- docs/reference/keyboard-shortcuts.mdx | 2 +- src/core/actions.rs | 1 + src/ui/app.rs | 413 ++++++++++++++++++++++++- src/ui/home.rs | 14 + src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 6 + src/ui/i18n/mod.rs | 6 + src/ui/i18n/zh.rs | 6 + src/ui/keymap.rs | 6 + src/ui/local_link.rs | 9 + src/ui/machine_mirror.rs | 1 + src/ui/palette.rs | 5 + src/ui/tab_sidebar.rs | 16 +- src/ui/tab_strip.rs | 62 +++- src/ui/tree_sync.rs | 370 +++++++++++++++++++++- 25 files changed, 1378 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa5974d..ea729d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a + tab → Hibernate, or "Hibernate Tab" in the command palette, stops every + process in the tab to give its memory back while the tab keeps its place in + the sidebar, faded and marked with a moon. Selecting it wakes it — or + right-click → Wake to warm it up without switching. Waking is the restore a + reboot already runs: each pane comes back in its old directory and shell, + opens on the screen it left, and a supported agent with a captured session + is resumed. A sleeping tab stays asleep across app and daemon restarts; the + tab on screen is always awake, so the last awake tab cannot be put to sleep. + `tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane + ls --json` carry a `hibernated` flag. Offered only where the machine's server + can do it (this build or newer). + - **A port forward can be switched off without losing its rule** (#439). A forward could only be removed, so pointing one local port at a different remote target meant deleting the rule and typing the other one in again. Each diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 1cd5f935..d7bea737 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -944,6 +944,7 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R "agent": view.agent.map(|a| a.display_name()), "group": tab.sidebar_group, "panes": tab.root.pane_ids(), + "hibernated": tab.hibernated, }) }) .collect(); @@ -1147,6 +1148,7 @@ fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Resu "tab": tab.id.to_string(), "cwd": record.and_then(|r| r.cwd.clone()), "live": record.map(|r| r.live), + "hibernated": tab.hibernated, })); } } @@ -4716,6 +4718,38 @@ mod tests { ); } + #[test] + fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() { + let mut backend = mock(); + backend.machine.workspaces[0].tabs[1].hibernated = true; + + let Outcome::Report(tabs) = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("tab ls must report"); + }; + let tabs = tabs.json["tabs"].as_array().expect("tabs").clone(); + assert_eq!(tabs[0]["hibernated"], false); + assert_eq!(tabs[1]["hibernated"], true); + + let Outcome::Report(panes) = run_cli( + &["tty7", "pane", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("pane ls must report"); + }; + let panes = panes.json["panes"].as_array().expect("panes").clone(); + let asleep: Vec = panes + .iter() + .filter(|p| p["hibernated"] == true) + .map(|p| p["pane"].as_u64().unwrap()) + .collect(); + assert_eq!(asleep, vec![2, 3]); + } + fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun { crate::exec::ExecRun { end, diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index d92ab7ef..c165a3f7 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -140,9 +140,14 @@ pub fn pane_table(machine: &Machine, only: Option) -> String { record .and_then(|r| r.cwd.clone()) .unwrap_or_else(|| "-".to_string()), - record - .map(|r| if r.live { "yes" } else { "no" }.to_string()) - .unwrap_or_else(|| "?".to_string()), + // Not running, and meant not to be: a sleeping tab's panes + // are waiting for their tab to be woken, not dead. + match record { + _ if tab.hibernated => "asleep".to_string(), + Some(r) if r.live => "yes".to_string(), + Some(_) => "no".to_string(), + None => "?".to_string(), + }, ]); } } @@ -648,4 +653,29 @@ mod tests { procs_tables(&empty) ); } + + /// A sleeping tab's panes are not running and not dead either: the table + /// says which, so nobody reads a tab put to sleep as one that crashed. + #[test] + fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() { + let mut m = two_workspace_machine(); + m.workspaces[0].tabs[1].hibernated = true; + for pane in &mut m.panes { + if pane.id == 2 || pane.id == 3 { + pane.live = false; + } + } + let rendered = pane_table(&m, None); + let row = |pane: &str| { + rendered + .lines() + .find(|l| l.starts_with(pane)) + .unwrap_or_else(|| panic!("no row for {pane}: {rendered}")) + .to_string() + }; + assert!(row("%1 ").ends_with("yes"), "{rendered}"); + assert!(row("%2 ").ends_with("asleep"), "{rendered}"); + assert!(row("%3 ").ends_with("asleep"), "{rendered}"); + assert!(row("%5 ").ends_with("yes"), "{rendered}"); + } } diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 22cf5c24..a24aaf75 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -12,6 +12,7 @@ pub fn two_workspace_machine() -> Machine { name: Some("build".into()), sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, Tab { id: TabId::new(), @@ -23,6 +24,7 @@ pub fn two_workspace_machine() -> Machine { a: Box::new(PaneNode::Leaf { pane: 2 }), b: Box::new(PaneNode::Leaf { pane: 3 }), }, + hibernated: false, }, ], active_tab: None, @@ -37,6 +39,7 @@ pub fn two_workspace_machine() -> Machine { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 5 }, + hibernated: false, }], active_tab: None, attachment: None, diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 2a0fdb29..201c0141 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -158,6 +158,19 @@ pub struct Tab { #[serde(default)] pub sidebar_group: Option, pub root: PaneNode, + /// The tab was put to sleep: its panes were stopped to give their memory + /// back, and it stays in the workspace to be woken later (#762). + /// + /// The panes keep their ids in `root` and their records in + /// [`Machine::panes`] — cwd, shell, agent session — because waking is a + /// restore of exactly those panes, through the same path a reboot takes. + /// That is also what keeps the sweeps off them: a pane the tree still + /// names is one whose stored screen and history are kept. + /// + /// Left out of the document while false, so a tree written by this build + /// reads the same to one that predates the field. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, } impl Tab { @@ -167,6 +180,7 @@ impl Tab { name: None, sidebar_group: None, root: PaneNode::Leaf { pane }, + hibernated: false, } } } @@ -747,6 +761,43 @@ impl MachineStore { }) } + /// Put a tab to sleep, or mark it awake again. Answers the panes the tab + /// holds, which for a tab going to sleep are the ones whoever asked is now + /// expected to stop — see [`ControlRequest::TabSetHibernated`]. + /// + /// Only the mark changes here. The panes stay in the tab and in the pane + /// list, so the facts a wake needs survive the processes: waking hands the + /// same ids to the ordinary restore, which spawns successors and swaps + /// them in with `pane_replace`. Setting the mark a tab already has is not + /// a change and raises no delta. + /// + /// The delta is `TabRestructured` carrying the whole tab rather than a + /// variant of its own, so a client that predates the mark still decodes + /// it — it reads the same tab back, minus a field it does not know. + /// + /// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated + pub fn tab_set_hibernated( + &self, + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + let panes = t.root.pane_ids(); + if t.hibernated == hibernated { + return Ok((panes, Vec::new())); + } + t.hibernated = hibernated; + let delta = LayoutDelta::TabRestructured { + tab: t.clone(), + pane: None, + }; + Ok((panes, vec![(workspace, delta)])) + }) + } + pub fn tab_set_group( &self, workspace: WorkspaceId, @@ -2801,4 +2852,101 @@ mod tests { assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]); assert!(machine.panes.is_empty()); } + + /// A tab put to sleep keeps its panes — ids in the layout, records in the + /// pane list — because waking is a restore of exactly those. Only the + /// orphan collection a close runs could take them, and a sleeping tab + /// still names them. + #[test] + fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() { + let (store, _dir, ws, tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None) + .unwrap(); + let (_sub, heard) = recorded(&store); + + let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + + assert_eq!(panes, vec![1, 2]); + let m = store.machine(); + let asleep = &m.workspaces[0].tabs[0]; + assert!(asleep.hibernated); + assert_eq!(asleep.root.pane_ids(), vec![1, 2]); + assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2)); + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 1); + match &heard[0].1 { + LayoutDelta::TabRestructured { tab: t, pane: None } => { + assert_eq!(t.id, tab.id); + assert!(t.hibernated, "the delta carries the mark"); + } + other => panic!("expected TabRestructured, heard {other:?}"), + } + } + + #[test] + fn setting_the_mark_a_tab_already_has_raises_nothing() { + let (store, _dir, ws, tab) = store_with_tab(); + let (_sub, heard) = recorded(&store); + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(heard.lock().unwrap().is_empty()); + + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + assert_eq!(heard.lock().unwrap().len(), 1); + + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + assert_eq!(heard.lock().unwrap().len(), 2); + } + + #[test] + fn hibernating_a_tab_that_is_not_there_is_refused() { + let (store, _dir, ws, _tab) = store_with_tab(); + let err = store + .tab_set_hibernated(ws, TabId::new(), true, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + /// Asleep has to outlive the daemon: a restart reads the tree back from + /// disk, and a tab that came back awake would be respawned on the spot — + /// every process the user put to sleep, started again unasked. + #[test] + fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() { + let (store, dir, ws, tab) = store_with_tab(); + let other = store + .tab_create(ws, None, seed(5, "/else"), None, None) + .unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + drop(store); + + let reopened = MachineStore::open(dir.path().join(MACHINE_FILE)); + let m = reopened.machine(); + let tabs = &m.workspaces[0].tabs; + assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated); + let record = m.panes.iter().find(|p| p.id == 1).unwrap(); + assert_eq!(record.cwd.as_deref(), Some("/work")); + assert!(!record.live); + } + + /// Awake is the default and stays out of the document, so a tree this + /// build writes for a tab that never slept reads the same to one that + /// predates the mark, and a tree from before it decodes as awake. + #[test] + fn the_mark_is_only_written_while_it_is_set() { + let tab = Tab::leaf(4); + let json = serde_json::to_string(&tab).unwrap(); + assert!(!json.contains("hibernated"), "{json}"); + let back: Tab = serde_json::from_str(&json).unwrap(); + assert!(!back.hibernated); + + let asleep = Tab { + hibernated: true, + ..Tab::leaf(4) + }; + let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap(); + assert!(back.hibernated); + } } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index f316a2af..1deeeca4 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -54,6 +54,16 @@ pub struct SessionTab { pub sidebar_group: Option, #[serde(skip)] pub tree_id: Option, + /// Asleep in the tree: comes back as a placeholder holding `pane`, and + /// nothing is spawned for it until it is woken. See + /// [`Tab::hibernated`](crate::core::machine::Tab::hibernated). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, + /// What a sleeping tab is called while nothing in it runs to say — read + /// off the tree's pane records, the same way the switcher names a tab of + /// a window it does not own. + #[serde(skip)] + pub asleep_view: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 136a3164..1543d8ca 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -141,6 +141,10 @@ pub mod feature { /// simply unknown here: the pane lives in the peer's registry, and the /// local daemon this client would otherwise ask has never heard of it. pub const PANE_PROCS: &str = "pane-procs"; + /// The peer can put a tab to sleep: mark it in the tree and stop its panes + /// while keeping their screens (#762). Needs both a tree and panes, so a + /// peer serving either alone does not say it. + pub const TAB_HIBERNATE: &str = "tab-hibernate"; } pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; @@ -292,6 +296,16 @@ pub enum ControlRequest { tab: TabId, group: Option, }, + /// Put a tab to sleep or mark it awake. Going to sleep, the peer also + /// stops every pane the tab holds, keeping each one's screen on disk for + /// the wake to restore; waking only clears the mark, because the panes a + /// wake brings up are spawned by the client like any restored pane. + /// Answers the tab's panes. Gated on [`feature::TAB_HIBERNATE`]. + TabSetHibernated { + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + }, PaneSplit { workspace: WorkspaceId, pane: u64, @@ -399,6 +413,7 @@ impl ControlRequest { | TabRename { .. } | TabMove { .. } | TabSetGroup { .. } + | TabSetHibernated { .. } | PaneSplit { .. } | PaneClose { .. } | PaneSetRatio { .. } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index f0da41de..de4c4f5f 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry { self.get(pane_id).map(|p| p.procs()).unwrap_or_default() } + fn hibernate_pane(&self, pane_id: u64) { + hibernate_pane(self, pane_id); + } + fn agent_states(&self) -> Vec { let panes: Vec> = self.panes.lock().unwrap().values().cloned().collect(); let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect(); @@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) { crate::daemon::scrollback::forget(pane_id); } +/// Stop a pane whose tab is going to sleep, keeping its screen. +/// +/// [`kill_pane`] with the other half of the bargain: the screen is written one +/// last time, exactly, and left on disk rather than dropped, so the pane that +/// wakes in this one's place opens showing it — the same restore a daemon that +/// died unannounced gets, asked for the same way. The tree still names this +/// pane, which is what keeps the sweeps off the file until then. +fn hibernate_pane(registry: &Registry, pane_id: u64) { + let Some(pane) = registry.remove(pane_id) else { + return; + }; + let (segments, title, _) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane_id, &segments, title.as_deref()); + pane.kill(); +} + fn ssh_connection_for( registry: &Registry, pane_id: u64, diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 6913a5e8..ebcc73ab 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync { /// is this process and not the client's own daemon: the client asks over /// the control link precisely because the processes are here. fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs; + /// Stop a pane for a tab going to sleep: the processes go, the pane's last + /// screen stays on disk for the wake to open with. Unlike closing a pane, + /// which drops that screen because nobody will ask for it again. + fn hibernate_pane(&self, pane_id: u64); } #[derive(Clone, Default)] @@ -383,6 +387,12 @@ fn handshake( if services.machine.is_some() { features.push(feature::MACHINE_TREE.to_string()); } + // A sleeping tab is a mark in the tree *and* stopped panes; a peer that can + // do only half of that would leave a tab asleep with its shells running, + // or its shells stopped with nothing saying the tab is meant to wake. + if services.machine.is_some() && services.panes.is_some() { + features.push(feature::TAB_HIBERNATE.to_string()); + } // The pane daemon's features ride along, same as `protocol_version` above: // a pane connection answers exactly one message, so a client that wanted // them from `ClientMsg::Version` would pay a whole routed connection — for @@ -777,6 +787,34 @@ fn run_request( .tab_set_group(workspace, tab, group, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } + ControlRequest::TabSetHibernated { + workspace, + tab, + hibernated, + } => { + let Some(directory) = conn.panes.as_ref() else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "this peer serves no panes, so it cannot put a tab to sleep", + )); + }; + let panes = conn.machine()?.tab_set_hibernated( + workspace, + tab, + hibernated, + conn.machine_origin, + )?; + // After the mark, not before: a pane stopped under a tab the tree + // does not yet call asleep is, to anyone reading the tree in + // between, a pane that died — and a restore reading it that way + // would spawn it straight back. + if hibernated { + for pane in &panes { + directory.hibernate_pane(*pane); + } + } + (ReplyOk::Panes(panes), Vec::new()) + } ControlRequest::PaneSplit { workspace, pane, @@ -1716,6 +1754,8 @@ mod aggregate_tests { } } + fn hibernate_pane(&self, _pane_id: u64) {} + fn agent_states(&self) -> Vec { vec![PaneAgentState { pane_id: 7, @@ -1896,6 +1936,151 @@ mod aggregate_tests { "whatever links exist are named; none are blank" ); } + + /// Records which panes were stopped for a sleeping tab, and in what order + /// relative to the tree: each entry carries whether the tree already + /// called the tab asleep when the pane was stopped. + struct SleepRecorder { + store: Arc, + stopped: std::sync::Mutex>, + } + + impl PaneDirectory for SleepRecorder { + fn pane_count(&self) -> u64 { + 0 + } + + fn panes(&self) -> Vec { + Vec::new() + } + + fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs { + Default::default() + } + + fn agent_states(&self) -> Vec { + Vec::new() + } + + fn hibernate_pane(&self, pane_id: u64) { + let asleep = self.store.machine().workspaces.iter().any(|w| { + w.tabs + .iter() + .any(|t| t.hibernated && t.root.contains(pane_id)) + }); + self.stopped.lock().unwrap().push((pane_id, asleep)); + } + } + + fn seed(pane: u64) -> machine::PaneSeed { + machine::PaneSeed { + pane, + cwd: Some("/repo".into()), + ssh_spec: None, + agent: None, + shell: None, + } + } + + /// Going to sleep stops every pane of the tab — after the tree says the + /// tab is asleep, so nobody reading the tree in between takes the stopped + /// panes for dead ones — and keeps them in the tree for the wake. Waking + /// only clears the mark: the client brings the panes back. + #[test] + fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + store + .pane_split( + ws.id, + 7, + machine::Axis::Horizontal, + 0.5, + seed(8), + false, + None, + ) + .unwrap(); + let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap(); + let recorder = Arc::new(SleepRecorder { + store: Arc::clone(&store), + stopped: std::sync::Mutex::new(Vec::new()), + }); + let client = client_with(Services { + machine: Some(Arc::clone(&store)), + attachments: Arc::new(AttachRegistry::default()), + panes: Some(recorder.clone()), + }); + assert!(client.hello().has_feature(feature::TAB_HIBERNATE)); + + let ReplyOk::Panes(panes) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert_eq!(panes, vec![7, 8]); + assert_eq!( + *recorder.stopped.lock().unwrap(), + vec![(7, true), (8, true)], + "both panes stopped, each after the tree marked the tab" + ); + let machine = store.machine(); + let asleep = &machine.workspaces[0].tabs; + assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated); + for pane in [7, 8] { + assert!( + machine.panes.iter().any(|p| p.id == pane), + "pane {pane}'s record is what the wake restores from" + ); + } + + recorder.stopped.lock().unwrap().clear(); + let ReplyOk::Panes(_) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: false, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert!(recorder.stopped.lock().unwrap().is_empty()); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } + + /// A peer with a tree and no panes could mark a tab asleep and leave its + /// shells running. It does not offer to, and refuses if asked anyway — + /// before the mark, so the tree is not left claiming a sleep that never + /// happened. + #[test] + fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + let client = client_with(Services::with_machine(Arc::clone(&store))); + assert!(client.hello().has_feature(feature::MACHINE_TREE)); + assert!(!client.hello().has_feature(feature::TAB_HIBERNATE)); + assert!( + client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .is_err() + ); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } } #[cfg(test)] diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 6c0fac7f..ea365f54 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@`. | Command | Effect | JSON | |---|---|---| -| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` | +| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` | | `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` | | `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` | | `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` | @@ -355,7 +355,7 @@ a stand-in. | Command | Effect | JSON | |---|---|---| -| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` | +| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag (`asleep` for a hibernated tab's panes) | `{"panes":[…]}` | | `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` | | `pane split …` | Identical to top-level `split` | `{"pane"}` | | `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` | diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 8013f2b1..c9ea38c2 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -107,7 +107,7 @@ Keybindings**: | Group | Actions | |---|---| -| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` | +| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | | Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | diff --git a/src/core/actions.rs b/src/core/actions.rs index 836838a9..c8e81210 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -30,6 +30,7 @@ actions!( RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, + HibernateTab, ForkAgentSession, ForkAgentSessionRight, ForkAgentSessionLeft, diff --git a/src/ui/app.rs b/src/ui/app.rs index 81c51245..33d8bde4 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -439,6 +439,25 @@ pub struct Tab { /// the tab no longer holds are dropped as the next one is written, so a /// closed pane leaves nothing behind either. focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>, + /// Set while the tab is asleep (#762): its panes were stopped and `pane` + /// is `Pane::Empty`. What is kept instead is what it takes to bring them + /// back — see [`Asleep`]. + pub(crate) asleep: Option, +} + +/// A sleeping tab: nothing of it runs, and everything a wake needs is here. +/// +/// `layout` is the tab as the restore path reads a tab — pane ids, cwds, +/// shells, agent sessions — so waking is that restore, run on one tab: each +/// pane's id asks the daemon for the screen it stored when the pane was +/// stopped, and an agent with a known session is resumed the way a reboot +/// resumes it. `view` is what the tab is called in the meantime, read off +/// the panes as they went to sleep (or off the tree's records, after a +/// restart), since there is no terminal left to ask. +pub(crate) struct Asleep { + pub(crate) layout: SessionPane, + pub(crate) view: tty7_core::core::tab_view::TabView, + pub(crate) home: Option, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -463,6 +482,7 @@ impl Tab { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } @@ -484,9 +504,25 @@ impl Tab { tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } + pub(crate) fn is_asleep(&self) -> bool { + self.asleep.is_some() + } + + /// The layout a sleeping tab will wake into; `None` for a tab that is awake. + pub(crate) fn asleep_layout(&self) -> Option<&SessionPane> { + self.asleep.as_ref().map(|a| &a.layout) + } + + /// The agent a sleeping tab was running when it went to sleep, so its row + /// still says what it is while nothing in it can. + pub(crate) fn asleep_agent(&self) -> Option { + self.asleep.as_ref().and_then(|a| a.view.agent) + } + fn focus_target(&self) -> Option { match self.last_focused { Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id), @@ -585,6 +621,14 @@ impl Tab { Option, ) { let name = self.name.clone(); + if let Some(asleep) = &self.asleep { + let view = tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + ..asleep.view.clone() + }; + return (view, asleep.home.clone()); + } let Some(leaf) = self.title_leaf(window, cx) else { return ( tty7_core::core::tab_view::TabView { @@ -1854,6 +1898,7 @@ impl Tty7App { self.tabs = tabs; self.active = active; self.maximized = None; + self.wake_active_if_asleep(window, cx); self.save_session(cx); crate::ui::windows::refresh_menu(cx); self.focus_active(window, cx); @@ -1897,6 +1942,7 @@ impl Tty7App { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }, ); self.active = insert_at; @@ -4799,6 +4845,13 @@ impl Tty7App { } pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + // A tab is woken by being looked at, the way a browser reloads a + // discarded tab when it is selected: a tab on screen is always a + // running one, so there is no third state to draw. A wake that fails + // leaves the tab asleep and the window where it was. + if self.tabs.get(index).is_some_and(Tab::is_asleep) && !self.wake_tab(index, window, cx) { + return; + } if index < self.tabs.len() && index != self.active { self.remember_active_pane(window, cx); // Zoom rides with its tab (#599): stash the outgoing tab's zoom @@ -4831,6 +4884,146 @@ impl Tty7App { } } + /// Whether tab `index` can be put to sleep right now (#762). + /// + /// Not while it is connecting: a pane still on its way has no id the + /// machine knows, so it would be lost rather than stopped. Not with a + /// native SSH pane in a remote window either — that pane runs on this + /// computer, beyond the reach of the machine asked to stop the tab. Not on + /// a machine whose server cannot do it. And not the one tab left awake: + /// the window has to have something on screen, and looking at a tab is + /// what wakes it. + pub(crate) fn can_hibernate_tab(&self, index: usize, cx: &App) -> bool { + let Some(tab) = self.tabs.get(index) else { + return false; + }; + if tab.is_asleep() || !crate::ui::tree_sync::can_hibernate_on(cx, self.workspace) { + return false; + } + let remote = WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); + let leaves = tab.pane.leaves(); + let all_hibernatable = !leaves.is_empty() + && leaves.iter().all(|leaf| match leaf { + PaneSlot::Ready(view) => !(remote && view.read(cx).ssh_spec().is_some()), + PaneSlot::Connecting(_) => false, + }); + all_hibernatable && self.awake_tab_besides(index).is_some() + } + + /// The tab to show instead of `index` when that one goes to sleep: the + /// awake tab used most recently, the nearest one on a tie. + fn awake_tab_besides(&self, index: usize) -> Option { + (0..self.tabs.len()) + .filter(|&i| i != index && !self.tabs[i].is_asleep()) + .max_by_key(|&i| { + ( + self.tabs[i].last_used.get(), + std::cmp::Reverse(i.abs_diff(index)), + ) + }) + } + + /// Put tab `index` to sleep: stop its panes, keep its place (#762). + /// + /// The window lets go of the panes first and the machine stops them after, + /// from the sync this ends with. That order is what keeps a stopped pane + /// from reading as one that died: by the time its shell exits there is no + /// view left here to take the exit as a reason to close the pane, or the + /// tab with it. The machine, for its part, marks the tab before it stops + /// anything, so a restore reading the tree in between never takes the + /// panes for dead ones and spawns them straight back. + pub(crate) fn hibernate_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_hibernate_tab(index, cx) { + return; + } + if index == self.active { + let Some(next) = self.awake_tab_besides(index) else { + return; + }; + self.activate(next, window, cx); + } + self.put_to_sleep(index, window, cx); + } + + /// The part of [`Self::hibernate_tab`] that does not ask whether it may: + /// the tab lets go of its panes and keeps what a wake needs instead. + fn put_to_sleep(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get(index).filter(|t| !t.is_asleep()) else { + return; + }; + let (mut view, home) = tab.label_view(Some(window), cx); + view.live = false; + view.status = None; + let layout = pane_to_session(&tab.pane, cx); + let tab = &mut self.tabs[index]; + let panes = std::mem::replace(&mut tab.pane, Pane::Empty); + tab.zoomed = None; + tab.last_focused = None; + tab.focus_origin.clear(); + tab.asleep = Some(Asleep { layout, view, home }); + drop(panes); + self.save_session(cx); + cx.notify(); + } + + /// Bring a sleeping tab back, through the restore every pane takes after a + /// reboot: each pane is spawned in its old one's place, opens on the screen + /// that one left behind, and resumes its agent session when it had one. + /// Returns whether the tab is awake now; a tab none of whose panes could + /// be started stays asleep, and says so. + pub(crate) fn wake_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(asleep) = self.tabs.get_mut(index).and_then(|t| t.asleep.take()) else { + return true; + }; + let pane_ws = self.window_workspace(cx); + let layout = redial_native_ssh(&asleep.layout); + // No listing: every pane in here was stopped on purpose, so the attach + // each one tries first fails and falls through to the fresh spawn — + // unless the stop never landed, in which case attaching to the pane + // that is still running is exactly right. + let pane = session_to_pane( + pane_ws.as_ref(), + self.workspace, + &layout, + None, + self.font_size, + window, + cx, + ); + let tab = &mut self.tabs[index]; + let Some(pane) = pane else { + tab.asleep = Some(asleep); + window.push_notification(t(L10nKey::TabWakeFailed), cx); + return false; + }; + tab.pane = pane; + tab.last_focused = None; + self.save_session(cx); + cx.notify(); + true + } + + /// Wake the tab on screen if it is asleep — for the paths that land on a + /// tab without going through [`Self::activate`]: a neighbour taking over + /// from a closed tab, a window restored onto a sleeping one. + pub(crate) fn wake_active_if_asleep(&mut self, window: &mut Window, cx: &mut Context) { + if self.tabs.get(self.active).is_some_and(Tab::is_asleep) { + self.wake_tab(self.active, window, cx); + } + } + /// Whether tab `index` has a pane zoomed over hidden siblings — what the /// chrome marks so the state is readable without toggling it (#752). /// @@ -4935,6 +5128,7 @@ impl Tty7App { } else if index < self.active { self.active -= 1; } + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); self.save_session(cx); cx.notify(); @@ -5730,6 +5924,7 @@ impl Tty7App { CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), CopyWorkingDirectory => self.copy_active_cwd(window, cx), MarkTabUnread => self.mark_tab_unread(self.active, cx), + HibernateTab => self.hibernate_tab(self.active, window, cx), ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx), CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx), RenameWorkspace => self.start_workspace_rename(window, cx), @@ -9042,6 +9237,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| { this.mark_tab_unread(this.active, cx) })) + .on_action(cx.listener(|this, _: &HibernateTab, window, cx| { + this.hibernate_tab(this.active, window, cx) + })) .on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| { this.fork_active_pane_session(ForkPlacement::NewTab, window, cx) })) @@ -9150,9 +9348,14 @@ fn mru_order(stamps: &[u64], active: usize) -> Vec { fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { SessionTab { name: tab.name.clone(), - pane: pane_to_session(&tab.pane, cx), + pane: match tab.asleep_layout() { + Some(layout) => layout.clone(), + None => pane_to_session(&tab.pane, cx), + }, sidebar_group: tab.sidebar_group.borrow().clone(), tree_id: None, + hibernated: false, + asleep_view: None, } } @@ -9327,7 +9530,19 @@ fn tabs_from_session( let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace)); let mut tabs: Vec = Vec::with_capacity(session.tabs.len()); let mut dropped = 0usize; - for st in &session.tabs { + let home = crate::ui::path_display::home_for_host( + cx, + workspace.map_or(HostId::LOCAL, |w| w.target.host_id()), + ); + for (index, st) in session.tabs.iter().enumerate() { + // Asleep stays asleep: nothing is spawned or attached for it, which is + // the point. The one exception is the tab the window opens onto — a + // tab on screen is awake, so that one is woken here, by the same + // restore every other tab is getting. + if st.hibernated && index != session.active { + tabs.push(asleep_tab(st, home.clone())); + continue; + } let Some(pane) = session_to_pane( workspace, owner, @@ -9359,12 +9574,96 @@ fn tabs_from_session( ), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }); } let active = session.active.min(tabs.len().saturating_sub(1)); (tabs, active, dropped) } +/// A tab restored asleep: its place, its name and its group, and nothing +/// running behind them. +fn asleep_tab(st: &SessionTab, home: Option) -> Tab { + let mut tab = Tab::new(Pane::Empty); + tab.name = st.name.clone(); + *tab.sidebar_group.borrow_mut() = st.sidebar_group.clone(); + if let Some(id) = st.tree_id { + tab.tree_id.set(id); + } + tab.asleep = Some(Asleep { + layout: st.pane.clone(), + view: st + .asleep_view + .clone() + .unwrap_or_else(|| view_of_layout(tab.tree_id.get(), &st.pane)), + home, + }); + tab +} + +/// What to call a sleeping tab when the tree had nothing better to say: the +/// first directory in its layout, and the agent in it if there was one. +fn view_of_layout( + id: tty7_core::core::machine::TabId, + layout: &SessionPane, +) -> tty7_core::core::tab_view::TabView { + fn leaves<'a>(pane: &'a SessionPane, out: &mut Vec<&'a SessionPane>) { + match pane { + SessionPane::Leaf { .. } => out.push(pane), + SessionPane::Split { a, b, .. } => { + leaves(a, out); + leaves(b, out); + } + } + } + let mut all = Vec::new(); + leaves(layout, &mut all); + let cwd = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { cwd, .. } => cwd.as_ref().map(|p| p.display().to_string()), + SessionPane::Split { .. } => None, + }); + let agent = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { agent, .. } => *agent, + SessionPane::Split { .. } => None, + }); + tty7_core::core::tab_view::TabView { + id, + name: None, + title: String::new(), + osc_title: None, + cwd, + agent, + status: None, + live: false, + panes: all.len(), + } +} + +/// A sleeping layout as a wake should read it: a native SSH pane dials its +/// host again rather than standing on its old id. That id belongs to a pane +/// whose connection is gone, and the restore reading it would put a local +/// shell in its place. +fn redial_native_ssh(layout: &SessionPane) -> SessionPane { + match layout { + SessionPane::Leaf { + ssh_spec: Some(_), .. + } => { + let mut leaf = layout.clone(); + if let SessionPane::Leaf { pane_id, .. } = &mut leaf { + *pane_id = None; + } + leaf + } + SessionPane::Leaf { .. } => layout.clone(), + SessionPane::Split { axis, ratio, a, b } => SessionPane::Split { + axis: *axis, + ratio: *ratio, + a: Box::new(redial_native_ssh(a)), + b: Box::new(redial_native_ssh(b)), + }, + } +} + fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool { !(window_is_remote && leaf_is_native_ssh) } @@ -11156,6 +11455,7 @@ mod ssh_rebuild_gpui_tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; app.apply_layout_delta( &LayoutDelta::TabRestructured { tab, pane: None }, @@ -12668,3 +12968,112 @@ mod tab_focus_memory_tests { }); } } + +#[cfg(test)] +mod hibernate_gpui_tests { + use gpui::TestAppContext; + + use crate::core::session::SessionPane; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::pane::Pane; + + fn leaf_pane_id(pane: &SessionPane) -> Option { + match pane { + SessionPane::Leaf { pane_id, .. } => *pane_id, + SessionPane::Split { .. } => None, + } + } + + /// Asleep, a tab holds nothing running and still holds its place: the + /// pane id the wake restores from, the name it had on screen, and a seat in + /// the sync that tells the machine it is asleep rather than gone. + #[gpui::test] + fn a_sleeping_tab_keeps_its_place_its_pane_and_its_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + let pane_id = app.tabs[1] + .pane + .first_leaf() + .and_then(|slot| slot.terminal().cloned()) + .expect("tab 1 has a pane") + .read(cx) + .pane_id; + let (before, _) = app.tabs[1].label_view(Some(window), cx); + + app.put_to_sleep(1, window, cx); + + assert_eq!(app.tabs.len(), 2, "sleeping is not closing"); + let tab = &app.tabs[1]; + assert!(tab.is_asleep()); + assert!(matches!(tab.pane, Pane::Empty)); + assert!(tab.pane.terminals().is_empty(), "nothing is left running"); + assert_eq!(tab.asleep_layout().and_then(leaf_pane_id), Some(pane_id)); + let (after, _) = tab.label_view(Some(window), cx); + assert_eq!( + format!("{:?}", after.label()), + format!("{:?}", before.label()), + "it is called what it was called while awake" + ); + assert!(!after.live); + + let (desired, _, held) = crate::ui::tree_sync::desired_tabs(app, cx); + assert!(held.is_empty(), "a sleeping tab is not a tab gone missing"); + assert_eq!(desired.len(), 2); + assert!(!desired[0].hibernated); + assert!(desired[1].hibernated); + match &desired[1].root { + crate::ui::tree_sync::DesiredNode::Leaf { pane, .. } => assert_eq!(*pane, pane_id), + other => panic!("expected the one leaf, got {other:?}"), + } + }); + } + + /// Looking at a tab is what wakes it, so the window has to keep one awake + /// to look at; the tab shown in a sleeping one's place is the awake tab + /// used last. + #[gpui::test] + fn the_last_awake_tab_has_nothing_to_hand_the_window_to(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update_in(&mut vcx, |app, window, cx| { + app.tabs[1].last_used.set(5); + app.tabs[2].last_used.set(9); + assert_eq!(app.awake_tab_besides(0), Some(2), "the one used last"); + + app.put_to_sleep(2, window, cx); + assert_eq!(app.awake_tab_besides(0), Some(1)); + + app.put_to_sleep(1, window, cx); + assert_eq!(app.awake_tab_besides(0), None); + assert!( + !app.can_hibernate_tab(0, cx), + "the last awake tab stays awake" + ); + assert!( + !app.can_hibernate_tab(1, cx), + "nor is a sleeping tab put to sleep twice" + ); + }); + } + + /// Closing a sleeping tab closes it like any other, and what Reopen Closed + /// Tab gets back is the layout it slept with — not the empty stand-in. + #[gpui::test] + fn a_sleeping_tab_closes_into_what_it_would_have_woken_as(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + app.put_to_sleep(1, window, cx); + let layout_id = app.tabs[1].asleep_layout().and_then(leaf_pane_id); + assert!(layout_id.is_some()); + + app.close_tab(1, window, cx); + + assert_eq!(app.tabs.len(), 1); + let closed = app.closed.last().expect("the closed tab is remembered"); + assert_eq!(leaf_pane_id(&closed.pane), layout_id); + assert!(!closed.hibernated, "reopening brings it back awake"); + }); + } +} diff --git a/src/ui/home.rs b/src/ui/home.rs index 3f972977..6f2903de 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -495,6 +495,8 @@ mod tests { name: Some("build".into()), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("build")); @@ -506,6 +508,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -514,6 +518,8 @@ mod tests { name: Some(" ".into()), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -525,6 +531,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, ratio: 0.5, @@ -541,6 +549,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); @@ -548,6 +558,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/")), }; assert_eq!(closed_tab_label(&root), None); @@ -559,6 +571,8 @@ mod tests { name: Some("a".repeat(40)), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; let label = closed_tab_label(&tab).unwrap(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 93a7e79a..a26c80ee 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1528,6 +1528,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdForkSession => "Fork Session", L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab", L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread", + L10nKey::CmdHibernateTab => "Hibernate Tab", + L10nKey::CmdHibernateTabSubtitle => { + "stop its processes to free memory; selecting it wakes it" + } L10nKey::CmdClosePaneTab => "Close Pane / Tab", L10nKey::CmdCloseWindow => "Close Window", L10nKey::CmdCloseWindowSubtitle => "shells keep running", @@ -1921,6 +1925,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsNoneLower => "none", L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool", L10nKey::TabContextMarkUnread => "Mark as Unread", + L10nKey::TabContextHibernate => "Hibernate", + L10nKey::TabContextWake => "Wake", + L10nKey::TabTooltipAsleep => "Hibernated — select to wake", + L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started", } } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 1be8a435..7d3dc051 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1592,6 +1592,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdForkSession => "セッションをフォーク", L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク", L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク", + L10nKey::CmdHibernateTab => "タブを休止", + L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰", L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる", L10nKey::CmdCloseWindow => "ウィンドウを閉じる", L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続", @@ -1997,6 +1999,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "なし", L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール", L10nKey::TabContextMarkUnread => "未読としてマーク", + L10nKey::TabContextHibernate => "休止", + L10nKey::TabContextWake => "復帰", + L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰", + L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません", }) } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 196ff2c2..9517a836 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1085,6 +1085,10 @@ l10n_keys! { TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, + TabContextHibernate, + TabContextWake, + TabTooltipAsleep, + TabWakeFailed, RemoteStripDisconnected, RemoteStripConnecting, RemoteStripReconnecting, @@ -1230,6 +1234,8 @@ l10n_keys! { CmdForkSession, CmdForkSessionSubtitle, CmdMarkTabAsUnread, + CmdHibernateTab, + CmdHibernateTabSubtitle, CmdClosePaneTab, CmdCloseWindow, CmdCloseWindowSubtitle, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index c28fab39..a501716e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1435,6 +1435,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdForkSession => "Fork 会话", L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页", L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读", + L10nKey::CmdHibernateTab => "休眠标签页", + L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒", L10nKey::CmdClosePaneTab => "关闭窗格/标签页", L10nKey::CmdCloseWindow => "关闭窗口", L10nKey::CmdCloseWindowSubtitle => "shell 保持运行", @@ -1816,6 +1818,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "无", L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具", L10nKey::TabContextMarkUnread => "标记为未读", + L10nKey::TabContextHibernate => "休眠", + L10nKey::TabContextWake => "唤醒", + L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒", + L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动", }) } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6d3ec02f..e9c93fe8 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -325,6 +325,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("CloseTabsToTheRight", ""), ("CopyWorkingDirectory", ""), ("MarkTabUnread", ""), + ("HibernateTab", ""), ("ForkAgentSession", ""), ("ForkAgentSessionRight", ""), ("ForkAgentSessionLeft", ""), @@ -614,6 +615,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::TabsPanes, t(L10nKey::CmdMarkTabAsUnread).to_string(), ), + "HibernateTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdHibernateTab).to_string(), + ), "ReopenClosedTab" => ( CommandGroup::TabsPanes, t(L10nKey::CmdReopenClosedTab).to_string(), @@ -1359,6 +1364,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None), "CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None), "MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None), + "HibernateTab" => KeyBinding::new(keystroke, HibernateTab, None), "ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None), "ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None), "ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None), diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index 8756fb6f..57e57c69 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -49,6 +49,15 @@ impl LocalLink { link.client.as_ref().filter(|c| c.is_connected()).cloned() } + /// Whether the local daemon advertised `feature` on its control hello — + /// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports) + /// for this machine. `false` while the link is down, for the same reason. + pub fn supports(cx: &App, feature: &str) -> bool { + cx.try_global::() + .and_then(|link| link.client.as_ref()) + .is_some_and(|c| c.is_connected() && c.hello().has_feature(feature)) + } + /// Drops the cached client without waiting for its reader to notice. /// /// `ControlClient::is_connected` only flips once the reader sees EOF, so diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 17daf72a..90245d01 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -935,6 +935,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }; assert!(apply( &mut machine, diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a673e772..334edd3e 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -33,6 +33,7 @@ pub enum CommandKind { CloseTabsToTheRight, CopyWorkingDirectory, MarkTabUnread, + HibernateTab, ForkAgentSession, CopyAgentSessionId, ResetFontSize, @@ -141,6 +142,7 @@ impl CommandKind { CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", MarkTabUnread => "mark-tab-unread", + HibernateTab => "hibernate-tab", ForkAgentSession => "fork-agent-session", CopyAgentSessionId => "copy-agent-session-id", ResetFontSize => "reset-font-size", @@ -253,6 +255,7 @@ impl CommandKind { CloseTabsToTheRight => "CloseTabsToTheRight", CopyWorkingDirectory => "CopyWorkingDirectory", MarkTabUnread => "MarkTabUnread", + HibernateTab => "HibernateTab", ForkAgentSession => "ForkAgentSession", CopyAgentSessionId => "CopyAgentSessionId", ResetFontSize => "ResetFontSize", @@ -474,6 +477,8 @@ impl Command { Command::localized(L10nKey::CmdForkSession, ForkAgentSession) .with_subtitle(t(L10nKey::CmdForkSessionSubtitle)), Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread), + Command::localized(L10nKey::CmdHibernateTab, HibernateTab) + .with_subtitle(t(L10nKey::CmdHibernateTabSubtitle)), Command::localized(L10nKey::CmdClosePaneTab, ClosePane), Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs), Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 0d6a7848..7ef995d4 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -463,7 +463,8 @@ impl Tty7App { let tab = &self.tabs[i]; let is_active = i == active; let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let git_cwd = git_click(tab, window, cx); @@ -478,6 +479,13 @@ impl Tty7App { } else { 0. }; + // The sleep mark takes the zoom mark's size; a sleeping tab + // has no panes on screen to zoom, so the two never share a row. + let zoom_extra = if asleep { + zoom_extra + row_metrics::ZOOM + row_metrics::GAP + } else { + zoom_extra + }; // Elision is measured against this budget so the label and // branch never wrap or overflow into CSS truncation. let label_avail = @@ -889,6 +897,9 @@ impl Tty7App { s.text_color(cx.theme().sidebar_foreground) .hover(|s| s.bg(gpui::rgb(sf.hover))) }) + // Faded as well as marked, the way the strip's chip is: the + // tabs holding nothing should be the quiet ones in the column. + .when(asleep, |s| s.opacity(0.6)) .when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| { s.opacity(0.75) }) @@ -937,6 +948,9 @@ impl Tty7App { .when(zoomed, |row| { row.child(self.zoom_mark(("sidebar-zoom", i), cx)) }) + .when(asleep, |row| { + row.child(self.sleep_mark(("sidebar-asleep", i), cx)) + }) .child(label_region) .when(show_badges && badge_pos < 9, |row| { row.child( diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 769ea1cc..4f0acd3c 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -13,9 +13,10 @@ use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ CloseActiveTab, CloseOtherTabs, CloseTabsToTheRight, CopyAgentSessionId, CopyWorkingDirectory, - ForkAgentSession, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, SelectWorkspace1, - SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6, - SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette, + ForkAgentSession, HibernateTab, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, + SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, + SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, + TogglePalette, }; use crate::core::config::{Config, RightPanelTab, SidebarGrouping}; use crate::core::group_key::GroupKey; @@ -1486,6 +1487,24 @@ impl Tty7App { /// Drawn in the tab entry rather than on the pane so it reads from either /// tab surface, and so it says something about the tabs you are *not* /// looking at — the zoom outlives a switch away from them. + /// The mark a sleeping tab carries (#762), in the slot the zoom mark uses: + /// leading, beside the other state marks, where the pointer that comes to + /// read it does not cover it. + pub(crate) fn sleep_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { + let tip = SharedString::from(t(L10nKey::TabTooltipAsleep)); + div() + .id(id) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .size(px(16.)) + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::Moon).size(px(11.))) + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) + .into_any_element() + } + pub(crate) fn zoom_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { let tip = chord_tooltip(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx); div() @@ -1647,6 +1666,34 @@ impl Tty7App { ); } + // Sleep and wake (#762). Waking is also what selecting the tab does; + // the item is here for waking one without leaving the tab on screen. + // Hibernate is offered only where the machine can do it, and greyed + // out for a tab it cannot be done to right now — the last one awake, + // or one still connecting — rather than vanishing from under the + // pointer that came looking for it. + if tab.is_some_and(|t| t.is_asleep()) { + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabContextWake)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.wake_tab(index, window, cx)); + } + })); + } else if crate::ui::tree_sync::can_hibernate_on(cx, this.workspace) { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::TabContextHibernate)) + .action(Box::new(HibernateTab)) + .disabled(!this.can_hibernate_tab(index, cx)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.hibernate_tab(index, window, cx)); + } + }), + ); + } + // Where this tab sits, and where it could be put instead. // // Laid out flat rather than behind a "Move to Group ▸" submenu: there @@ -1958,7 +2005,8 @@ impl Tty7App { let label = self.tab_label(tab, i, Some(window), cx); let full_title = self.tab_title_tooltip(tab, i, Some(window), cx); let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let zoomed = self.tab_is_zoomed(i); @@ -2038,6 +2086,9 @@ impl Tty7App { s.text_color(cx.theme().muted_foreground) .hover(|s| s.bg(cx.theme().muted)) }) + // Faded as well as marked: a row of chips is read at a glance, + // and the tabs holding nothing should be the quiet ones. + .when(asleep, |s| s.opacity(0.6)) .when(dragged, |s| s.opacity(0.75)) .child( canvas( @@ -2096,6 +2147,9 @@ impl Tty7App { .when(zoomed, |chip| { chip.child(self.zoom_mark(("tab-zoom", i), cx)) }) + .when(asleep, |chip| { + chip.child(self.sleep_mark(("tab-asleep", i), cx)) + }) .child(label_region) .when(show_badges && i < 9, |chip| { chip.child( diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index be7f0ef3..b98e70b7 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -51,6 +51,18 @@ fn classify_tree_link(client: Option>) -> TreeLink { } } +/// Whether the machine holding `client_ws` can put one of its tabs to sleep. +/// A machine that cannot would take the mark and leave the shells running, or +/// refuse it outright, so a window never offers to on its behalf. +pub(crate) fn can_hibernate_on(cx: &App, client_ws: WorkspaceId) -> bool { + let feature = tty7_core::daemon::control::feature::TAB_HIBERNATE; + let host = WorkspaceStore::host_of(cx, client_ws); + match host.is_local() { + true => crate::ui::local_link::LocalLink::supports(cx, feature), + false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature), + } +} + fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { WorkspaceStore::all(cx) .get(client_ws) @@ -65,6 +77,9 @@ pub(crate) struct DesiredTab { pub name: Option, pub group: Option, pub root: DesiredNode, + /// The tab is asleep, and `root` is the layout it will wake into — the + /// same pane ids the machine already holds, none of them running. + pub hibernated: bool, } #[derive(Debug, Clone)] @@ -120,7 +135,15 @@ pub(crate) fn desired_tabs( let mut active = None; let mut held = Vec::new(); for (index, tab) in app.tabs.iter().enumerate() { - let Some(root) = desired_node(&tab.pane, remote, cx) else { + // A sleeping tab has nothing on screen to read its layout off, so it + // is read off what it will wake into: the same panes, by the same ids, + // which is what keeps the diff below from taking it for a tab that + // lost them all. + let root = match tab.asleep_layout() { + Some(layout) => desired_node_from_session(layout, remote), + None => desired_node(&tab.pane, remote, cx), + }; + let Some(root) = root else { if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { held.push(tab.tree_id.get()); } @@ -135,11 +158,66 @@ pub(crate) fn desired_tabs( name: tab.name.clone(), group: tab.sidebar_group.borrow().as_ref().map(GroupKey::encode), root, + hibernated: tab.asleep_layout().is_some(), }); } (out, active, held) } +/// [`desired_node`] for a layout that is only a record — a sleeping tab's. +/// A leaf with no id is one the machine never had, and a native SSH leaf in a +/// remote window lives on this client rather than on the window's machine; +/// neither is the machine's to hold, exactly as for a live tab. +fn desired_node_from_session(layout: &SessionPane, remote_window: bool) -> Option { + match layout { + SessionPane::Leaf { + cwd, + pane_id, + shell, + ssh_spec, + agent, + agent_session_id, + agent_launch_argv, + } => { + let pane = (*pane_id)?; + if remote_window && ssh_spec.is_some() { + return None; + } + Some(DesiredNode::Leaf { + pane, + seed: PaneSeed { + pane, + cwd: cwd.as_ref().map(|p| p.to_string_lossy().into_owned()), + ssh_spec: ssh_spec.clone(), + agent: agent.map(|agent| AgentFacts { + agent, + session_id: agent_session_id.clone(), + launch_argv: agent_launch_argv.clone(), + status: None, + }), + shell: shell.clone(), + }, + }) + } + SessionPane::Split { axis, ratio, a, b } => { + let left = desired_node_from_session(a, remote_window); + let right = desired_node_from_session(b, remote_window); + match (left, right) { + (Some(a), Some(b)) => Some(DesiredNode::Split { + axis: match axis { + crate::core::session::SessionAxis::Horizontal => TreeAxis::Horizontal, + crate::core::session::SessionAxis::Vertical => TreeAxis::Vertical, + }, + ratio: *ratio, + a: Box::new(a), + b: Box::new(b), + }), + (one, other) => one.or(other), + } + } + } +} + fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { match pane { Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), @@ -504,6 +582,13 @@ fn create_tab( group: want.group.clone(), }); } + if want.hibernated { + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: true, + }); + } mirror.tabs.insert( index.min(mirror.tabs.len()), TreeTab { @@ -511,6 +596,7 @@ fn create_tab( name: want.name.clone(), sidebar_group: want.group.clone(), root, + hibernated: want.hibernated, }, ); mirror.active = Some(want.id); @@ -565,6 +651,18 @@ fn reconcile_tab( group: want.group.clone(), }); } + // Ahead of anything structural. Going to sleep changes nothing else + // about the tab; waking swaps every pane for its successor, which may + // take a close-and-recreate below — and the mark has to come off the + // tab that exists now, before that one is gone. + if tab.hibernated != want.hibernated { + tab.hibernated = want.hibernated; + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: want.hibernated, + }); + } } let desired_root = want.root.to_pane_node(); @@ -1584,14 +1682,18 @@ pub(crate) fn session_from_tree( ws: &tty7_core::core::machine::Workspace, panes: &[PaneRecord], ) -> Session { + let views = tty7_core::core::tab_view::tab_views_of(ws, panes); let tabs: Vec = ws .tabs .iter() - .map(|tab| SessionTab { + .zip(views) + .map(|(tab, view)| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), sidebar_group: tab.sidebar_group.as_deref().and_then(GroupKey::decode), pane: session_pane_from_node(&tab.root, panes), + hibernated: tab.hibernated, + asleep_view: tab.hibernated.then_some(view), }) .collect(); let active = ws @@ -2648,6 +2750,13 @@ impl Tty7App { } true } + // A tab arriving asleep, or one that someone else put to sleep or + // woke, is not a shape to graft onto this window: building it here + // would spawn what is meant to stay stopped, or leave running what + // was just stopped. Saying the delta did not apply re-pulls the + // tree, and the restore that follows is the one path that already + // knows what a sleeping tab is. + LayoutDelta::TabCreated { tab, .. } if tab.hibernated => false, LayoutDelta::TabCreated { at, tab } => { self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx) } @@ -2659,6 +2768,7 @@ impl Tty7App { .and_then(|id| index_of(&self.tabs, id)) .unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1))); self.maximized = None; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } true @@ -2690,6 +2800,7 @@ impl Tty7App { true } LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) { + Some(index) if tab.hibernated || self.tabs[index].is_asleep() => false, Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), None => false, }, @@ -2716,6 +2827,7 @@ impl Tty7App { } self.maximized = None; self.active = index; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } @@ -3441,6 +3553,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!( @@ -4079,12 +4192,14 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, TreeTab { id: failed, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 2 }, + hibernated: false, }, ], active: Some(put_up), @@ -4169,12 +4284,14 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 11 }, + hibernated: false, }, TreeTab { id: theirs.1, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 12 }, + hibernated: false, }, ], active: Some(theirs.0), @@ -4309,6 +4426,7 @@ mod tests { name: None, group: None, root, + hibernated: false, } } @@ -4973,6 +5091,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!(apply_to_mirror( &mut watcher, @@ -4998,6 +5117,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }, pane: None, }, @@ -5067,6 +5187,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5157,6 +5278,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 7 }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5186,6 +5308,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }], active_tab: Some(TabId::new()), ..Default::default() @@ -5215,4 +5338,247 @@ mod tests { "got {ops:?}" ); } + + fn asleep(id: TabId, root: DesiredNode) -> DesiredTab { + DesiredTab { + hibernated: true, + ..tab(id, root) + } + } + + /// Going to sleep changes nothing about the tab's shape — the same panes, + /// by the same ids — so the only thing the machine hears is the mark. A + /// tab that lost its panes would have been a close, and the close would + /// have taken every record the wake needs with it. + #[test] + fn a_tab_going_to_sleep_says_so_and_nothing_else() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let root = || split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)); + diff( + ws, + &mut mirror, + &[tab(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + + let ops = diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }] + ); + assert!(mirror.tabs[0].hibernated); + assert!( + diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[] + ) + .is_empty(), + "a tab that stays asleep is not told again" + ); + } + + /// Waking swaps each stopped pane for its successor. The mark comes off + /// first, while the tab it belongs to is certainly still the one the + /// machine has. + #[test] + fn a_waking_tab_clears_its_mark_before_its_panes_are_replaced() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[asleep(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, leaf(9))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: false, + }, + ControlRequest::PaneReplace { + workspace: ws, + old: 1, + new: seed(9), + }, + ] + ); + assert!(!mirror.tabs[0].hibernated); + assert_converged(&mirror, &want); + } + + /// A window writing a sleeping tab onto a machine that lost it — a store + /// wiped under a remote workspace — writes it back asleep, not as a tab + /// the next restore would spawn. + #[test] + fn a_tab_created_asleep_is_marked_as_it_is_created() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let ops = diff( + ws, + &mut mirror, + &[asleep(id, leaf(4))], + Some(id), + SyncScope::Full, + &[], + ); + assert!( + matches!(ops.first(), Some(ControlRequest::TabCreate { .. })), + "{ops:?}" + ); + assert!( + ops.contains(&ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }), + "{ops:?}" + ); + assert!(mirror.tabs[0].hibernated); + } + + /// The window reads a sleeping tab's layout off the record it kept, and + /// has to hand the machine the very tab the machine already holds: same + /// ids, same seeds, same shape. + #[test] + fn a_sleeping_layout_reads_back_as_the_tree_it_came_from() { + use crate::core::session::SessionAxis; + let leaf_of = |pane: u64, cwd: &str| SessionPane::Leaf { + cwd: Some(std::path::PathBuf::from(cwd)), + pane_id: Some(pane), + shell: None, + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some(format!("sess-{pane}")), + agent_launch_argv: None, + }; + let layout = SessionPane::Split { + axis: SessionAxis::Horizontal, + ratio: 0.3, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(leaf_of(4, "/b")), + }; + let node = desired_node_from_session(&layout, false).expect("both leaves have ids"); + assert_eq!( + node.to_pane_node(), + PaneNode::Split { + axis: TreeAxis::Horizontal, + ratio: 0.3, + a: Box::new(PaneNode::Leaf { pane: 3 }), + b: Box::new(PaneNode::Leaf { pane: 4 }), + } + ); + let seed = node.seed_of(4).expect("pane 4 is a leaf"); + assert_eq!(seed.cwd.as_deref(), Some("/b")); + assert_eq!( + seed.agent.as_ref().and_then(|a| a.session_id.as_deref()), + Some("sess-4"), + "the session a wake resumes stays on the record" + ); + + // A leaf the machine never had is left out, as for a live tab. + let unknown = SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(SessionPane::Leaf { + cwd: None, + pane_id: None, + shell: None, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }), + }; + assert_eq!( + desired_node_from_session(&unknown, false).map(|n| n.to_pane_node()), + Some(PaneNode::Leaf { pane: 3 }) + ); + } + + /// A restore reads the mark off the tree: the sleeping tab keeps its pane + /// ids — they are what the wake restores from — and comes with a name read + /// off the tree's records, since nothing in it will be running to say one. + #[test] + fn a_restore_brings_a_sleeping_tab_back_asleep_and_named() { + let (awake, sleeping) = (TabId::new(), TabId::new()); + let ws = tty7_core::core::machine::Workspace { + tabs: vec![ + TreeTab { + id: awake, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + hibernated: false, + }, + TreeTab { + id: sleeping, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 2 }, + hibernated: true, + }, + ], + active_tab: Some(awake), + ..Default::default() + }; + let panes = vec![ + PaneRecord { + cwd: Some("/work".into()), + live: true, + ..PaneRecord::new(1) + }, + PaneRecord { + cwd: Some("/work/api".into()), + osc_title: Some("fixing the switcher".into()), + live: false, + ..PaneRecord::new(2) + }, + ]; + + let session = session_from_tree(&ws, &panes); + assert!(!session.tabs[0].hibernated); + assert!(session.tabs[0].asleep_view.is_none()); + let tab = &session.tabs[1]; + assert!(tab.hibernated); + match &tab.pane { + SessionPane::Leaf { pane_id, cwd, .. } => { + assert_eq!(*pane_id, Some(2)); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); + } + _ => panic!("leaf"), + } + let view = tab.asleep_view.as_ref().expect("a sleeping tab is named"); + assert_eq!(view.osc_title.as_deref(), Some("fixing the switcher")); + assert!(!view.live); + } }