mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-01 00:01:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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":[…]}` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -30,6 +30,7 @@ actions!(
|
||||
RevealLinkUnderPointer,
|
||||
CopyLinkPathUnderPointer,
|
||||
MarkTabUnread,
|
||||
HibernateTab,
|
||||
ForkAgentSession,
|
||||
ForkAgentSessionRight,
|
||||
ForkAgentSessionLeft,
|
||||
|
||||
+411
-2
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => "タブを復帰できませんでした:どのペインも起動できません",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => "无法唤醒标签页:其中的窗格都无法启动",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user