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
This commit is contained in:
l0ng-ai
2026-09-25 16:41:12 +08:00
committed by GitHub
parent 9f034558c0
commit bd0dd22bfa
25 changed files with 1378 additions and 15 deletions
+13
View File
@@ -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
+34
View File
@@ -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<u64> = 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,
+33 -3
View File
@@ -140,9 +140,14 @@ pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> 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}");
}
}
+3
View File
@@ -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,
+148
View File
@@ -158,6 +158,19 @@ pub struct Tab {
#[serde(default)]
pub sidebar_group: Option<String>,
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<SubscriberId>,
) -> io::Result<Vec<u64>> {
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);
}
}
+10
View File
@@ -54,6 +54,16 @@ pub struct SessionTab {
pub sidebar_group: Option<crate::core::group_key::GroupKey>,
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
/// 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<crate::core::tab_view::TabView>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+15
View File
@@ -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<String>,
},
/// 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 { .. }
+20
View File
@@ -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<crate::daemon::control::PaneAgentState> {
let panes: Vec<Arc<DaemonPane>> = 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,
+185
View File
@@ -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<R: Read>(
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<PaneAgentState> {
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<MachineStore>,
stopped: std::sync::Mutex<Vec<(u64, bool)>>,
}
impl PaneDirectory for SleepRecorder {
fn pane_count(&self) -> u64 {
0
}
fn panes(&self) -> Vec<PaneInfo> {
Vec::new()
}
fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs {
Default::default()
}
fn agent_states(&self) -> Vec<PaneAgentState> {
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)]
+2 -2
View File
@@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
| 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":[…]}` |
+1 -1
View File
@@ -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` |
+1
View File
@@ -30,6 +30,7 @@ actions!(
RevealLinkUnderPointer,
CopyLinkPathUnderPointer,
MarkTabUnread,
HibernateTab,
ForkAgentSession,
ForkAgentSessionRight,
ForkAgentSessionLeft,
+411 -2
View File
@@ -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<Asleep>,
}
/// 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<std::path::PathBuf>,
}
#[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<crate::core::cli_agent::CLIAgent> {
self.asleep.as_ref().and_then(|a| a.view.agent)
}
fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> {
match self.last_focused {
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
@@ -585,6 +621,14 @@ impl Tab {
Option<std::path::PathBuf>,
) {
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<Self>) {
// 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<usize> {
(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<Self>,
) {
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<Self>) {
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<Self>,
) -> 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<Self>) {
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<usize> {
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<Tab> = 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<std::path::PathBuf>) -> 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<u64> {
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");
});
}
}
+14
View File
@@ -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();
+8
View File
@@ -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",
}
}
+6
View File
@@ -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 => "タブを復帰できませんでした:どのペインも起動できません",
})
}
+6
View File
@@ -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,
+6
View File
@@ -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 => "无法唤醒标签页:其中的窗格都无法启动",
})
}
+6
View File
@@ -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<KeyBinding> {
"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),
+9
View File
@@ -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::<LocalLink>()
.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
+1
View File
@@ -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,
+5
View File
@@ -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),
+15 -1
View File
@@ -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(
+58 -4
View File
@@ -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<gpui::ElementId>, 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<gpui::ElementId>, 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(
+368 -2
View File
@@ -51,6 +51,18 @@ fn classify_tree_link(client: Option<Arc<ControlClient>>) -> 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<String>,
pub group: Option<String>,
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<DesiredNode> {
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<SessionTab> = 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);
}
}