Files
tty7/src/ui/tree_sync.rs
T
ayamirandl0ng-ai 59dbe83913 feat(macos): add default terminal integration (#818)
* feat(macos): add default terminal integration

* fix(macos): route external opens through the layout pull

Five holes in the LaunchServices path, all on the way from a URL to a tab.

The `ssh:` arm handed the raw URL back to `parse_quick_connect`, which
reads a bare `user@host:port` typed into Quick Connect. Everything a URL
carries past the authority landed in the wrong field: `ssh://h:2200/`
parsed its port as `2200/` and was dropped on the floor, `ssh://h/srv`
became the host `h/srv`, and the percent escapes `url` was added for were
never decoded. Read the authority off the parsed URL instead.

`x-man-page://3/printf` is Apple's sectioned form, and taking the host as
the page name ran `man 3`, which asks the user what page they wanted.
Section and page are now both carried.

A window that is pulling its layout is one `Adopt::IfEmpty` will not adopt
into, so a tab inserted while the pull is out comes back as the whole
workspace — the failure `then_open` already exists to avoid. Both the
script/man path and the SSH path inserted straight into a freshly restored
window, so `then_open` becomes a list of parked requests and carries a
command or an SSH link as well as a folder. A cold `ssh://` link also went
through `open_at` directly, claiming a fresh workspace and leaving the
restored one detached and unannounced; it takes the shared restore now.

`new_tab_running` wrote the command whether or not a tab opened, so a
failed spawn typed a script path and a newline into whatever pane was
focused before — a shell mid-line, or an agent.

Left alone deliberately: an `ssh://` link still connects without a
confirmation, which is a product call rather than a defect.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-09 17:05:58 +08:00

4982 lines
181 KiB
Rust

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::Arc;
use gpui::{App, Global};
use gpui_component::WindowExt as _;
use tty7_core::core::machine::{
AgentFacts, Axis as TreeAxis, LayoutDelta, Machine, PaneNode, PaneRecord, PaneSeed, Side,
Tab as TreeTab, TabId, Workspace,
};
use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk};
use tty7_core::host::HostId;
use crate::core::group_key::GroupKey;
use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t};
use crate::ui::pane::{Pane, PaneSlot};
pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option<Arc<ControlClient>> {
if host.is_local() {
crate::ui::local_link::LocalLink::client(cx)
} else {
crate::ui::remote_connect::HostLinks::get(cx, host)
.map(|h| Arc::clone(h.client()))
.filter(|c| c.is_connected())
}
}
pub(crate) enum TreeLink {
Ready(Arc<ControlClient>),
Unserved,
Down,
}
pub(crate) fn tree_control_for(cx: &mut App, host: HostId) -> TreeLink {
classify_tree_link(control_for(cx, host))
}
fn classify_tree_link(client: Option<Arc<ControlClient>>) -> TreeLink {
match client {
Some(client)
if client
.hello()
.has_feature(tty7_core::daemon::control::feature::MACHINE_TREE) =>
{
TreeLink::Ready(client)
}
Some(_) => TreeLink::Unserved,
None => TreeLink::Down,
}
}
fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId {
WorkspaceStore::all(cx)
.get(client_ws)
.and_then(|w| w.host.as_ref())
.map(|r| r.workspace)
.unwrap_or(client_ws)
}
#[derive(Debug, Clone)]
pub(crate) struct DesiredTab {
pub id: TabId,
pub name: Option<String>,
pub group: Option<String>,
pub root: DesiredNode,
}
#[derive(Debug, Clone)]
pub(crate) enum DesiredNode {
Leaf {
pane: u64,
seed: PaneSeed,
},
Split {
axis: TreeAxis,
ratio: f32,
a: Box<DesiredNode>,
b: Box<DesiredNode>,
},
}
impl DesiredNode {
fn first_leaf(&self) -> (&u64, &PaneSeed) {
match self {
DesiredNode::Leaf { pane, seed } => (pane, seed),
DesiredNode::Split { a, .. } => a.first_leaf(),
}
}
fn to_pane_node(&self) -> PaneNode {
match self {
DesiredNode::Leaf { pane, .. } => PaneNode::Leaf { pane: *pane },
DesiredNode::Split { axis, ratio, a, b } => PaneNode::Split {
axis: *axis,
ratio: *ratio,
a: Box::new(a.to_pane_node()),
b: Box::new(b.to_pane_node()),
},
}
}
fn seed_of(&self, pane: u64) -> Option<&PaneSeed> {
match self {
DesiredNode::Leaf { pane: p, seed } => (*p == pane).then_some(seed),
DesiredNode::Split { a, b, .. } => a.seed_of(pane).or_else(|| b.seed_of(pane)),
}
}
}
pub(crate) fn desired_tabs(
app: &Tty7App,
cx: &App,
) -> (Vec<DesiredTab>, Option<TabId>, Vec<TabId>) {
let remote = WorkspaceStore::all(cx)
.get(app.workspace)
.is_some_and(|w| w.is_remote());
let mut out = Vec::new();
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 {
if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) {
held.push(tab.tree_id.get());
}
continue;
};
let id = tab.tree_id.get();
if index == app.active {
active = Some(id);
}
out.push(DesiredTab {
id,
name: tab.name.clone(),
group: tab.sidebar_group.borrow().as_ref().map(GroupKey::encode),
root,
});
}
(out, active, held)
}
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(),
Pane::Leaf(PaneSlot::Connecting(_)) | Pane::Empty => false,
Pane::Split { a, b, .. } => {
every_leaf_is_native_ssh(a, cx) && every_leaf_is_native_ssh(b, cx)
}
}
}
fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNode> {
match pane {
Pane::Leaf(PaneSlot::Ready(view)) => {
let view = view.read(cx);
let ssh_spec = view.ssh_spec();
if remote_window && ssh_spec.is_some() {
return None;
}
let agent = view.agent().map(|agent| {
let session = view.agent_session();
AgentFacts {
agent,
session_id: session.as_ref().and_then(|s| s.session_id.clone()),
launch_argv: session.as_ref().and_then(|s| s.launch_argv.clone()),
status: None,
}
});
Some(DesiredNode::Leaf {
pane: view.pane_id,
seed: PaneSeed {
pane: view.pane_id,
cwd: view
.spawnable_cwd()
.map(|p| p.to_string_lossy().into_owned()),
ssh_spec,
agent,
// Only a pane this window spawned knows this; one it
// attached to never saw the command line. The daemon fills
// that gap from its own side, so leaving it empty here
// withholds nothing the tree does not already get.
shell: view.shell_spec(),
},
})
}
Pane::Leaf(PaneSlot::Connecting(pending)) => {
let spawn = &pending.read(cx).spawn;
let pane = spawn.restore_pane?;
let agent = spawn.agent.map(|agent| AgentFacts {
agent,
session_id: spawn.agent_session_id.clone(),
launch_argv: spawn.agent_launch_argv.clone(),
status: None,
});
Some(DesiredNode::Leaf {
pane,
seed: PaneSeed {
pane,
cwd: spawn
.working_directory
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
ssh_spec: None,
agent,
shell: spawn.shell.clone(),
},
})
}
Pane::Split {
axis, a, b, ratio, ..
} => {
let left = desired_node(a, remote_window, cx);
let right = desired_node(b, remote_window, cx);
match (left, right) {
(Some(a), Some(b)) => Some(DesiredNode::Split {
axis: match axis {
gpui::Axis::Horizontal => TreeAxis::Horizontal,
gpui::Axis::Vertical => TreeAxis::Vertical,
},
ratio: ratio.get(),
a: Box::new(a),
b: Box::new(b),
}),
(one, other) => one.or(other),
}
}
Pane::Empty => None,
}
}
/// The records the machine's `register_pane` will mint from the seeds a sync
/// pushes, for the window to mirror itself — they come back to it in nothing
/// it is sent (#612). Its own terminals stand in for the machine's liveness
/// probe: a pane this window holds open is one that registry answers alive, and
/// one still connecting is not yet anyone's to call — later facts settle it.
fn seeded_records(desired: &[DesiredTab], live: impl Fn(u64) -> bool) -> Vec<PaneRecord> {
fn walk(node: &DesiredNode, live: &impl Fn(u64) -> bool, out: &mut Vec<PaneRecord>) {
match node {
DesiredNode::Leaf { pane, seed } => out.push(seed.clone().into_record(live(*pane))),
DesiredNode::Split { a, b, .. } => {
walk(a, live, out);
walk(b, live, out);
}
}
}
let mut out = Vec::new();
for tab in desired {
walk(&tab.root, &live, &mut out);
}
out
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct WsMirror {
pub tabs: Vec<TreeTab>,
pub active: Option<TabId>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SyncScope {
Full,
Additive,
}
pub(crate) fn diff(
workspace: WorkspaceId,
mirror: &mut WsMirror,
desired: &[DesiredTab],
desired_active: Option<TabId>,
scope: SyncScope,
held: &[TabId],
) -> Vec<ControlRequest> {
let mut ops = Vec::new();
if scope == SyncScope::Full {
migrate_panes(workspace, mirror, desired, &mut ops);
let mut index = 0;
while index < mirror.tabs.len() {
let id = mirror.tabs[index].id;
if desired.iter().any(|t| t.id == id) || held.contains(&id) {
index += 1;
continue;
}
let closed = mirror.tabs.remove(index);
ops.push(ControlRequest::TabClose {
workspace,
tab: closed.id,
});
heal_active(mirror, index);
}
}
// The tabs the machine already has are settled first, and only then are the
// new ones built. A pane that left its tab to become one of its own is in
// both halves of that — the machine refuses to register a pane it already
// holds, so the tab it left has to give it up before the tab it is
// becoming can ask for it.
for want in desired {
if let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) {
reconcile_tab(workspace, mirror, at, want, &mut ops);
}
}
for (index, want) in desired.iter().enumerate() {
if mirror.tabs.iter().any(|t| t.id == want.id) {
continue;
}
let at = match scope {
SyncScope::Full => index,
SyncScope::Additive => mirror.tabs.len(),
};
create_tab(workspace, mirror, at, want, &mut ops);
}
if scope == SyncScope::Additive || !held.is_empty() {
return ops;
}
for (index, want) in desired.iter().enumerate() {
let at = mirror
.tabs
.iter()
.position(|t| t.id == want.id)
.expect("every desired tab exists after the passes above");
if at != index {
let tab = mirror.tabs.remove(at);
mirror.tabs.insert(index, tab);
ops.push(ControlRequest::TabMove {
workspace,
tab: want.id,
to: index as u64,
});
}
}
if let Some(active) = desired_active
&& mirror.active != Some(active)
&& mirror.tabs.iter().any(|t| t.id == active)
{
mirror.active = Some(active);
ops.push(ControlRequest::WorkspaceSetActiveTab {
workspace,
tab: active,
});
}
ops
}
/// Carries panes across to the tab that now wants them, before anything else
/// gets a chance to read their old tab as one to close.
///
/// This is a tab dragged into another tab's layout: every pane it brought
/// keeps running and changes tab, and the tab it came from goes away once it
/// has nothing left. Told as `PaneMove`, which is the one op that can say that
/// — closing the old tab and building the new one would say instead that a
/// tab's worth of panes went away and a tab's worth arrived.
///
/// One move per round, because each move changes where the next one can go:
/// a pane may only land beside a pane its destination already holds, so a
/// two-pane tab crosses as its first pane and then the rest beside it. A round
/// that finds nothing left to do ends the pass, which is also what happens
/// when a move cannot be spelled this way at all — the passes below then treat
/// it as the reshape it is.
fn migrate_panes(
workspace: WorkspaceId,
mirror: &mut WsMirror,
desired: &[DesiredTab],
ops: &mut Vec<ControlRequest>,
) {
while let Some((pane, to, axis, first)) = next_migration(mirror, desired) {
let holders = |m: &WsMirror, p: u64| m.tabs.iter().position(|t| t.root.contains(p));
let (Some(from), Some(dest)) = (holders(mirror, pane), holders(mirror, to)) else {
return;
};
// The destination takes the pane before its old tab gives it up, so a
// split that somehow will not take leaves the mirror as it was rather
// than a pane short of the tree the daemon has.
if !mirror.tabs[dest]
.root
.split_leaf(to, pane, axis, 0.5, first)
{
return;
}
// The rest of the bookkeeping `Machine::pane_move` does at the other
// end: a tab that has just lost its last pane is gone, and the active
// tab heals onto whatever took its place.
if mirror.tabs[from].root.remove_leaf(pane).is_none() {
mirror.tabs.remove(from);
heal_active(mirror, from);
}
ops.push(ControlRequest::PaneMove {
workspace,
pane,
to,
axis,
first,
});
}
}
/// The next pane sitting in a tab that no longer wants it, and the pane in the
/// tab that does that it can be put beside.
fn next_migration(mirror: &WsMirror, desired: &[DesiredTab]) -> Option<(u64, u64, TreeAxis, bool)> {
for want in desired {
let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) else {
continue;
};
let root = want.root.to_pane_node();
// Panes the machine has never heard of are splits, not moves: a side
// holding one of those is not a side that can cross over.
let arriving = |node: &PaneNode| {
let ids = node.pane_ids();
!ids.is_empty()
&& ids.iter().all(|p| {
mirror
.tabs
.iter()
.position(|t| t.root.contains(*p))
.is_some_and(|holder| holder != at)
})
};
let settled = |node: &PaneNode| match node {
PaneNode::Leaf { pane } => mirror.tabs[at].root.contains(*pane).then_some(*pane),
_ => None,
};
if let Some(step) = arrival_site(&root, &arriving, &settled) {
return Some(step);
}
}
None
}
/// The split in the shape a tab wants where panes still living in another tab
/// meet a pane that is already here, read as a pane to move and the pane to
/// put it beside.
///
/// The side that is arriving may be a whole subtree — only its first pane
/// crosses on this round, and the ones behind it follow on later rounds, by
/// which time this same reading finds them the sites they want inside it. The
/// side that is staying has to be a single pane, because that is all a move can
/// split. Nothing else can be said in one move: a tab dropped against the outer
/// edge of a layout that is more than one pane deep has to go in above the
/// whole of it, and the passes after this one rebuild the tab instead.
fn arrival_site(
node: &PaneNode,
arriving: &impl Fn(&PaneNode) -> bool,
settled: &impl Fn(&PaneNode) -> Option<u64>,
) -> Option<(u64, u64, TreeAxis, bool)> {
let PaneNode::Split { axis, a, b, .. } = node else {
return None;
};
let first = |side: &PaneNode| side.pane_ids().first().copied();
if let (Some(to), true) = (settled(a), arriving(b)) {
return Some((first(b)?, to, *axis, false));
}
if let (true, Some(to)) = (arriving(a), settled(b)) {
return Some((first(a)?, to, *axis, true));
}
arrival_site(a, arriving, settled).or_else(|| arrival_site(b, arriving, settled))
}
fn heal_active(mirror: &mut WsMirror, removed: usize) {
let named = mirror
.active
.is_some_and(|active| mirror.tabs.iter().any(|t| t.id == active));
if named {
return;
}
if mirror.tabs.is_empty() {
mirror.active = None;
return;
}
mirror.active = Some(mirror.tabs[removed.min(mirror.tabs.len() - 1)].id);
}
fn create_tab(
workspace: WorkspaceId,
mirror: &mut WsMirror,
index: usize,
want: &DesiredTab,
ops: &mut Vec<ControlRequest>,
) {
let (first, seed) = want.root.first_leaf();
ops.push(ControlRequest::TabCreate {
workspace,
at: Some(index as u64),
pane: seed.clone(),
tab: Some(want.id),
});
let mut root = PaneNode::Leaf { pane: *first };
materialize_splits(workspace, &want.root, &mut root, ops);
if want.name.is_some() {
ops.push(ControlRequest::TabRename {
workspace,
tab: want.id,
name: want.name.clone(),
});
}
if want.group.is_some() {
ops.push(ControlRequest::TabSetGroup {
workspace,
tab: want.id,
group: want.group.clone(),
});
}
mirror.tabs.insert(
index.min(mirror.tabs.len()),
TreeTab {
id: want.id,
name: want.name.clone(),
sidebar_group: want.group.clone(),
root,
},
);
mirror.active = Some(want.id);
}
fn materialize_splits(
workspace: WorkspaceId,
want: &DesiredNode,
root: &mut PaneNode,
ops: &mut Vec<ControlRequest>,
) {
let DesiredNode::Split { axis, ratio, a, b } = want else {
return;
};
let (anchor, _) = a.first_leaf();
let (new, seed) = b.first_leaf();
ops.push(ControlRequest::PaneSplit {
workspace,
pane: *anchor,
axis: *axis,
ratio: *ratio,
new: seed.clone(),
first: false,
});
root.split_leaf(*anchor, *new, *axis, *ratio, false);
materialize_splits(workspace, a, root, ops);
materialize_splits(workspace, b, root, ops);
}
fn reconcile_tab(
workspace: WorkspaceId,
mirror: &mut WsMirror,
at: usize,
want: &DesiredTab,
ops: &mut Vec<ControlRequest>,
) {
{
let tab = &mut mirror.tabs[at];
if tab.name != want.name {
tab.name = want.name.clone();
ops.push(ControlRequest::TabRename {
workspace,
tab: want.id,
name: want.name.clone(),
});
}
if tab.sidebar_group != want.group {
tab.sidebar_group = want.group.clone();
ops.push(ControlRequest::TabSetGroup {
workspace,
tab: want.id,
group: want.group.clone(),
});
}
}
let desired_root = want.root.to_pane_node();
if mirror.tabs[at].root == desired_root {
return;
}
if same_shape_and_panes(&mirror.tabs[at].root, &desired_root) {
fix_ratios(
workspace,
want.id,
&mut mirror.tabs[at].root,
&desired_root,
ops,
);
return;
}
let have = mirror.tabs[at].root.pane_ids();
let wanted = desired_root.pane_ids();
let added: Vec<u64> = wanted
.iter()
.copied()
.filter(|p| !have.contains(p))
.collect();
let removed: Vec<u64> = have
.iter()
.copied()
.filter(|p| !wanted.contains(p))
.collect();
let done = match (added.as_slice(), removed.as_slice()) {
([new], []) => try_single_split(workspace, mirror, at, want, &desired_root, *new, ops),
([], []) => try_single_move(workspace, mirror, at, &desired_root, ops),
([], gone) if !gone.is_empty() => {
for pane in gone {
mirror.tabs[at].root.remove_leaf(*pane);
ops.push(ControlRequest::PaneClose {
workspace,
pane: *pane,
});
}
same_shape_and_panes(&mirror.tabs[at].root, &desired_root)
}
([new], [old]) => {
let elsewhere = mirror
.tabs
.iter()
.enumerate()
.any(|(i, t)| i != at && t.root.contains(*new));
let mut predicted = mirror.tabs[at].root.clone();
predicted.replace_leaf(*old, *new);
if !elsewhere && same_shape_and_panes(&predicted, &desired_root) {
let seed = want
.root
.seed_of(*new)
.expect("the added pane is a desired leaf")
.clone();
mirror.tabs[at].root = predicted;
ops.push(ControlRequest::PaneReplace {
workspace,
old: *old,
new: seed,
});
true
} else {
false
}
}
_ => false,
};
if done {
fix_ratios(
workspace,
want.id,
&mut mirror.tabs[at].root,
&desired_root,
ops,
);
return;
}
let closed = mirror.tabs.remove(at);
ops.push(ControlRequest::TabClose {
workspace,
tab: closed.id,
});
heal_active(mirror, at);
create_tab(workspace, mirror, at, want, ops);
}
fn try_single_split(
workspace: WorkspaceId,
mirror: &mut WsMirror,
at: usize,
want: &DesiredTab,
desired_root: &PaneNode,
new: u64,
ops: &mut Vec<ControlRequest>,
) -> bool {
let Some((sibling, axis, ratio, first)) = split_site(desired_root, new) else {
return false;
};
let mut predicted = mirror.tabs[at].root.clone();
if !predicted.split_leaf(sibling, new, axis, ratio, first) {
return false;
}
if !same_shape_and_panes(&predicted, desired_root) {
return false;
}
let seed = want
.root
.seed_of(new)
.expect("the added pane is a desired leaf")
.clone();
mirror.tabs[at].root = predicted;
ops.push(ControlRequest::PaneSplit {
workspace,
pane: sibling,
axis,
ratio,
new: seed,
first,
});
true
}
/// Reshapes a tab that still holds exactly the panes it did, when one pane
/// changing places accounts for the whole difference.
///
/// That is what dragging a pane across the layout is, and it is worth spotting:
/// the fallback for a reshape is to close the tab and build it again, which
/// tells every other reader of the machine that a tab went away and came back
/// when all that happened was a pane sliding sideways.
///
/// A swap of two panes that are not each other's siblings is not one move, and
/// still takes the fallback.
fn try_single_move(
workspace: WorkspaceId,
mirror: &mut WsMirror,
at: usize,
desired_root: &PaneNode,
ops: &mut Vec<ControlRequest>,
) -> bool {
for pane in mirror.tabs[at].root.pane_ids() {
let Some((to, axis, _, first)) = split_site(desired_root, pane) else {
continue;
};
let mut predicted = mirror.tabs[at].root.clone();
if predicted.remove_leaf(pane) != Some(true) {
continue;
}
// The daemon re-splits at a half whatever the wanted ratio is, so the
// mirror has to predict that half; `fix_ratios` settles the rest.
if !predicted.split_leaf(to, pane, axis, 0.5, first)
|| !same_shape_and_panes(&predicted, desired_root)
{
continue;
}
mirror.tabs[at].root = predicted;
ops.push(ControlRequest::PaneMove {
workspace,
pane,
to,
axis,
first,
});
return true;
}
false
}
fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> {
let PaneNode::Split { axis, ratio, a, b } = node else {
return None;
};
match (&**a, &**b) {
(PaneNode::Leaf { pane }, sibling) if *pane == new => {
if let PaneNode::Leaf { pane: s } = sibling {
return Some((*s, *axis, *ratio, true));
}
return None;
}
(sibling, PaneNode::Leaf { pane }) if *pane == new => {
if let PaneNode::Leaf { pane: s } = sibling {
return Some((*s, *axis, *ratio, false));
}
return None;
}
_ => {}
}
if a.contains(new) {
split_site(a, new)
} else if b.contains(new) {
split_site(b, new)
} else {
None
}
}
fn same_shape_and_panes(a: &PaneNode, b: &PaneNode) -> bool {
match (a, b) {
(PaneNode::Leaf { pane: pa }, PaneNode::Leaf { pane: pb }) => pa == pb,
(
PaneNode::Split {
axis: ax,
a: aa,
b: ab,
..
},
PaneNode::Split {
axis: bx,
a: ba,
b: bb,
..
},
) => ax == bx && same_shape_and_panes(aa, ba) && same_shape_and_panes(ab, bb),
_ => false,
}
}
fn fix_ratios(
workspace: WorkspaceId,
tab: TabId,
mirror: &mut PaneNode,
desired: &PaneNode,
ops: &mut Vec<ControlRequest>,
) {
fn walk(
workspace: WorkspaceId,
tab: TabId,
mirror: &mut PaneNode,
desired: &PaneNode,
path: &mut Vec<Side>,
ops: &mut Vec<ControlRequest>,
) {
let (
PaneNode::Split {
ratio: mr,
a: ma,
b: mb,
..
},
PaneNode::Split {
ratio: dr,
a: da,
b: db,
..
},
) = (mirror, desired)
else {
return;
};
if (*mr - *dr).abs() > 1e-4 {
*mr = *dr;
ops.push(ControlRequest::PaneSetRatio {
workspace,
tab,
path: path.clone(),
ratio: *dr,
});
}
path.push(Side::A);
walk(workspace, tab, ma, da, path, ops);
path.pop();
path.push(Side::B);
walk(workspace, tab, mb, db, path, ops);
path.pop();
}
let mut path = Vec::new();
walk(workspace, tab, mirror, desired, &mut path, ops);
}
enum SyncPhase {
Unprimed { dirty: bool, priming: bool },
Primed(WsMirror),
}
struct WsState {
sync: SyncPhase,
queue: VecDeque<ControlRequest>,
inflight: bool,
informed: bool,
epoch: u64,
/// A hydration that failed and still owes this window its layout.
///
/// Whatever the window is showing is not the layout it was told to put up,
/// so nothing may be pushed from it until the pull is retried — an emptied
/// window diffs into "close every tab" and would wipe the layout off the
/// machine, and a window full of dead tabs pushes those back up as truth.
rehydrate: Option<Adopt>,
/// The tabs the window held when the pull that is owed was ordered.
///
/// A `Replace` retry is dropped once the user has filled the window in
/// themselves, and this is what "the user" means. A `Replace` is ordered
/// precisely *because* what the window is showing cannot be trusted — dead
/// panes left by a daemon that came back as a new process, a window emptied
/// for a handoff that was then refused — so the tabs that were already
/// there are the very thing it exists to replace, and can never be the
/// reason to abandon it. Only a tab this debt never saw says the user moved
/// on without us.
///
/// Rewritten by every attempt, and only ever read while `rehydrate` is
/// outstanding, so it always describes the debt currently standing.
owed_over: Vec<TabId>,
/// The tabs the last rebuild was handed and could not put up — on the
/// machine and in the mirror, but not on screen, because no pane in them
/// would start (`tabs_from_session` drops such a tab, and says so).
///
/// Held out of every diff, the way a tab still connecting is: the window
/// cannot speak for them, and its licence to prune must not read their
/// absence as the user closing them. A rebuild that put up some of its tabs
/// earned the licence — it did put a layout up — and used it to `TabClose`
/// exactly the tabs it had failed to rebuild, deleting them off the machine,
/// panes and all (#672). Rewritten by the next rebuild, which either puts
/// them up or fails them again; a tab the machine drops meanwhile drops
/// out here too, so nothing stays held for a tab nobody has.
not_rebuilt: Vec<TabId>,
/// How many pulls in a row this window has owed, which paces the retry.
///
/// Counts consecutive failures, so it is cleared by anything that ends the
/// run: a pull that lands (`finish_hydration`), a prime that lands
/// (`finish_prime` — the machine answered, which is the whole question),
/// and a debt abandoned rather than paid (`take_rehydrate` dropping a
/// `Replace` the user has overtaken). A machine that hiccups once is then
/// asked again promptly, and one that is really gone is not asked in a
/// loop.
///
/// Leaving it standing after the run ends is what makes a *first* failure
/// wait the cap: the count would still be carrying an outage that is over.
rehydrate_attempts: u32,
/// Tabs a launch or a LaunchServices open asked for, waiting for this
/// window's layout.
///
/// Held here rather than opened straight away because a window with a tab
/// in it is one `Adopt::IfEmpty` will not adopt into: the pull would land,
/// decline the layout, and push the single tab back as the whole workspace.
/// Parking it also means a pull that has to be retried still gets the
/// folder opened, on whichever attempt finally lands.
///
/// A list because more than one can be waiting: a launch parks the folder
/// it was given, and the `x-man-page:` or script Finder sent to the same
/// cold start parks its own tab behind it.
then_open: Vec<ParkedOpen>,
/// A name the user typed for a workspace this window is about to create.
///
/// It has to travel with the create rather than follow it as a rename: the
/// workspace does not exist on the machine yet, so a rename sent now is
/// answered `NotFound` and dropped, and the create that runs afterwards
/// names it whatever `fresh_workspace_name` rolled (#618). Consumed by
/// `start_prime`, which spends it instead of the generated name, and
/// cleared by `finish_prime` once the machine has confirmed a name.
chosen_name: Option<String>,
/// Whether this window has already been told why it opened empty.
///
/// The retry is as quiet as the failure was, so a window whose machine
/// never answers re-enters `hydrate` on every `sync_window` and would say
/// the same thing again every fifteen seconds. Saying it once is the
/// point; saying it on a loop is noise. Cleared once a pull lands, so a
/// later outage is still worth a word.
said_why_empty: bool,
}
impl Default for WsState {
fn default() -> Self {
WsState {
sync: SyncPhase::Unprimed {
dirty: false,
priming: false,
},
queue: VecDeque::new(),
inflight: false,
informed: false,
epoch: 0,
rehydrate: None,
owed_over: Vec::new(),
not_rebuilt: Vec::new(),
rehydrate_attempts: 0,
then_open: Vec::new(),
chosen_name: None,
said_why_empty: false,
}
}
}
#[derive(Default)]
pub(crate) struct TreeSync {
windows: HashMap<WorkspaceId, WsState>,
}
impl Global for TreeSync {}
pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) {
let client_ws = app.workspace;
if !cx.has_global::<crate::core::session::WorkspaceStore>() {
return;
}
if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) {
return;
}
let showing: Vec<TabId> = app.tabs.iter().map(|t| t.tree_id.get()).collect();
if let Some(adopt) = take_rehydrate(cx, client_ws, &showing) {
// The app is mid-update here, so the tabs it knows it is showing come
// from `app` itself, not from reading the entity back.
hydrate_with(cx, client_ws, adopt, showing);
return;
}
adopt_tab_ids(app, cx);
let (desired, desired_active, mut held) = desired_tabs(app, cx);
let machine_ws = tree_workspace_id(cx, client_ws);
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
match &mut state.sync {
SyncPhase::Unprimed { dirty, priming } => {
*dirty = true;
if !*priming {
*priming = true;
start_prime(cx, client_ws);
}
}
SyncPhase::Primed(mirror) => {
let scope = if state.informed {
SyncScope::Full
} else {
SyncScope::Additive
};
// The tabs the last rebuild could not put up are the machine's to
// keep: not on screen, so `desired` cannot speak for them, and held
// so their absence is not read as a close.
state
.not_rebuilt
.retain(|id| mirror.tabs.iter().any(|t| t.id == *id));
held.extend(state.not_rebuilt.iter().copied());
let ops = diff(machine_ws, mirror, &desired, desired_active, scope, &held);
if !ops.is_empty() {
let (tabs, active) = (mirror.tabs.clone(), mirror.active);
state.queue.extend(ops);
let host = WorkspaceStore::host_of(cx, client_ws);
crate::ui::machine_mirror::MachineMirrors::note_synced_workspace(
cx, host, machine_ws, tabs, active,
);
let open: Vec<u64> = app
.tabs
.iter()
.flat_map(|t| t.pane.terminals())
.map(|v| v.read(cx).pane_id)
.collect();
crate::ui::machine_mirror::MachineMirrors::note_seeded_panes(
cx,
host,
seeded_records(&desired, |pane| open.contains(&pane)),
);
pump(cx, client_ws);
}
}
}
}
pub(crate) fn on_link_up(cx: &mut App, host: HostId) {
for (workspace, app) in crate::ui::windows::WindowRegistry::open_windows(cx) {
if WorkspaceStore::host_of(cx, workspace) != host {
continue;
}
if let Some(app) = app.upgrade() {
app.update(cx, |app, cx| sync_window(app, cx));
}
}
}
/// Claims a hydration owed to `client_ws`, if one is still outstanding.
/// `showing` is what the window has on screen right now.
///
/// A `Replace` retry is dropped once the user has filled the window in without
/// us: replaying the machine's older layout over work they did in the meantime
/// would be worse than never retrying at all.
///
/// What counts as "without us" is a tab the debt never saw. Judging it by the
/// window merely *having* tabs read every resync of a window that kept its own
/// — a daemon back as a new process, a delta that would not apply, a remote
/// server restarted — as the user having moved on, when those tabs are the
/// stale ones the `Replace` was ordered to sweep away. The retry was abandoned
/// on its first attempt, every time, and the resync silently did nothing.
fn take_rehydrate(cx: &mut App, client_ws: WorkspaceId, showing: &[TabId]) -> Option<Adopt> {
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&client_ws)?;
let adopt = state.rehydrate.take()?;
let overtaken = showing.iter().any(|id| !state.owed_over.contains(id));
if overtaken && adopt == Adopt::Replace {
// Abandoned, not paid — but the run of failures is over either way, and
// a count left standing would make the next window's first failure wait
// the cap on an outage that has nothing to do with it.
state.rehydrate_attempts = 0;
return None;
}
Some(adopt)
}
/// Whether a window with no tabs may delete `client_ws` outright — from the
/// machine's tree and from the store both.
///
/// Two independent things have to agree, because the window's own emptiness
/// cannot tell them apart: a workspace is empty when it genuinely holds
/// nothing, and equally when its layout failed to rebuild. Only the first is a
/// reason to delete anything, and the second has already cost a workspace with
/// ten live tabs in it.
///
/// So the window must be informed (it pulled a layout and put it up), *and* the
/// mirror — the machine's own account, which no local failure can empty — must
/// agree there is nothing there. An unprimed mirror knows nothing and answers
/// no: "I don't know" may never authorize a deletion.
pub(crate) fn workspace_is_disposable(cx: &App, client_ws: WorkspaceId) -> bool {
let Some(state) = cx
.try_global::<TreeSync>()
.and_then(|t| t.windows.get(&client_ws))
else {
return false;
};
state.informed && matches!(&state.sync, SyncPhase::Primed(mirror) if mirror.tabs.is_empty())
}
pub(crate) fn mark_window_informed(cx: &mut App, client_ws: WorkspaceId) {
cx.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.informed = true;
}
fn adopt_tab_ids(app: &Tty7App, cx: &App) {
let Some(TreeSync { windows }) = cx.try_global::<TreeSync>() else {
return;
};
let Some(WsState {
sync: SyncPhase::Primed(mirror),
..
}) = windows.get(&app.workspace)
else {
return;
};
let known: Vec<TabId> = app.tabs.iter().map(|t| t.tree_id.get()).collect();
for tab in &app.tabs {
let id = tab.tree_id.get();
if mirror.tabs.iter().any(|m| m.id == id) {
continue;
}
let panes: Vec<u64> = tab
.pane
.terminals()
.iter()
.map(|v| v.read(cx).pane_id)
.collect();
if panes.is_empty() {
continue;
}
let Some(matched) = mirror
.tabs
.iter()
.find(|m| !known.contains(&m.id) && panes.iter().any(|p| m.root.contains(*p)))
else {
continue;
};
tab.tree_id.set(matched.id);
}
}
pub(crate) fn on_preempted(cx: &mut App, client_ws: WorkspaceId) {
let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else {
return;
};
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: false,
};
state.queue.clear();
state.informed = false;
state.epoch += 1;
}
pub(crate) fn forget(cx: &mut App, client_ws: WorkspaceId) {
if let Some(state) = cx.try_global::<TreeSync>() {
let _ = state;
cx.default_global::<TreeSync>().windows.remove(&client_ws);
}
}
pub(crate) fn fire_workspace_op(
cx: &mut App,
client_ws: WorkspaceId,
op: impl FnOnce(WorkspaceId) -> ControlRequest,
) {
if !cx.has_global::<crate::core::session::WorkspaceStore>() {
return;
}
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
let request = op(machine_ws);
crate::ui::machine_mirror::MachineMirrors::note_workspace_op(cx, host, &request);
let client = match tree_control_for(cx, host) {
TreeLink::Ready(client) => client,
TreeLink::Unserved => {
unsendable(
&request,
"this machine's server does not serve the workspace tree",
);
return;
}
TreeLink::Down => {
unsendable(&request, "there is no control link to its machine");
return;
}
};
cx.background_executor()
.spawn(async move {
if let Err(e) = client.call(request.clone()) {
unsendable(&request, &format!("the machine refused it: {e}"));
}
})
.detach();
}
fn unsendable(request: &ControlRequest, why: &str) {
match request {
ControlRequest::WorkspaceRemove { workspace } => log::warn!(
"workspace {workspace} was deleted here but not on its machine ({why}); \
its entry stays in that machine's tree, where another client will still \
see it — delete it again from a client that can reach the machine"
),
other => log::debug!("{other:?} not sent ({why}); the next edit carries it"),
}
}
/// Names a workspace this window is in the middle of creating.
///
/// Parked rather than sent: the machine has not been asked to create the
/// workspace yet, so a rename addressed to it right now comes back `NotFound`
/// and is dropped on the floor — which is how a name typed into the create form
/// used to lose to the generated one (#618). `start_prime` spends it on the
/// create itself.
///
/// A window already synced with its machine has no create coming, so there is
/// nothing to ride along with and the rename goes out as usual.
pub(crate) fn name_new_workspace(cx: &mut App, client_ws: WorkspaceId, name: String) {
// A window tree-sync has never heard of is as unprimed as one it is
// priming right now: either way the create is still ahead of us.
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
if matches!(state.sync, SyncPhase::Unprimed { .. }) {
state.chosen_name = Some(name);
return;
}
rename_workspace(cx, client_ws, Some(name));
}
/// The name parked for a create that has not run yet, for tests that need to
/// see it got that far.
#[cfg(test)]
pub(crate) fn chosen_name_for(cx: &mut App, client_ws: WorkspaceId) -> Option<String> {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.and_then(|state| state.chosen_name.clone())
}
pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Option<String>) {
fire_workspace_op(cx, client_ws, move |ws| ControlRequest::WorkspaceRename {
workspace: ws,
name,
});
}
fn start_prime(cx: &mut App, client_ws: WorkspaceId) {
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
let client = match tree_control_for(cx, host) {
TreeLink::Ready(client) => client,
unavailable => {
if matches!(unavailable, TreeLink::Unserved) {
log::warn!(
"workspace {client_ws}: its machine's server does not serve the tree; \
the layout will not be synced"
);
}
if let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws)
&& let SyncPhase::Unprimed { priming, .. } = &mut state.sync
{
*priming = false;
}
return;
}
};
let epoch = cx
.default_global::<TreeSync>()
.windows
.get(&client_ws)
.map(|s| s.epoch)
.unwrap_or(0);
cx.spawn(async move |cx| {
// Picked on the main thread, where the names already in use are
// readable, but inside the task rather than before it: a window that is
// switching workspaces orders its pull first and is named second, so
// reading any earlier would miss the name the user typed. A name the
// user did type beats a generated one, and is left in place for
// `finish_prime` to spend against the machine's answer.
let fresh = cx.update(|cx| {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.and_then(|state| state.chosen_name.clone())
.unwrap_or_else(|| fresh_workspace_name(cx, host))
});
let outcome = cx
.background_executor()
.spawn(async move { pull_or_create(&client, machine_ws, fresh) })
.await;
cx.update(|cx| finish_prime(cx, client_ws, epoch, outcome));
})
.detach();
}
/// A codename no workspace on `host` is using. Beats leaving new workspaces
/// named after whatever directory their first shell happened to start in —
/// three of those in a switcher all read the same.
pub(crate) fn fresh_workspace_name(cx: &App, host: HostId) -> String {
let mut taken: Vec<String> = Vec::new();
if let Some(machine) = crate::ui::machine_mirror::MachineMirrors::machine(cx, host) {
taken.extend(machine.workspaces.iter().filter_map(|w| w.name.clone()));
}
// Labels are the names the switcher actually shows, which for an unnamed
// workspace is its directory. Counting those as taken is deliberately
// generous — it only ever costs another roll of the dice.
if cx.has_global::<WorkspaceStore>() {
taken.extend(
WorkspaceStore::all(cx)
.views
.iter()
.filter(|w| w.host_id() == host)
.filter_map(|w| w.label.clone()),
);
}
tty7_core::core::codename::unique(|name| taken.iter().any(|t| t == name))
}
/// This workspace's layout, and the name the machine has for it.
///
/// The name comes back even from the create this client asked for, and
/// especially from that one: a client is left out of the deltas its own ops
/// raise, so the `WorkspaceCreated` delta carrying the name it just proposed
/// never arrives. Dropping the name here left the mirror holding the workspace
/// unnamed until something pulled the whole tree again, and the name it had had
/// all along then landed on screen looking like a rename (#604).
/// Settles the name a window asked for against the name its machine came back
/// with, and returns what the workspace is really called.
///
/// `answered` is the machine's answer. A chosen name it read back was spent by
/// the create that carried it, and there is nothing left to do. One it did not
/// means the create never ran — the workspace was already there, or the other
/// create won the race with a stale idea of the name — so it goes out as the
/// rename it has become. Either way the name is owed only once.
fn settle_chosen_name(
cx: &mut App,
client_ws: WorkspaceId,
answered: Option<String>,
) -> Option<String> {
let chosen = cx
.default_global::<TreeSync>()
.windows
.get_mut(&client_ws)
.and_then(|state| state.chosen_name.take());
match chosen {
Some(chosen) if answered.as_deref() == Some(chosen.as_str()) => answered,
Some(chosen) => {
rename_workspace(cx, client_ws, Some(chosen.clone()));
Some(chosen)
}
None => answered,
}
}
fn pull_or_create(
client: &ControlClient,
machine_ws: WorkspaceId,
fresh: String,
) -> io::Result<(WsMirror, Option<String>)> {
match client.call(ControlRequest::WorkspaceTree {
workspace: machine_ws,
}) {
Ok(ReplyOk::WorkspaceTree(ws)) => Ok(primed(*ws)),
Ok(other) => Err(io::Error::other(format!(
"WorkspaceTree answered {other:?}"
))),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
match client.call(ControlRequest::WorkspaceCreate {
name: Some(fresh),
workspace: Some(machine_ws),
})? {
ReplyOk::WorkspaceTree(ws) => Ok(primed(*ws)),
other => Err(io::Error::other(format!(
"WorkspaceCreate answered {other:?}"
))),
}
}
Err(e) => Err(e),
}
}
fn primed(ws: Workspace) -> (WsMirror, Option<String>) {
(
WsMirror {
tabs: ws.tabs,
active: ws.active_tab,
},
ws.name,
)
}
fn finish_prime(
cx: &mut App,
client_ws: WorkspaceId,
epoch: u64,
outcome: io::Result<(WsMirror, Option<String>)>,
) {
let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else {
return;
};
if state.epoch != epoch || !matches!(state.sync, SyncPhase::Unprimed { priming: true, .. }) {
log::debug!("workspace {client_ws}: dropping a superseded tree pull");
return;
}
let was_dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. });
let landed = match outcome {
Ok((mirror, name)) => {
state.informed |= mirror.tabs.is_empty();
// The machine answered, which is the only thing the retry was
// waiting to find out, so the next failure starts its backoff over.
state.rehydrate_attempts = 0;
let landed = (mirror.tabs.clone(), mirror.active, name);
state.sync = SyncPhase::Primed(mirror);
landed
}
Err(e) => {
log::warn!("could not pull the tree for workspace {client_ws}: {e}");
state.sync = SyncPhase::Unprimed {
dirty: was_dirty,
priming: false,
};
return;
}
};
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
crate::ui::machine_mirror::MachineMirrors::note_synced_workspace(
cx, host, machine_ws, landed.0, landed.1,
);
// The pull above is the only place this window will hear the workspace's
// name — it is left out of the deltas its own create raises (#604).
let name = settle_chosen_name(cx, client_ws, landed.2);
crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name);
if !was_dirty {
return;
}
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade())
else {
return;
};
app.update(cx, |app, cx| sync_window(app, cx));
}
fn pump(cx: &mut App, client_ws: WorkspaceId) {
let host = WorkspaceStore::host_of(cx, client_ws);
let client = tree_control_for(cx, host);
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
if state.inflight || state.queue.is_empty() {
return;
}
let client = match client {
TreeLink::Ready(client) => client,
TreeLink::Unserved => {
desync(cx, client_ws, "the server does not serve the machine tree");
return;
}
TreeLink::Down => {
desync(cx, client_ws, "the control link is down");
return;
}
};
let batch: Vec<ControlRequest> = state.queue.drain(..).collect();
state.inflight = true;
cx.spawn(async move |cx| {
let result = cx
.background_executor()
.spawn(async move {
for op in batch {
if let Err(e) = client.call(op.clone()) {
return Err((op, e));
}
}
Ok(())
})
.await;
cx.update(|cx| {
if let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) {
state.inflight = false;
}
match result {
Ok(()) => pump(cx, client_ws),
Err((op, e)) => {
log::warn!("tree operation {op:?} failed: {e}; re-pulling the tree");
desync(cx, client_ws, "an operation was refused");
}
}
});
})
.detach();
}
fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) {
log::info!("resynchronizing workspace {client_ws} with its machine ({why})");
let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else {
return;
};
state.queue.clear();
state.inflight = false;
state.sync = SyncPhase::Unprimed {
dirty: true,
priming: true,
};
state.epoch += 1;
start_prime(cx, client_ws);
}
pub(crate) fn session_from_tree(
ws: &tty7_core::core::machine::Workspace,
panes: &[PaneRecord],
) -> Session {
let tabs: Vec<SessionTab> = ws
.tabs
.iter()
.map(|tab| SessionTab {
name: tab.name.clone(),
tree_id: Some(tab.id),
sidebar_group: tab.sidebar_group.as_deref().and_then(GroupKey::decode),
pane: session_pane_from_node(&tab.root, panes),
})
.collect();
let active = ws
.active_tab
.and_then(|id| ws.tabs.iter().position(|t| t.id == id))
.unwrap_or(0);
Session { active, tabs }
}
fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane {
match node {
PaneNode::Leaf { pane } => {
let record = panes.iter().find(|p| p.id == *pane);
let (cwd, ssh_spec, agent, shell) = match record {
Some(r) => (
r.cwd.clone().map(std::path::PathBuf::from),
r.ssh_spec.clone(),
r.agent.clone(),
r.shell.clone(),
),
None => (None, None, None, None),
};
SessionPane::Leaf {
cwd,
// The id goes down whatever `live` says. That flag is a cached
// fact about another process, written by whoever last observed
// the pane and reloaded from disk as `false` on every server
// start — so a quiet pane that nobody has observed since reads
// as dead while its shell is very much alive. Believing it here
// is what threw away live sessions on a workspace switch: the
// id was erased, and the restore below had nothing to attach
// to, so it spawned a fresh shell over a running one.
//
// Attaching is the thing that actually knows. `spawn_shell_
// terminal_in` attaches when the pane is there and spawns fresh
// when it is not, which is the same answer this filter was
// trying to guess — except it is right. `live` stays a hint for
// what to show, never the judge of what to destroy.
pane_id: Some(*pane),
shell,
ssh_spec,
agent: agent.as_ref().map(|a| a.agent),
agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()),
agent_launch_argv: agent.as_ref().and_then(|a| a.launch_argv.clone()),
}
}
PaneNode::Split { axis, ratio, a, b } => SessionPane::Split {
axis: match axis {
TreeAxis::Horizontal => crate::core::session::SessionAxis::Horizontal,
TreeAxis::Vertical => crate::core::session::SessionAxis::Vertical,
},
ratio: *ratio,
a: Box::new(session_pane_from_node(a, panes)),
b: Box::new(session_pane_from_node(b, panes)),
},
}
}
const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15);
const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200);
pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) {
hydrate(cx, client_ws, Adopt::IfEmpty);
}
/// Hydrates a window whose on-screen tabs are already known to the caller.
///
/// Reading them back with `tabs_on_screen` is illegal while the app's own
/// update lease is held — gpui aborts on a read of an entity that is already
/// being updated — and `switch_workspace` runs inside exactly that lease. The
/// ids are the same ones the read would produce: the tabs the window shows
/// right now.
pub(crate) fn hydrate_window_with_tabs(cx: &mut App, client_ws: WorkspaceId, showing: Vec<TabId>) {
hydrate_with(cx, client_ws, Adopt::IfEmpty, showing);
}
/// Pulls this window's layout, then opens `path` as one more tab in it.
///
/// This is what a launch carrying a directory does — Explorer's "Open in tty7",
/// or `tty7 <PATH>` with no window up. Both halves are wanted: the layout the
/// user left, and the folder they just double-clicked.
pub(crate) fn hydrate_window_then_open(
cx: &mut App,
client_ws: WorkspaceId,
path: std::path::PathBuf,
) {
cx.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.then_open
.push(ParkedOpen::Folder(path));
hydrate(cx, client_ws, Adopt::IfEmpty);
}
/// Parks a tab request while this window's layout is still on its way, and
/// says whether it did. A caller told `false` has a settled window and should
/// open the tab itself; one told `true` has handed the request to the pull,
/// which opens it once the layout is up.
///
/// Inserting into a window mid-pull is the failure `then_open` exists to
/// avoid: `Adopt::IfEmpty` declines a window that has a tab, and pushes that
/// one tab back as the whole workspace.
pub(crate) fn park_command_while_pulling(
cx: &mut App,
client_ws: WorkspaceId,
cwd: &std::path::Path,
command: &str,
) -> bool {
if !layout_is_pending(cx, client_ws) {
return false;
}
let parked = parked_for(cx, client_ws);
// Opening a window for this request parks its folder on the way past, in
// `for_workspace_at`. The command belongs in that tab, not in a second one
// beside it running the same shell.
match parked
.iter_mut()
.find(|open| matches!(open, ParkedOpen::Folder(folder) if folder == cwd))
{
Some(open) => {
*open = ParkedOpen::Command {
cwd: cwd.to_path_buf(),
command: command.to_owned(),
}
}
None => parked.push(ParkedOpen::Command {
cwd: cwd.to_path_buf(),
command: command.to_owned(),
}),
}
true
}
/// [`park_command_while_pulling`] for an `ssh://` link, which opens a tab of
/// its own and so races the same pull.
pub(crate) fn park_ssh_while_pulling(
cx: &mut App,
client_ws: WorkspaceId,
ssh: &tty7_core::core::ssh_profile::QuickConnect,
) -> bool {
if !layout_is_pending(cx, client_ws) {
return false;
}
parked_for(cx, client_ws).push(ParkedOpen::Ssh(ssh.clone()));
true
}
/// Whether this window's layout is still on its way: a pull is out, one is
/// owed a retry, or tabs are already waiting on one.
fn layout_is_pending(cx: &mut App, client_ws: WorkspaceId) -> bool {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.is_some_and(|state| {
matches!(state.sync, SyncPhase::Unprimed { priming: true, .. })
|| state.rehydrate.is_some()
|| !state.then_open.is_empty()
})
}
fn parked_for(cx: &mut App, client_ws: WorkspaceId) -> &mut Vec<ParkedOpen> {
&mut cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.then_open
}
/// Opens the tabs a launch parked here, now that the layout they waited for is
/// up. Does nothing for the windows — every other one — that parked nothing.
fn open_parked_path(cx: &mut App, client_ws: WorkspaceId) {
let parked = cx
.default_global::<TreeSync>()
.windows
.get_mut(&client_ws)
.map(|state| std::mem::take(&mut state.then_open))
.unwrap_or_default();
if parked.is_empty() {
return;
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return;
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade())
else {
return;
};
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
for open in parked {
match open {
ParkedOpen::Folder(cwd) => app.new_tab_at(cwd, window, cx),
ParkedOpen::Command { cwd, command } => {
app.new_tab_running(cwd, command, window, cx)
}
ParkedOpen::Ssh(ssh) => app.quick_connect(ssh, window, cx),
}
}
});
});
}
/// A tab parked until its window's layout lands.
enum ParkedOpen {
/// What a launch and an Explorer double-click ask for.
Folder(std::path::PathBuf),
/// A shell in `cwd` with `command` typed into it, from a script or an
/// `x-man-page:` link Finder handed over.
Command {
cwd: std::path::PathBuf,
command: String,
},
/// An `ssh://` link.
Ssh(tty7_core::core::ssh_profile::QuickConnect),
}
#[derive(Clone, Copy, PartialEq)]
enum Adopt {
IfEmpty,
Replace,
}
/// The tree ids of the tabs `client_ws`'s window is showing.
///
/// Empty for a workspace no window has — and for a test with no registry at
/// all, which is the same answer: nothing is on screen to speak for it.
fn tabs_on_screen(cx: &mut App, client_ws: WorkspaceId) -> Vec<TabId> {
if !cx.has_global::<crate::ui::windows::WindowRegistry>() {
return Vec::new();
}
crate::ui::windows::WindowRegistry::app_for(cx, client_ws)
.and_then(|app| app.upgrade())
.map(|app| app.read(cx).tabs.iter().map(|t| t.tree_id.get()).collect())
.unwrap_or_default()
}
fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) {
let showing = tabs_on_screen(cx, client_ws);
hydrate_with(cx, client_ws, adopt, showing);
}
fn hydrate_with(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt, showing: Vec<TabId>) {
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
let (epoch, failures) = {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default();
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
state.queue.clear();
state.epoch += 1;
// This attempt takes over the debt; it re-records it if it fails too.
state.rehydrate = None;
// What the window is showing as this attempt is ordered, so a debt it
// has to record can tell that layout apart from one the user builds
// while the pull is out.
state.owed_over = showing;
if adopt == Adopt::Replace {
// A `Replace` is the statement that this window no longer speaks
// for its workspace, so the licence to prune goes with it — the
// same move `on_preempted` makes, for the same reason. Only a pull
// that lands hands it back (`settle_hydration`).
//
// Without this, a `Replace` that failed and was then abandoned left
// a window that had never seen the layout still authorised to diff
// at `SyncScope::Full`: one tab the user opened over an emptied
// window became `TabClose` for every tab on the machine, deleting
// the records of panes whose shells were still running (#579). The
// window is left additive instead — it writes its own tabs up and
// closes nothing it cannot account for.
state.informed = false;
}
(state.epoch, state.rehydrate_attempts)
};
// How many times in a row this window has already failed, which is what
// decides whether another failure is news or the same news again.
let level = hydration_log_level(failures, log::Level::Warn);
cx.spawn(async move |cx| {
let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE;
let client = loop {
match cx.update(|cx| tree_control_for(cx, host)) {
TreeLink::Ready(client) => break Some(client),
TreeLink::Unserved => {
log::log!(
level,
"workspace {client_ws}: its machine's server does not serve the \
machine tree; opening empty"
);
break None;
}
TreeLink::Down if std::time::Instant::now() > deadline => {
log::log!(
level,
"workspace {client_ws}: no link to its machine; opening empty"
);
break None;
}
TreeLink::Down => cx.background_executor().timer(HYDRATE_LINK_POLL).await,
}
};
let Some(client) = client else {
cx.update(|cx| {
// Only the attempt that still owns the window gets to speak: a
// superseded one is being retried right now, and announcing an
// emptiness someone else is already filling would be a lie by
// the time it is read.
//
// A machine that answers late is normal for a remote one, and
// the switcher already says so there. On this computer nothing
// else would.
if owe_rehydration(cx, client_ws, epoch, adopt)
&& adopt == Adopt::IfEmpty
&& host.is_local()
{
say_why_the_window_is_empty(cx, client_ws);
}
});
return;
};
// Read here rather than before the spawn: `switch_workspace` orders
// this pull and only then names the workspace, so at the moment this
// task was created the name had not been typed in yet.
let chosen = cx.update(|cx| {
cx.default_global::<TreeSync>()
.windows
.get(&client_ws)
.and_then(|state| state.chosen_name.clone())
});
let outcome = cx
.background_executor()
.spawn(async move { pull_workspace(&client, machine_ws, chosen) })
.await;
cx.update(|cx| finish_hydration(cx, client_ws, epoch, adopt, outcome));
})
.detach();
}
/// Tells the window it opened empty because its machine never answered.
///
/// An empty window is also what a window with no tabs looks like, and the retry
/// that would fill it in is as quiet as the failure was — so a server one
/// dialect behind reads as "tty7 lost my tabs" with nothing anywhere to say
/// otherwise. This is that "otherwise", said in the window it happened to.
fn say_why_the_window_is_empty(cx: &mut App, client_ws: WorkspaceId) {
match cx.default_global::<TreeSync>().windows.get_mut(&client_ws) {
Some(state) if !state.said_why_empty => state.said_why_empty = true,
_ => return,
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return;
};
let _ = handle.update(cx, |_, window, cx| {
window.push_notification(t(L10nKey::TreeWindowOpenedEmpty), cx);
});
}
/// Records that a hydration failed and still owes `client_ws` its layout, and
/// arms the retry that pays it back.
///
/// Nothing else recovers on its own: the window stays empty, and without this
/// the next `sync_window` would push that emptiness to the machine as "close
/// every tab". The debt is settled by the next sync of this window — a
/// reconnect drives one through `on_link_up`, an edit in the window drives one
/// through `save_session`, and [`arm_rehydrate_retry`] drives one when neither
/// happens.
///
/// That last driver is the load-bearing one. A pull can fail with the link
/// perfectly healthy — a `MachineGet` that overran its ten seconds on a slow
/// link, or a create that lost its race with `start_prime` — and then no link
/// ever comes back up to notice, and an empty window has nothing to edit. The
/// window sat empty until the app was restarted, with every tab and every
/// shell still on the machine: "tty7 lost my session" for a request that
/// needed asking twice.
///
/// Returns whether the debt was taken on. A superseded attempt gets `false`:
/// a newer hydration owns the window now, and this one speaks for nothing.
fn owe_rehydration(cx: &mut App, client_ws: WorkspaceId, epoch: u64, adopt: Adopt) -> bool {
let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else {
return false;
};
if state.epoch != epoch {
return false;
}
if let SyncPhase::Unprimed { priming, .. } = &mut state.sync {
*priming = false;
}
state.rehydrate = Some(adopt);
state.rehydrate_attempts = state.rehydrate_attempts.saturating_add(1);
let attempts = state.rehydrate_attempts;
log::log!(
// Once settled this line says the same thing every thirty seconds until
// the window closes, which is a fact about the machine and not an event.
hydration_log_level(attempts, log::Level::Info),
"workspace {client_ws}: will pull its layout again once its machine answers \
(attempt {attempts})"
);
arm_rehydrate_retry(cx, client_ws, epoch, attempts);
true
}
/// Whether the debt this retry was armed for is still the window's own.
///
/// A newer epoch means another hydration took the window over while the
/// backoff ran, and this retry speaks for nothing.
fn still_owed(cx: &App, client_ws: WorkspaceId, epoch: u64) -> bool {
cx.try_global::<TreeSync>()
.and_then(|t| t.windows.get(&client_ws))
.is_some_and(|s| s.rehydrate.is_some() && s.epoch == epoch)
}
/// The attempt from which the backoff no longer grows.
///
/// Also the point where a window stops being a fresh failure and becomes a
/// standing one, which is what [`hydration_log_level`] keys off.
const REHYDRATE_SETTLED: u32 = 5;
const REHYDRATE_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(30);
/// The first retry is soon enough to look instant to someone watching an empty
/// window; the backoff is what keeps a machine that is really unreachable from
/// being asked on a loop for as long as its window stays open.
fn rehydrate_backoff(attempts: u32) -> std::time::Duration {
std::time::Duration::from_secs(2u64.saturating_pow(attempts.min(REHYDRATE_SETTLED)))
.min(REHYDRATE_BACKOFF_CAP)
}
/// Steps `fresh` down to `debug` once this window's failures have stopped being
/// events and become a standing condition.
///
/// The first few are news: something that was working stopped. Once the backoff
/// has settled at its cap the window is in a steady state — a machine that is
/// simply not there — and the retry will go on failing every thirty seconds for
/// as long as the window stays open. Repeating that at full volume buries
/// whatever else is in the log. The retry stays exactly as persistent either
/// way; only the volume drops.
fn hydration_log_level(attempts: u32, fresh: log::Level) -> log::Level {
if attempts >= REHYDRATE_SETTLED {
log::Level::Debug
} else {
fresh
}
}
/// Asks `client_ws` to sync once the backoff is up, if it still owes a pull.
///
/// Deliberately routed through `sync_window` rather than straight into
/// `hydrate`: that is where the rules about *whether* a window may still adopt
/// the machine's layout live — a preempted workspace stays out of it, and a
/// `Replace` is dropped once the user has filled the window in themselves.
fn arm_rehydrate_retry(cx: &mut App, client_ws: WorkspaceId, epoch: u64, attempts: u32) {
let delay = rehydrate_backoff(attempts);
cx.spawn(async move |cx| {
cx.background_executor().timer(delay).await;
let _ = cx.update(|cx| {
if !still_owed(cx, client_ws, epoch) {
return;
}
// No window left to fill, so asking its machine now would be work
// for nobody. Closing a window drops its whole `WsState` through
// `forget`, debt and all, so `still_owed` above normally answers
// first; this covers the window that is on its way out and has
// already dropped its app.
let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws)
.and_then(|app| app.upgrade())
else {
return;
};
app.update(cx, |app, cx| sync_window(app, cx));
});
})
.detach();
}
fn pull_workspace(
client: &ControlClient,
machine_ws: WorkspaceId,
chosen: Option<String>,
) -> io::Result<(Machine, WsMirror, Session)> {
let mut machine = match layout_of(machine_get(client)?, machine_ws) {
Ok(pulled) => return Ok(pulled),
Err(machine) => machine,
};
// The whole tree is already in hand, so the taken names can be read
// straight off it rather than passed down from the main thread.
let taken: Vec<&str> = machine
.workspaces
.iter()
.filter_map(|w| w.name.as_deref())
.collect();
// A name the user typed beats a rolled one. This create and `start_prime`'s
// race each other (see the `Err` arm below), so both have to offer it —
// whichever wins, the workspace ends up called what was asked for.
let name = chosen.unwrap_or_else(|| tty7_core::core::codename::unique(|n| taken.contains(&n)));
match client.call(ControlRequest::WorkspaceCreate {
name: Some(name),
workspace: Some(machine_ws),
}) {
// The tree read a moment ago predates the workspace this call just
// made, and no delta will fill it in: a client is left out of the
// deltas its own ops raise. Put the created workspace into the tree
// about to be installed, or the mirror holds it unnamed and the name
// the machine gave it arrives later looking like a rename (#604).
Ok(ReplyOk::WorkspaceTree(created)) => {
machine.workspaces.retain(|w| w.id != created.id);
machine.workspaces.push(*created);
Ok((machine, WsMirror::default(), Session::default()))
}
Ok(_) => Ok((machine, WsMirror::default(), Session::default())),
// Losing this create is not a failed hydration. Opening a remote
// workspace runs two pulls at once — this one and `start_prime`'s —
// and both create when the tree they read did not hold it yet, so the
// loser is told it already exists. The workspace the create was for is
// on the machine either way, and it may already hold tabs: read the
// tree again and hydrate from what is really there. Treating this as a
// failure left the window empty over a workspace that was fine.
//
// Any refusal is worth the second look, not just "already exists": what
// matters is whether the workspace is there now, and the tree answers
// that better than the error text does. If it still is not there, the
// create's own refusal is the honest error to report — the reread
// happened on its behalf and has nothing of its own to say.
Err(refused) => {
log::debug!(
"workspace {machine_ws} could not be created ({refused}); reading the tree \
again in case something else created it first"
);
match machine_get(client) {
Ok(machine) => layout_of(machine, machine_ws).map_err(|_| refused),
Err(_) => Err(refused),
}
}
}
}
fn machine_get(client: &ControlClient) -> io::Result<Machine> {
match client.call(ControlRequest::MachineGet)? {
ReplyOk::MachineTree(m) => Ok(*m),
other => Err(io::Error::other(format!("MachineGet answered {other:?}"))),
}
}
/// This workspace's layout as `machine` has it, or the tree handed back
/// untouched when the machine does not hold the workspace at all.
fn layout_of(
machine: Machine,
machine_ws: WorkspaceId,
) -> Result<(Machine, WsMirror, Session), Machine> {
let Some(ws) = machine.workspaces.iter().find(|w| w.id == machine_ws) else {
return Err(machine);
};
let mirror = WsMirror {
tabs: ws.tabs.clone(),
active: ws.active_tab,
};
let session = session_from_tree(ws, &machine.panes);
Ok((machine, mirror, session))
}
fn finish_hydration(
cx: &mut App,
client_ws: WorkspaceId,
epoch: u64,
adopt: Adopt,
outcome: io::Result<(Machine, WsMirror, Session)>,
) {
if settle_hydration(cx, client_ws, epoch, adopt, outcome) {
open_parked_path(cx, client_ws);
}
}
/// The body of [`finish_hydration`]. Returns whether this attempt settled the
/// window's layout — false for one that was superseded or has to be retried,
/// which are the two cases where a parked folder waits for the attempt that
/// does settle it rather than opening over a layout still on its way.
fn settle_hydration(
cx: &mut App,
client_ws: WorkspaceId,
epoch: u64,
adopt: Adopt,
outcome: io::Result<(Machine, WsMirror, Session)>,
) -> bool {
let current = cx
.default_global::<TreeSync>()
.windows
.get(&client_ws)
.map(|s| s.epoch);
if current != Some(epoch) {
log::debug!("workspace {client_ws}: dropping a superseded hydration");
return false;
}
let (machine, mirror, session) = match outcome {
Ok(pulled) => pulled,
Err(e) => {
let failures = cx
.default_global::<TreeSync>()
.windows
.get(&client_ws)
.map_or(0, |s| s.rehydrate_attempts);
log::log!(
hydration_log_level(failures, log::Level::Warn),
"could not hydrate workspace {client_ws} from its machine: {e}"
);
let _ = owe_rehydration(cx, client_ws, epoch, adopt);
return false;
}
};
let host = WorkspaceStore::host_of(cx, client_ws);
let machine_ws = tree_workspace_id(cx, client_ws);
// What the tree that is about to be installed calls this workspace, which
// for a pull that had to create it is the name that create proposed.
let answered = machine
.workspaces
.iter()
.find(|w| w.id == machine_ws)
.and_then(|w| w.name.clone());
crate::ui::machine_mirror::MachineMirrors::install(cx, host, machine);
let name = settle_chosen_name(cx, client_ws, answered);
crate::ui::machine_mirror::MachineMirrors::note_workspace_name(cx, host, machine_ws, name);
let machine_was_empty = mirror.tabs.is_empty();
let was_dirty = {
let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else {
return false;
};
let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. });
state.informed |= machine_was_empty;
state.sync = SyncPhase::Primed(mirror);
// The machine answered, so the next failure starts its backoff over.
state.rehydrate_attempts = 0;
// The machine answered, so the explanation has been overtaken by events
// and a later outage deserves its own.
state.said_why_empty = false;
dirty
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade())
else {
return false;
};
if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() {
// A full window over an empty tree has to write itself back, whether
// or not an edit was waiting: the machine is missing tabs this window
// is showing, and nothing else would ever put them there.
//
// Deliberately not limited to this machine. An empty tree means one of
// two things and the answer is the same either way: locally the
// workspace was removed under the window (`ws rm`, or another client),
// and remotely the far end lost its records — a re-imaged box, a store
// that was wiped. Writing the window back is what a reattach is for.
// The panes it names may well be dead; the window already draws them
// that way, and a tab the user can close beats a tab that silently
// stops existing.
if was_dirty || machine_was_empty {
app.update(cx, |app, cx| sync_window(app, cx));
}
return true;
}
if session.tabs.is_empty() && adopt == Adopt::IfEmpty {
if was_dirty
&& let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade())
{
app.update(cx, |app, cx| sync_window(app, cx));
}
return true;
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return false;
};
let wanted = session.tabs.len();
// `tree_id` is not serialized, so only a session built from the tree
// carries one on every tab (which is what reaches here today). The guard
// below counts the tabs asked for, not the ids found: a session with tabs
// and no ids must not read as "nothing was wanted".
let wanted_ids: Vec<TabId> = session.tabs.iter().filter_map(|t| t.tree_id).collect();
debug_assert_eq!(
wanted_ids.len(),
wanted,
"a session rebuilt from the tree names every tab it holds"
);
log::info!("rebuilding {wanted} tab(s) of workspace {client_ws} from its machine's tree");
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
app.adopt_workspace(client_ws, session, window, cx)
});
});
let showing = tabs_on_screen(cx, client_ws);
settle_rebuild(cx, client_ws, wanted, &wanted_ids, &showing);
true
}
/// What a rebuild leaves the window entitled to say, from how many tabs the
/// tree asked it to put up (`wanted`), which ids those were (`wanted_ids`),
/// and the tabs it is showing now (`showing`).
///
/// Informed *after* the rebuild, and only if the rebuild produced something.
///
/// The licence means "this window knows what belongs in this workspace", and
/// `switch_workspace` / `detach_workspace` read it as permission to delete a
/// workspace that has no tabs — from the machine tree and from the store
/// both. Granting it before the rebuild handed that permission to a window
/// whose rebuild had not happened yet, and a rebuild can produce nothing:
/// `tabs_from_session` drops any tab whose panes all fail to start, which is
/// what every tab does when the pane socket is unreachable. The window then
/// sat there, empty and authoritative, and the next switch deleted a
/// workspace with ten live tabs in it.
///
/// Emptiness that came from a failure has to stay indistinguishable from not
/// knowing, because that is what it is.
///
/// A rebuild that produced *some* of its tabs is the same failure, one tab at
/// a time, and the licence it does earn — it put a layout up, and closes and
/// splits in it must reach the machine — cannot be allowed to speak for the
/// tabs it did not: at `SyncScope::Full` every mirror tab missing from the
/// window is a `TabClose`, and the tabs missing were exactly the ones that
/// failed to rebuild, so the sync deleted them off the machine, panes and all
/// (#672). They are held instead (`not_rebuilt`), out of the diff's reach until
/// a later rebuild puts them up or the machine lets them go.
///
/// Holding has a cost the caller should know: `diff` stops before its
/// reorder pass and the active-tab op whenever anything is held, so while
/// this set stands the window's tab order and active tab do not reach the
/// machine — a restart restores the order and focus from before the drag.
/// And nothing retries a held tab: the set is rewritten only by the next
/// `settle_rebuild`, which runs only from a hydration that rebuilds, and a
/// re-prime or an `Adopt::IfEmpty` hydrate on a populated window never gets
/// there. A tab that fails to rebuild stays held, and holds the ordering
/// with it, until the next restart. That state was already reachable — a
/// pane whose remote spawn failed stays connecting for the same span, held
/// the same way — so this widens a standing hole rather than opening one; a
/// retry, or a way to close a held tab from the window, is separate work.
///
/// The none-rebuilt guard reads the *count* asked for, not the ids found:
/// `tree_id` is not serialized, so a session that reached here from disk
/// would name no ids at all, and "no ids" must not read as "no tabs wanted"
/// — that would grant the licence to a window that rebuilt nothing, which is
/// #672 again.
fn settle_rebuild(
cx: &mut App,
client_ws: WorkspaceId,
wanted: usize,
wanted_ids: &[TabId],
showing: &[TabId],
) {
let not_rebuilt: Vec<TabId> = wanted_ids
.iter()
.copied()
.filter(|id| !showing.contains(id))
.collect();
if !not_rebuilt.is_empty() && !showing.is_empty() {
log::warn!(
"workspace {client_ws}: {} of its {wanted} tab(s) could not be rebuilt; they stay on \
the machine, held out of this window's sync (tab order and active tab are not \
synced while a tab is held)",
not_rebuilt.len()
);
}
let rebuilt = !showing.is_empty();
cx.default_global::<TreeSync>()
.windows
.entry(client_ws)
.or_default()
.not_rebuilt = not_rebuilt;
if rebuilt || wanted == 0 {
mark_window_informed(cx, client_ws);
} else {
log::warn!(
"workspace {client_ws}: none of its {wanted} tab(s) could be rebuilt; leaving the \
window uninformed so the layout is not mistaken for an empty workspace"
);
}
}
/// Someone else removed this workspace from its machine — `tty7 ws rm`, or
/// another client.
///
/// With no window on it, it stops existing here too. Left in the store it
/// would keep its row in the switcher and open onto nothing, which is how a
/// workspace deleted from the CLI used to haunt the panel until a restart.
///
/// With a window on it, the window stays: `ws rm` leaves every pane running,
/// and closing the window would strand them with no way back. Pulling the
/// layout again is what makes that honest — finding the workspace gone is
/// exactly the case `pull_workspace` puts back under the same id, and the
/// window writes its tabs to it on the way out of the hydration.
fn on_workspace_deleted(cx: &mut App, client_ws: WorkspaceId) {
if crate::ui::windows::WindowRegistry::window_for(cx, client_ws).is_none() {
log::info!("workspace {client_ws} was deleted on its machine; forgetting it here too");
forget(cx, client_ws);
crate::core::session::WorkspaceStore::remove(cx, client_ws);
crate::ui::windows::refresh_menu(cx);
cx.refresh_windows();
return;
}
log::info!(
"workspace {client_ws} was deleted on its machine while a window still had it open; \
putting it back under the same id"
);
hydrate(cx, client_ws, Adopt::IfEmpty);
}
pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: LayoutDelta) {
crate::ui::machine_mirror::MachineMirrors::apply_delta(cx, host, key, &delta);
let client_ws = if host.is_local() {
key.parse::<WorkspaceId>().ok()
} else {
WorkspaceStore::all(cx)
.views
.iter()
.find(|w| {
w.host
.as_ref()
.is_some_and(|r| r.host_id() == host && r.workspace.to_string() == key)
})
.map(|w| w.id)
};
let Some(client_ws) = client_ws else {
return;
};
if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) {
on_preempted(cx, client_ws);
return;
}
if matches!(delta, LayoutDelta::WorkspaceDeleted) {
on_workspace_deleted(cx, client_ws);
return;
}
let mirror_ok = match cx
.default_global::<TreeSync>()
.windows
.get_mut(&client_ws)
.map(|s| &mut s.sync)
{
Some(SyncPhase::Primed(mirror)) => apply_to_mirror(mirror, &delta),
_ => return,
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade())
else {
return;
};
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else {
return;
};
let window_ok = handle
.update(cx, |_, window, cx| {
app.update(cx, |app, cx| app.apply_layout_delta(&delta, window, cx))
})
.unwrap_or(true);
if !mirror_ok || !window_ok {
log::info!(
"workspace {client_ws}: delta {delta:?} did not apply cleanly; re-pulling the tree"
);
resync_window_from_tree(cx, client_ws);
return;
}
app.update(cx, |app, cx| sync_window(app, cx));
}
fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool {
match delta {
// Nothing here is about a workspace's tab list, so the mirror is
// already right. `WorkspaceDeleted` never reaches this far —
// `on_layout_delta` hands it to `on_workspace_deleted` and returns —
// and is listed only so a new delta cannot join this arm by accident.
LayoutDelta::WorkspaceCreated { .. }
| LayoutDelta::WorkspaceRenamed { .. }
| LayoutDelta::WorkspaceTouched { .. }
| LayoutDelta::WorkspaceDeleted
| LayoutDelta::PaneFacts { .. } => true,
LayoutDelta::ActiveTabChanged { tab } => {
mirror.active = Some(*tab);
true
}
LayoutDelta::TabCreated { at, tab } => {
mirror.tabs.retain(|t| t.id != tab.id);
let at = (*at).min(mirror.tabs.len());
mirror.tabs.insert(at, tab.clone());
true
}
LayoutDelta::TabClosed { tab } => {
let before = mirror.tabs.len();
mirror.tabs.retain(|t| t.id != *tab);
if mirror.tabs.is_empty() {
mirror.active = None;
}
mirror.tabs.len() != before
}
LayoutDelta::TabRenamed { tab, name } => {
let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.name = name.clone();
true
}
LayoutDelta::TabRegrouped { tab, group } => {
let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.sidebar_group = group.clone();
true
}
LayoutDelta::TabMoved { tab, to } => {
let Some(from) = mirror.tabs.iter().position(|t| t.id == *tab) else {
return false;
};
let moved = mirror.tabs.remove(from);
mirror.tabs.insert((*to).min(mirror.tabs.len()), moved);
true
}
LayoutDelta::TabRestructured { tab, .. } => {
let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == tab.id) else {
return false;
};
*t = tab.clone();
true
}
LayoutDelta::RatioChanged { tab, path, ratio } => {
let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
match t.root.descend_mut(path) {
Some(PaneNode::Split { ratio: r, .. }) => {
*r = *ratio;
true
}
_ => false,
}
}
}
}
pub(crate) fn resync_window_from_tree(cx: &mut App, client_ws: WorkspaceId) {
hydrate(cx, client_ws, Adopt::Replace);
}
/// Records the daemon process a link is talking to, answering "did the server
/// behind this link just become a *different* process?".
///
/// `seen` is the instance last recorded for this link, empty when unknown. A
/// first sighting is not a restart (there is nothing on screen to be wrong
/// about yet), and an empty instance means the server predates the field —
/// it neither reports nor overwrites what was seen before. Shared by the
/// remote path (`remote_workspace::server_restarted`, keyed per host) and the
/// local link (single daemon), which needs the same comparison to notice the
/// daemon it lost came back as another process (#553).
pub(crate) fn note_instance(seen: &mut String, instance: &str) -> bool {
if instance.is_empty() {
return false;
}
let before = std::mem::replace(seen, instance.to_string());
if !before.is_empty() && before != instance {
log::info!(
"the tty7-server on this machine is a new process ({before} → {instance}); \
its panes are gone"
);
return true;
}
false
}
/// Drops the link to the local daemon and rebuilds every local window from the
/// machine tree, for a caller that killed that daemon itself (#553).
///
/// The invalidate half comes first on purpose: the link still holds the client
/// that pointed at the server that is now gone, and a pull sent down it dies on
/// a dead socket before the reader notices. With it dropped, `hydrate` waits for
/// the reconnect `LocalLink::tick` is already driving and pulls the layout the
/// daemon actually has.
///
/// Only for the caller that has no live link left. One that just handshaked a
/// *new* daemon calls [`resync_local_windows_from_tree`] with that link in hand:
/// dropping it there would throw away a working link and make every window wait
/// out another connect for no reason.
pub(crate) fn resync_after_local_daemon_change(cx: &mut App) {
crate::ui::local_link::LocalLink::invalidate(cx);
resync_local_windows_from_tree(cx);
}
/// Rebuilds every local window from the machine tree, because the daemon behind
/// the local link became a different process — it died and the reconnect found a
/// new one, whose registry knows nothing about the panes on screen (#553).
///
/// Remote windows are left alone: their own link says when their machine's
/// server changed, and this one speaks for this computer only.
///
/// When no daemon answers the pull, the windows owe a rehydration instead
/// (`owe_rehydration`) — which is also the guard that keeps a window emptied by
/// a failed pull from being pushed back up as "close every tab".
pub(crate) fn resync_local_windows_from_tree(cx: &mut App) {
for (workspace, _) in crate::ui::windows::WindowRegistry::open_windows(cx) {
if WorkspaceStore::host_of(cx, workspace) != HostId::LOCAL {
continue;
}
resync_window_from_tree(cx, workspace);
}
}
impl Tty7App {
pub(crate) fn apply_layout_delta(
&mut self,
delta: &LayoutDelta,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> bool {
let index_of = |tabs: &[crate::ui::app::Tab], id: TabId| {
tabs.iter().position(|t| t.tree_id.get() == id)
};
let applied = match delta {
LayoutDelta::WorkspaceCreated { .. }
| LayoutDelta::WorkspaceTouched { .. }
| LayoutDelta::WorkspaceRenamed { .. }
| LayoutDelta::PaneFacts { .. } => true,
// Unreachable: `on_layout_delta` hands a deletion to
// `on_workspace_deleted` and returns before any window is asked. A
// deletion is about whether this workspace still exists here at
// all, which is not a question one window's tab list can answer.
LayoutDelta::WorkspaceDeleted => true,
LayoutDelta::ActiveTabChanged { tab } => {
if let Some(index) = index_of(&self.tabs, *tab) {
self.activate_from_delta(index, window, cx);
}
true
}
LayoutDelta::TabCreated { at, tab } => {
self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx)
}
LayoutDelta::TabClosed { tab } => {
if let Some(index) = index_of(&self.tabs, *tab) {
let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get());
self.tabs.remove(index);
self.active = active_id
.and_then(|id| index_of(&self.tabs, id))
.unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1)));
self.maximized = None;
self.focus_active(window, cx);
}
true
}
LayoutDelta::TabRenamed { tab, name } => {
if let Some(index) = index_of(&self.tabs, *tab) {
self.tabs[index].name = name.clone();
}
true
}
LayoutDelta::TabRegrouped { tab, group } => {
if let Some(index) = index_of(&self.tabs, *tab) {
*self.tabs[index].sidebar_group.borrow_mut() =
group.as_deref().and_then(GroupKey::decode);
}
true
}
LayoutDelta::TabMoved { tab, to } => {
if let Some(from) = index_of(&self.tabs, *tab) {
let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get());
let moved = self.tabs.remove(from);
self.tabs.insert((*to).min(self.tabs.len()), moved);
if let Some(id) = active_id
&& let Some(index) = index_of(&self.tabs, id)
{
self.active = index;
}
}
true
}
LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) {
Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx),
None => false,
},
LayoutDelta::RatioChanged { tab, path, ratio } => {
if let Some(index) = index_of(&self.tabs, *tab) {
set_gui_ratio(&mut self.tabs[index].pane, path, *ratio)
} else {
true
}
}
};
cx.notify();
applied
}
fn activate_from_delta(
&mut self,
index: usize,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) {
if self.active == index {
return;
}
self.maximized = None;
self.active = index;
self.focus_active(window, cx);
}
fn insert_tab_from_tree(
&mut self,
at: usize,
tab: &TreeTab,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> bool {
if self.tabs.iter().any(|t| t.tree_id.get() == tab.id) {
return true;
}
let mut existing = HashMap::new();
let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else {
return false;
};
let gui = crate::ui::app::Tab::from_tree(tab, pane);
self.tabs.insert(at, gui);
if self.active >= at && self.tabs.len() > 1 {
self.active += 1;
}
true
}
fn rebuild_tab_from_tree(
&mut self,
index: usize,
tab: &TreeTab,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> bool {
let remote = WorkspaceStore::all(cx)
.get(self.workspace)
.is_some_and(|w| w.is_remote());
let mut existing: HashMap<u64, PaneSlot> = HashMap::new();
let mut ssh_slots: Vec<PaneSlot> = Vec::new();
for slot in self.tabs[index].pane.leaves() {
let id = match &slot {
PaneSlot::Ready(view) if remote && view.read(cx).ssh_spec().is_some() => {
ssh_slots.push(slot);
continue;
}
PaneSlot::Ready(view) => Some(view.read(cx).pane_id),
PaneSlot::Connecting(pending) => pending.read(cx).spawn.restore_pane,
};
if let Some(id) = id {
existing.insert(id, slot);
}
}
let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else {
return false;
};
let pane = ssh_slots.into_iter().fold(pane, |tree, slot| {
Pane::split_node(gpui::Axis::Horizontal, 0.5, tree, Pane::Leaf(slot))
});
let gui = &mut self.tabs[index];
gui.pane = pane;
gui.name = tab.name.clone();
*gui.sidebar_group.borrow_mut() = tab.sidebar_group.as_deref().and_then(GroupKey::decode);
self.maximized = None;
true
}
fn build_pane_from_tree(
&self,
node: &PaneNode,
existing: &mut HashMap<u64, PaneSlot>,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> Option<Pane> {
match node {
PaneNode::Leaf { pane } => {
if let Some(slot) = existing.remove(pane) {
return Some(Pane::Leaf(slot));
}
match crate::ui::app::new_terminal(
self.window_workspace(cx),
Some(self.workspace),
self.font_size,
None,
Some(*pane),
None,
window,
cx,
) {
Ok(slot) => Some(Pane::Leaf(slot)),
Err(e) => {
log::warn!("could not attach pane {pane} from a delta: {e}");
None
}
}
}
PaneNode::Split { axis, ratio, a, b } => {
let left = self.build_pane_from_tree(a, existing, window, cx);
let right = self.build_pane_from_tree(b, existing, window, cx);
match (left, right) {
(Some(a), Some(b)) => Some(Pane::split_node(
match axis {
TreeAxis::Horizontal => gpui::Axis::Horizontal,
TreeAxis::Vertical => gpui::Axis::Vertical,
},
*ratio,
a,
b,
)),
(one, other) => one.or(other),
}
}
}
}
}
fn set_gui_ratio(pane: &mut Pane, path: &[Side], ratio: f32) -> bool {
match path.split_first() {
None => match pane {
Pane::Split { ratio: cell, .. } => {
cell.set(ratio.clamp(0.05, 0.95));
true
}
_ => false,
},
Some((side, rest)) => match pane {
Pane::Split { a, b, .. } => match side {
Side::A => set_gui_ratio(a, rest, ratio),
Side::B => set_gui_ratio(b, rest, ratio),
},
_ => false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_instance_reports_only_a_real_change() {
let mut seen = String::new();
assert!(
!note_instance(&mut seen, "abc"),
"a first sighting is not a restart"
);
assert!(
!note_instance(&mut seen, "abc"),
"the same process is not a restart"
);
assert!(note_instance(&mut seen, "def"), "a new process is");
assert!(!note_instance(&mut seen, "def"));
}
#[test]
fn note_instance_ignores_a_server_that_predates_the_field() {
let mut seen = String::from("abc");
assert!(
!note_instance(&mut seen, ""),
"an unknown instance is never a restart"
);
assert_eq!(seen, "abc", "and it must not overwrite what was seen");
let mut fresh = String::new();
assert!(!note_instance(&mut fresh, ""));
assert!(
!note_instance(&mut fresh, "abc"),
"the first real instance after an unknown one is a first sighting"
);
}
/// A name typed into the create form is for a workspace the machine has
/// not been told to make yet. Sending it as a rename is sending it into a
/// `NotFound`, which `unsendable` swallows, and the create that follows
/// then names the workspace whatever it rolled — the user's name lost to a
/// generated one every time (#618).
#[gpui::test]
fn a_typed_name_waits_for_the_create_rather_than_racing_it(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
cx.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
name_new_workspace(cx, ws, "deploy".into());
assert_eq!(
cx.default_global::<TreeSync>().windows[&ws]
.chosen_name
.as_deref(),
Some("deploy"),
"the name rides along with the create instead of chasing it"
);
});
}
/// `start_prime` spends the typed name on the create, so a machine that
/// answers with it has settled the matter.
#[gpui::test]
fn the_machine_reading_back_the_typed_name_settles_it(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let (ws, view) = primed_window(cx, Some("deploy"));
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("deploy".into()))),
);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.chosen_name
.is_none(),
"a name the machine has confirmed is not still owed"
);
assert_eq!(
crate::ui::machine_mirror::display_name(cx, &view).as_deref(),
Some("deploy"),
"and it is what the window shows"
);
});
}
/// The other branch of `pull_or_create`: the workspace was already on the
/// machine, so the pull answered and the create never ran — nobody was ever
/// offered the typed name. It has to go out as a rename, and it has to beat
/// the name the pull came back with, which #604 wired straight to the chip.
#[gpui::test]
fn a_workspace_the_machine_already_had_still_takes_the_typed_name(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let (ws, view) = primed_window(cx, Some("deploy"));
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("keen-marten".into()))),
);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.chosen_name
.is_none(),
"said once, not on every later pull"
);
assert_eq!(
crate::ui::machine_mirror::display_name(cx, &view).as_deref(),
Some("deploy"),
"the name the user typed outranks the one the pull answered with"
);
});
}
/// The create that actually runs when a window switches workspaces is
/// `pull_workspace`'s, not `start_prime`'s — `hydrate` is what
/// `switch_workspace` orders, and it rolled its own codename with no idea a
/// name had been typed. Whatever the tree comes back saying, the typed name
/// is what the workspace ends up called.
#[gpui::test]
fn a_hydrating_pull_settles_the_typed_name_too(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
crate::ui::windows::WindowRegistry::init(cx);
let (ws, view) = primed_window(cx, Some("deploy"));
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
let pulled = Machine {
workspaces: vec![tty7_core::core::machine::Workspace {
id: ws,
name: Some("keen-marten".into()),
..Default::default()
}],
panes: Vec::new(),
};
settle_hydration(
cx,
ws,
epoch,
Adopt::IfEmpty,
Ok((pulled, WsMirror::default(), Session::default())),
);
assert_eq!(
crate::ui::machine_mirror::display_name(cx, &view).as_deref(),
Some("deploy"),
"the name the user typed, not the codename the pull rolled"
);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.chosen_name
.is_none(),
"and it is owed only once"
);
});
}
/// A window with no name owed reads whatever the machine says, which is
/// the whole of #604 and must survive the arbitration above.
#[gpui::test]
fn a_window_owing_no_name_still_reads_the_machines(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let (ws, view) = primed_window(cx, None);
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("keen-marten".into()))),
);
assert_eq!(
crate::ui::machine_mirror::display_name(cx, &view).as_deref(),
Some("keen-marten")
);
});
}
/// A window mid-prime with `chosen` parked on it, and a machine this client
/// has pulled, so the mirror has somewhere to write the name.
fn primed_window(
cx: &mut App,
chosen: Option<&str>,
) -> (WorkspaceId, crate::core::session::WindowView) {
tty7_core::core::config::set_config_dir(
std::env::temp_dir().join(format!("tty7-chosen-name-{}", std::process::id())),
);
let view = crate::core::session::WindowView::default();
let ws = view.id;
WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view.clone()],
active: Some(ws),
},
);
crate::ui::machine_mirror::MachineMirrors::install(cx, HostId::LOCAL, Machine::default());
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
state.chosen_name = chosen.map(str::to_string);
(ws, view)
}
#[cfg(unix)]
#[test]
fn a_peer_without_the_machine_tree_bit_classifies_as_unserved() {
use tty7_core::daemon::control::ControlHello;
use tty7_core::host::local::LocalHost;
use tty7_core::host::server::{Services, serve_with};
let connect = |services: Services| {
let (server, client) = std::os::unix::net::UnixStream::pair().unwrap();
std::thread::spawn(move || {
let _ = serve_with(server, LocalHost::new(), services);
});
let hello = ControlHello::host_rpc("test-token", "test-host");
Arc::new(
tty7_core::daemon::control::ControlClient::over_unix(
client,
&hello,
Box::new(|_| {}),
)
.unwrap(),
)
};
let treeless = connect(Services::none());
assert!(matches!(
classify_tree_link(Some(treeless)),
TreeLink::Unserved
));
let dir = std::env::temp_dir().join(format!("tty7-treelink-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = tty7_core::core::machine::MachineStore::open(
dir.join(tty7_core::core::machine::MACHINE_FILE),
);
let serving = connect(Services::with_machine(store));
assert!(matches!(
classify_tree_link(Some(serving)),
TreeLink::Ready(_)
));
assert!(matches!(classify_tree_link(None), TreeLink::Down));
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui::test]
fn a_parked_folder_outlives_the_pull_that_has_to_be_retried(cx: &mut gpui::TestAppContext) {
// The folder an Explorer launch asked for is opened by whichever
// attempt finally lands, so re-entering `hydrate` — which is how every
// retry gets here — must not reset it along with the rest of the pull
// state. Losing it would mean the double-clicked folder never opens.
cx.update(|cx| {
let ws = WorkspaceId::new();
let path = std::path::PathBuf::from("/tmp/from-explorer");
crate::core::session::WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews::default(),
);
crate::ui::windows::WindowRegistry::init(cx);
let parked = |cx: &mut gpui::App| -> Vec<std::path::PathBuf> {
cx.default_global::<TreeSync>().windows[&ws]
.then_open
.iter()
.map(|open| match open {
ParkedOpen::Folder(cwd) => cwd.clone(),
ParkedOpen::Command { cwd, .. } => cwd.clone(),
ParkedOpen::Ssh(_) => std::path::PathBuf::from("<ssh>"),
})
.collect()
};
hydrate_window_then_open(cx, ws, path.clone());
assert_eq!(
parked(cx),
vec![path.clone()],
"the request must be parked, not opened over a layout still in flight"
);
hydrate_with(cx, ws, Adopt::IfEmpty, Vec::new());
assert_eq!(
parked(cx),
vec![path.clone()],
"a retry must still owe the folder"
);
// The command for the folder this window was opened for lands in
// that parked tab rather than adding a second one beside it.
assert!(park_command_while_pulling(cx, ws, &path, "./build.sh"));
assert_eq!(parked(cx), vec![path.clone()]);
assert!(matches!(
&cx.default_global::<TreeSync>().windows[&ws].then_open[0],
ParkedOpen::Command { command, .. } if command == "./build.sh"
));
// A LaunchServices open for somewhere else queues behind it rather
// than replacing it.
let script = std::path::PathBuf::from("/tmp/from-finder");
assert!(park_command_while_pulling(cx, ws, &script, "./deploy.sh"));
assert_eq!(parked(cx), vec![path.clone(), script]);
// With no window to put them in there is nothing to open, and the
// requests must not survive to surface in some unrelated window.
open_parked_path(cx, ws);
assert!(parked(cx).is_empty());
// A window whose layout has settled is opened into directly.
cx.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap()
.sync = SyncPhase::Primed(WsMirror::default());
assert!(!park_command_while_pulling(cx, ws, &path, "./build.sh"));
});
}
#[gpui::test]
fn preemption_drops_the_mirror_the_queue_and_the_informed_licence(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let ws = WorkspaceId::new();
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.sync = SyncPhase::Primed(WsMirror::default());
state.informed = true;
state.queue.push_back(ControlRequest::Ping);
}
on_preempted(cx, ws);
let state = &cx.default_global::<TreeSync>().windows[&ws];
assert!(matches!(
state.sync,
SyncPhase::Unprimed {
dirty: false,
priming: false,
}
));
assert!(
state.queue.is_empty(),
"queued ops belong to the lost session"
);
assert!(
!state.informed,
"the licence to prune must not survive a takeover"
);
});
}
/// The destructive half of a deletion. It erases state only this client
/// holds — geometry, the label, a remote binding — so the fence in front of
/// it ("no window is showing this workspace") is the whole safety of it.
///
/// The other half needs a live `Tty7App` in a real window to reach, so it
/// is not tested here; what it does is hydrate, which the hydration tests
/// cover, and it touches neither the store nor the registry.
#[gpui::test]
fn a_deletion_nothing_has_open_forgets_the_workspace_here_too(cx: &mut gpui::TestAppContext) {
use crate::core::session::{WindowView, WindowViews};
cx.update(|cx| {
// Removing a workspace saves the views, and a test has no business
// writing the real ones.
let _ = tty7_core::core::config::set_config_dir(
std::env::temp_dir().join(format!("tty7-deleted-test-{}", std::process::id())),
);
crate::ui::windows::WindowRegistry::init(cx);
let deleted = WindowView::default();
let gone = deleted.id;
let untouched = WindowView::default();
let survivor = untouched.id;
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![deleted, untouched],
active: Some(gone),
},
);
cx.default_global::<TreeSync>()
.windows
.entry(gone)
.or_default()
.sync = SyncPhase::Primed(WsMirror::default());
on_workspace_deleted(cx, gone);
assert!(
WorkspaceStore::all(cx).get(gone).is_none(),
"a row that opens onto nothing is worse than no row at all"
);
assert_eq!(
WorkspaceStore::all(cx).active,
None,
"the active workspace cannot be one that no longer exists"
);
assert!(
WorkspaceStore::all(cx).get(survivor).is_some(),
"a deletion is about one workspace, not about the store"
);
assert!(
!cx.default_global::<TreeSync>().windows.contains_key(&gone),
"its sync state has nothing left to be about"
);
});
}
/// The rule that stops a failed rebuild from being read as "empty".
///
/// A window with no tabs may delete its workspace outright — tree and store
/// both — so the two ways of having no tabs must not look alike. Genuinely
/// empty is a reason; "the panes would not start" is not, and it is what
/// every tab looks like when the pane socket has gone away.
#[gpui::test]
fn only_a_mirror_that_agrees_lets_an_empty_window_delete_its_workspace(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let set = |cx: &mut App, informed: bool, sync: SyncPhase| {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.informed = informed;
state.sync = sync;
};
let unprimed = || SyncPhase::Unprimed {
dirty: false,
priming: false,
};
let primed_with =
|tabs: Vec<TreeTab>| SyncPhase::Primed(WsMirror { tabs, active: None });
let a_tab = || TreeTab {
id: TabId::new(),
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
};
assert!(
!workspace_is_disposable(cx, WorkspaceId::new()),
"a workspace nothing is tracking is not a workspace to delete"
);
set(cx, true, unprimed());
assert!(
!workspace_is_disposable(cx, ws),
"an unpulled mirror knows nothing, and not knowing must never authorize this"
);
set(cx, true, primed_with(vec![a_tab()]));
assert!(
!workspace_is_disposable(cx, ws),
"this is the regression: the window came up empty because the rebuild failed, \
while the machine still held the tabs. Deleting here destroyed them."
);
set(cx, false, primed_with(vec![]));
assert!(
!workspace_is_disposable(cx, ws),
"a window that never put up a layout does not get to say what belongs here"
);
set(cx, true, primed_with(vec![]));
assert!(
workspace_is_disposable(cx, ws),
"informed, and the machine agrees it holds nothing — the one case that is"
);
});
}
/// #554: Restart Server empties the window *before* it tries the handoff,
/// and a refused handoff leaves the daemon exactly as it was, still serving
/// every pane the window just dropped.
///
/// What makes that recoverable is the window giving up its claim to speak
/// for the machine the moment the failure lands. An emptied window that is
/// still `informed` over a `Primed` mirror is the dangerous shape: the next
/// sync diffs it into "close every tab", and closing the window authorizes
/// a `WorkspaceRemove` outright — the shells stay alive, but their records
/// go, and nothing tree-driven can ever reach them again.
///
/// Tested on `resync_window_from_tree`, which is the per-window step both
/// [`resync_after_local_daemon_change`] and [`resync_local_windows_from_tree`]
/// do their work through — the pair above it only decides whether the link
/// is dropped first and which windows are walked, neither of which is what
/// keeps the emptied window quiet.
#[gpui::test]
fn a_failed_daemon_change_stops_the_emptied_window_speaking_for_the_machine(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let tab = TabId::new();
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.informed = true;
// The mirror the emptied window would have been diffed against,
// already drained the way `save_session` drains it on the way
// out of a window that has no tabs left.
state.sync = SyncPhase::Primed(WsMirror {
tabs: vec![],
active: None,
});
state
.queue
.push_back(ControlRequest::TabClose { workspace: ws, tab });
}
assert!(
workspace_is_disposable(cx, ws),
"the shape #554 leaves behind: an emptied window that still speaks for a \
machine holding live panes, and may delete the workspace off it"
);
resync_window_from_tree(cx, ws);
assert!(
!workspace_is_disposable(cx, ws),
"this is the regression: closing the window after a refused handoff sent \
WorkspaceRemove and took the whole workspace, live panes and all"
);
let state = &cx.default_global::<TreeSync>().windows[&ws];
assert!(
matches!(state.sync, SyncPhase::Unprimed { .. }),
"the mirror the emptied window would diff into 'close every tab' has to be \
dropped, not carried into the next sync"
);
assert!(
state.queue.is_empty(),
"operations queued from the emptied window speak for a layout that is being \
pulled again; sending them would close the tabs it is pulling"
);
});
}
#[gpui::test]
fn a_hydration_that_died_on_a_stale_link_is_owed_back(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let epoch = {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
state.epoch
};
owe_rehydration(cx, ws, epoch, Adopt::Replace);
let state = &cx.default_global::<TreeSync>().windows[&ws];
assert!(
matches!(state.sync, SyncPhase::Unprimed { priming: false, .. }),
"the attempt is over; another one must be able to start"
);
assert!(
state.rehydrate.is_some(),
"dropping the failure here is what left the window on the home page"
);
// A newer attempt has already taken over — the loser must not
// re-arm a retry behind its back.
{
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap();
state.rehydrate = None;
state.epoch += 1;
}
owe_rehydration(cx, ws, epoch, Adopt::Replace);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.rehydrate
.is_none()
);
});
}
/// The debt an owed pull records is worth nothing without something that
/// pays it. A pull can fail with the link up and healthy — a `MachineGet`
/// past its deadline on a slow link, a create that lost its race — and
/// then no reconnect ever happens to notice, and an empty window has no
/// edit in it to drive a sync. The window sat there empty, with every tab
/// still on the machine, until the app was restarted.
#[gpui::test]
fn an_owed_pull_is_retried_until_it_is_paid_or_superseded(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let epoch = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default()
.epoch;
owe_rehydration(cx, ws, epoch, Adopt::IfEmpty);
assert!(
still_owed(cx, ws, epoch),
"the retry armed for this debt must still recognise it"
);
// Paid: the pull landed, so the retry that is still in flight has
// to stand down rather than replay the machine over the window.
cx.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.expect("owed above")
.rehydrate = None;
assert!(!still_owed(cx, ws, epoch));
// Superseded: a newer hydration owns the window now.
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.expect("owed above");
state.rehydrate = Some(Adopt::IfEmpty);
state.epoch += 1;
assert!(!still_owed(cx, ws, epoch));
assert!(still_owed(cx, ws, epoch + 1));
});
}
#[test]
fn the_retry_backs_off_and_settles_at_a_cap() {
let secs = |n| rehydrate_backoff(n).as_secs();
assert_eq!(secs(1), 2, "the first retry is prompt: a window is empty");
assert!(
secs(1) < secs(2) && secs(2) < secs(3),
"a machine that keeps refusing must be asked less often, not more"
);
assert_eq!(secs(REHYDRATE_SETTLED), 30);
assert_eq!(
secs(50),
30,
"a window left open on an unreachable machine settles at the cap"
);
}
/// Once the backoff stops growing the same failure repeats every thirty
/// seconds for as long as the window stays open. Reporting each one at full
/// volume turns one unreachable machine into a log nobody can read past.
#[test]
fn a_standing_failure_stops_shouting_once_the_backoff_settles() {
assert_eq!(
hydration_log_level(1, log::Level::Warn),
log::Level::Warn,
"the first failures are news and must stay news"
);
assert_eq!(
hydration_log_level(REHYDRATE_SETTLED, log::Level::Warn),
log::Level::Debug
);
assert_eq!(
hydration_log_level(REHYDRATE_SETTLED, log::Level::Info),
log::Level::Debug,
"the step down is to debug from wherever it started, not to warn"
);
}
/// #604: the answer to the create this window asked for is the only place
/// it will ever hear the workspace's name — a client is left out of the
/// deltas its own ops raise — so priming has to hand that name to the
/// mirror. Dropping it left the window showing an unnamed workspace, and
/// the name it had had all along arrived with the next full pull looking
/// like a rename.
#[gpui::test]
fn priming_teaches_the_mirror_the_name_the_machine_gave_the_workspace(
cx: &mut gpui::TestAppContext,
) {
cx.update(|cx| {
tty7_core::core::config::set_config_dir(
std::env::temp_dir().join(format!("tty7-prime-name-{}", std::process::id())),
);
let view = crate::core::session::WindowView::default();
let ws = view.id;
WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view.clone()],
active: Some(ws),
},
);
// A machine this client has pulled, from before the workspace it is
// about to create existed on it.
crate::ui::machine_mirror::MachineMirrors::install(
cx,
HostId::LOCAL,
Machine::default(),
);
cx.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default()
.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
finish_prime(
cx,
ws,
epoch,
Ok((WsMirror::default(), Some("keen-marten".to_string()))),
);
assert_eq!(
crate::ui::machine_mirror::display_name(cx, &view).as_deref(),
Some("keen-marten"),
"the window knows what its workspace is called as soon as it exists"
);
});
}
/// The count paces the retry, so it has to mean "failures in a row". Left
/// standing after the run ends, it makes the next *first* failure wait the
/// cap on an outage that was already over.
#[gpui::test]
fn the_backoff_count_ends_with_the_run_of_failures(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let _ = tty7_core::core::config::set_config_dir(
std::env::temp_dir().join(format!("tty7-backoff-count-{}", std::process::id())),
);
let view = crate::core::session::WindowView::default();
let ws = view.id;
WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view],
active: Some(ws),
},
);
let unprimed = |cx: &mut App| {
cx.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default()
.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
};
let attempts =
|cx: &mut App| cx.default_global::<TreeSync>().windows[&ws].rehydrate_attempts;
unprimed(cx);
let epoch = cx.default_global::<TreeSync>().windows[&ws].epoch;
for expected in 1..=3 {
unprimed(cx);
owe_rehydration(cx, ws, epoch, Adopt::IfEmpty);
assert_eq!(
attempts(cx),
expected,
"each failure in the run paces the next"
);
}
// The machine answered. Whatever it was, it is over.
unprimed(cx);
finish_prime(cx, ws, epoch, Ok((WsMirror::default(), None)));
assert_eq!(
attempts(cx),
0,
"a prime landing is the machine answering, which is the whole question"
);
// Abandoned rather than paid: the user filled the window in
// themselves, so the `Replace` is dropped — and the run is over too.
{
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap();
state.rehydrate = Some(Adopt::Replace);
state.owed_over = Vec::new();
state.rehydrate_attempts = 4;
}
assert!(take_rehydrate(cx, ws, &[TabId::new()]).is_none());
assert_eq!(
attempts(cx),
0,
"a debt nobody owes any more cannot go on pacing the next one"
);
});
}
/// The retry fires on a timer, so the window it was armed for can be gone
/// by the time it runs. It has to notice and stand down — and leave the
/// debt where it is, because a window that is not there is not one that
/// has been paid.
#[gpui::test]
async fn a_retry_that_finds_no_window_stands_down(cx: &mut gpui::TestAppContext) {
let ws = cx.update(|cx| {
crate::ui::windows::WindowRegistry::init(cx);
let ws = WorkspaceId::new();
let epoch = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default()
.epoch;
owe_rehydration(cx, ws, epoch, Adopt::IfEmpty);
ws
});
// Well past the first backoff: the armed retry really runs, rather than
// the test ending while it is still asleep.
cx.executor().advance_clock(rehydrate_backoff(1) * 2);
cx.executor().run_until_parked();
cx.update(|cx| {
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.rehydrate
.is_some(),
"the debt outlives a retry that found nothing to pay it into"
);
});
}
#[gpui::test]
fn a_window_that_filled_up_while_owed_keeps_what_it_has(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let stale = [TabId::new(), TabId::new()];
let arm = |cx: &mut App, adopt, over: &[TabId]| {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.rehydrate = Some(adopt);
state.owed_over = over.to_vec();
};
arm(cx, Adopt::Replace, &[]);
assert!(
take_rehydrate(cx, ws, &[]).is_some(),
"an empty window is exactly the one that still needs its layout"
);
assert!(
take_rehydrate(cx, ws, &[]).is_none(),
"the debt is claimed once"
);
// #579: the tabs a `Replace` was ordered over are the ones it exists
// to replace. Reading them as "the user moved on" abandoned the
// retry on its first attempt for every resync of a window that kept
// its tabs — a daemon back as a new process, a remote server
// restarted — and the resync silently did nothing at all.
arm(cx, Adopt::Replace, &stale);
assert!(
take_rehydrate(cx, ws, &stale).is_some(),
"the dead tabs the resync was ordered over cannot be the reason to drop it"
);
arm(cx, Adopt::Replace, &stale);
assert!(
take_rehydrate(cx, ws, &stale[..1]).is_some(),
"closing some of them is not filling the window in either"
);
arm(cx, Adopt::Replace, &stale);
assert!(
take_rehydrate(cx, ws, &[stale[0], TabId::new()]).is_none(),
"replaying an older layout over the user's new tabs is worse than not retrying"
);
assert!(
cx.default_global::<TreeSync>().windows[&ws]
.rehydrate
.is_none(),
"and the dropped retry must not linger"
);
arm(cx, Adopt::IfEmpty, &[]);
assert!(
take_rehydrate(cx, ws, &[TabId::new()]).is_some(),
"IfEmpty polices that itself, and still owes the mirror a pull"
);
});
}
/// #579: the other half of an abandoned `Replace`. Dropping the retry is a
/// decision about what to *put up*; it says nothing about the window having
/// learned what belongs in the workspace, and it never did learn — the pull
/// it was told to make failed.
///
/// Left `informed`, that window went on to diff at `SyncScope::Full`
/// against a mirror `start_prime` had just refilled from the machine, so
/// the one tab the user opened over an emptied window emitted `TabClose`
/// for every other tab on it. Those panes' records went with them while
/// their shells kept running, and nothing tree-driven could reach them
/// again — the same damage as #554, through a door #554 did not close.
#[gpui::test]
fn a_resync_takes_back_the_licence_to_prune_until_a_pull_lands(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.informed = true;
}
resync_window_from_tree(cx, ws);
assert!(
!cx.default_global::<TreeSync>().windows[&ws].informed,
"a window told its contents are not the workspace cannot still speak for it"
);
// The shape the damage needs: the mirror back from the machine,
// full of the tabs the window never adopted. Without the licence
// the sync is additive, which
// `an_additive_diff_never_closes_tabs_the_window_has_not_seen`
// pins as closing nothing.
{
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap();
state.rehydrate = None;
state.sync = SyncPhase::Primed(WsMirror {
tabs: vec![TreeTab::leaf(1), TreeTab::leaf(2)],
active: None,
});
}
assert!(
!workspace_is_disposable(cx, ws),
"and it cannot delete the workspace out from under those panes either"
);
// A pull that lands is the only thing that hands the licence back.
mark_window_informed(cx, ws);
assert!(cx.default_global::<TreeSync>().windows[&ws].informed);
});
}
/// An `IfEmpty` hydration says the opposite of a `Replace`: keep whatever
/// the window has. A window that opened onto a workspace deleted under it
/// is put back by writing its own tabs up, and needs the licence to do it.
#[gpui::test]
fn an_if_empty_pull_leaves_the_licence_where_it_found_it(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
cx.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default()
.informed = true;
hydrate_window_from_tree(cx, ws);
assert!(cx.default_global::<TreeSync>().windows[&ws].informed);
});
}
/// #672's residual: a rebuild that put up some of the tabs the tree asked
/// for and dropped the rest (`tabs_from_session` drops a tab none of whose
/// panes would start). It earns the licence — it did put a layout up — and
/// the tabs it dropped are not tabs the user closed, so they must be held
/// out of the diff rather than the licence withheld from the whole window.
#[gpui::test]
fn a_partial_rebuild_holds_the_tabs_it_could_not_put_up(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let (put_up, failed) = (TabId::new(), TabId::new());
let settled = |cx: &mut App| {
let state = &cx.default_global::<TreeSync>().windows[&ws];
(state.informed, state.not_rebuilt.clone())
};
settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]);
assert_eq!(
settled(cx),
(true, vec![failed]),
"the window speaks for the tab it put up, and holds the one it could not"
);
settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up, failed]);
assert_eq!(
settled(cx),
(true, vec![]),
"a later rebuild that puts them all up holds nothing back"
);
cx.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap()
.informed = false;
settle_rebuild(cx, ws, 2, &[put_up, failed], &[]);
assert_eq!(
settled(cx),
(false, vec![put_up, failed]),
"a rebuild that produced nothing still does not get to speak for the workspace"
);
settle_rebuild(cx, ws, 0, &[], &[]);
assert_eq!(
settled(cx),
(true, vec![]),
"an empty tree rebuilt into an empty window is the one case that is genuinely empty"
);
// A session with tabs but no ids — what a disk-loaded session
// looks like, since `tree_id` is not serialized — that rebuilt
// nothing. "No ids" must not read as "no tabs wanted".
cx.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap()
.informed = false;
settle_rebuild(cx, ws, 2, &[], &[]);
assert_eq!(
settled(cx),
(false, vec![]),
"the guard counts the tabs asked for, not the ids found"
);
});
}
/// The consequence, on the sync that follows: the mirror holds both tabs,
/// the window shows one, and the one it could not put up must not come out
/// of a `Full` diff as `TabClose` — that op deleted from the machine exactly
/// the tabs a restart had failed to bring back, panes and all (#672).
#[gpui::test]
fn the_next_sync_leaves_a_tab_the_rebuild_could_not_put_up_on_the_machine(
cx: &mut gpui::TestAppContext,
) {
let (app, mut vcx, _pane_stream) = crate::ui::app::test_window::harness_with_pane(cx);
let (put_up, failed) = (TabId::new(), TabId::new());
app.update_in(&mut vcx, |app, _, cx| {
let view = crate::core::session::WindowView::default();
let ws = view.id;
WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view],
active: Some(ws),
},
);
app.workspace = ws;
app.tabs[0].tree_id.set(put_up);
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.sync = SyncPhase::Primed(WsMirror {
tabs: vec![
TreeTab {
id: put_up,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
},
TreeTab {
id: failed,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 2 },
},
],
active: Some(put_up),
});
// Keeps whatever the sync queues where the test can read it:
// with no link, `pump` would otherwise clear the queue and drop
// the mirror on its way to a re-pull.
state.inflight = true;
}
settle_rebuild(cx, ws, 2, &[put_up, failed], &[put_up]);
assert!(
cx.default_global::<TreeSync>().windows[&ws].informed,
"the shape the damage needs: a window licensed to prune, over a mirror \
holding a tab it is not showing"
);
sync_window(app, cx);
let state = &cx.default_global::<TreeSync>().windows[&ws];
assert!(
!state
.queue
.iter()
.any(|op| matches!(op, ControlRequest::TabClose { .. })),
"the tab that failed to come back is not one the user closed: {:?}",
state.queue
);
match &state.sync {
SyncPhase::Primed(mirror) => assert_eq!(
mirror.tabs.iter().map(|t| t.id).collect::<Vec<_>>(),
vec![put_up, failed],
"and it stays on the machine for the next rebuild to put up"
),
SyncPhase::Unprimed { .. } => {
panic!("the sync must not have thrown the mirror away")
}
}
});
}
/// #716: switching into a workspace put its empty session up and saved
/// it — a window showing nothing, syncing at Full scope against the
/// mirror and licence the last visit left behind, which closes every tab
/// on the machine before the pull that would have populated the window
/// has even been ordered. Arriving must speak for nothing.
///
/// The licence is what the assertion holds. The closes it authorises are
/// queued and pumped inside `adopt_workspace`, and the hydrate ordered
/// straight after clears the queue, so by the time a test can look the
/// ops are gone either way — while `informed` outliving the arrival is
/// both durable and the thing that made them possible.
#[gpui::test]
fn arriving_at_a_workspace_does_not_prune_what_is_already_in_it(cx: &mut gpui::TestAppContext) {
let (app, mut vcx, _pane_stream) = crate::ui::app::test_window::harness_with_pane(cx);
let theirs = (TabId::new(), TabId::new());
app.update_in(&mut vcx, |app, window, cx| {
crate::ui::windows::WindowRegistry::init(cx);
let here = crate::core::session::WindowView::default();
let there = crate::core::session::WindowView::default();
let (here_id, there_id) = (here.id, there.id);
WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![here, there],
active: Some(here_id),
},
);
app.workspace = here_id;
// The workspace being switched to, as an earlier visit left it:
// primed with the tabs it holds, and licensed to prune them.
{
let state = cx
.default_global::<TreeSync>()
.windows
.entry(there_id)
.or_default();
state.sync = SyncPhase::Primed(WsMirror {
tabs: vec![
TreeTab {
id: theirs.0,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 11 },
},
TreeTab {
id: theirs.1,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 12 },
},
],
active: Some(theirs.0),
});
state.informed = true;
// Keeps whatever the switch queues where the test can read it.
state.inflight = true;
}
app.switch_workspace(Some(there_id), window, cx);
let state = &cx.default_global::<TreeSync>().windows[&there_id];
assert!(
!state.informed,
"a window that has just arrived speaks for nothing in the workspace \
until its own pull lands — least of all that it is empty"
);
assert!(
!state
.queue
.iter()
.any(|op| matches!(op, ControlRequest::TabClose { .. })),
"and it closes nothing it never showed: {:?}",
state.queue
);
});
}
#[test]
fn a_ratio_delta_is_clamped_to_the_servers_band_not_a_narrower_one() {
let mut pane = Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::Empty, Pane::Empty);
assert!(set_gui_ratio(&mut pane, &[], 0.07));
match &pane {
Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.07),
_ => unreachable!("built as a split"),
}
assert!(set_gui_ratio(&mut pane, &[], 0.01));
match &pane {
Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.05),
_ => unreachable!("built as a split"),
}
}
#[test]
fn a_tab_created_delta_that_straddled_a_repull_lands_once_in_the_window_mirror() {
let mut mirror = WsMirror::default();
let delta = LayoutDelta::TabCreated {
at: 0,
tab: TreeTab::leaf(1),
};
assert!(apply_to_mirror(&mut mirror, &delta));
assert!(apply_to_mirror(&mut mirror, &delta));
assert_eq!(mirror.tabs.len(), 1);
}
#[gpui::test]
fn a_superseded_prime_result_does_not_roll_the_mirror_back(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let ws = WorkspaceId::new();
let stale_epoch = {
let state = cx
.default_global::<TreeSync>()
.windows
.entry(ws)
.or_default();
state.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
state.epoch
};
let advanced = WsMirror {
tabs: vec![TreeTab::leaf(7)],
active: None,
};
{
let state = cx
.default_global::<TreeSync>()
.windows
.get_mut(&ws)
.unwrap();
state.epoch += 1;
state.sync = SyncPhase::Primed(advanced.clone());
}
finish_prime(cx, ws, stale_epoch, Ok((WsMirror::default(), None)));
match &cx.default_global::<TreeSync>().windows[&ws].sync {
SyncPhase::Primed(mirror) => assert_eq!(
*mirror, advanced,
"the stale pull's empty answer must not replace the advanced mirror"
),
_ => panic!("the mirror was dropped entirely"),
}
});
}
fn seed(pane: u64) -> PaneSeed {
PaneSeed {
pane,
cwd: Some(format!("/work/{pane}")),
ssh_spec: None,
agent: None,
shell: None,
}
}
fn leaf(pane: u64) -> DesiredNode {
DesiredNode::Leaf {
pane,
seed: seed(pane),
}
}
fn split(axis: TreeAxis, ratio: f32, a: DesiredNode, b: DesiredNode) -> DesiredNode {
DesiredNode::Split {
axis,
ratio,
a: Box::new(a),
b: Box::new(b),
}
}
fn tab(id: TabId, root: DesiredNode) -> DesiredTab {
DesiredTab {
id,
name: None,
group: None,
root,
}
}
#[test]
fn seeded_records_carry_the_seed_and_the_windows_own_liveness() {
let desired = vec![
tab(
TabId::new(),
split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)),
),
tab(TabId::new(), leaf(3)),
];
let records = seeded_records(&desired, |pane| pane != 2);
assert_eq!(
records.iter().map(|r| r.id).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert_eq!(records[0].cwd.as_deref(), Some("/work/1"));
assert!(records[0].live, "a pane the window holds open is live");
assert!(!records[1].live, "one still connecting is not yet");
assert!(records[2].live);
}
fn assert_converged(mirror: &WsMirror, desired: &[DesiredTab]) {
assert_eq!(mirror.tabs.len(), desired.len());
for (m, d) in mirror.tabs.iter().zip(desired) {
assert_eq!(m.id, d.id);
assert_eq!(m.name, d.name);
assert_eq!(m.sidebar_group, d.group);
assert_eq!(m.root, d.root.to_pane_node());
}
}
#[test]
fn opening_the_first_tab_emits_a_create_carrying_the_client_identity() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let desired = vec![tab(id, leaf(7))];
let ops = diff(ws, &mut mirror, &desired, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::TabCreate {
workspace: ws,
at: Some(0),
pane: seed(7),
tab: Some(id),
}],
"a created tab is active on the server, so no separate active op"
);
assert_converged(&mirror, &desired);
assert_eq!(mirror.active, Some(id));
}
#[test]
fn a_split_emits_one_pane_split_against_its_sibling() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let one = vec![tab(id, leaf(1))];
diff(ws, &mut mirror, &one, Some(id), SyncScope::Full, &[]);
let two = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))];
let ops = diff(ws, &mut mirror, &two, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneSplit {
workspace: ws,
pane: 1,
axis: TreeAxis::Vertical,
ratio: 0.5,
new: seed(2),
first: false,
}]
);
assert_converged(&mirror, &two);
}
#[test]
fn a_new_pane_on_the_upper_side_splits_with_first_set() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, leaf(1))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, split(TreeAxis::Horizontal, 0.4, leaf(2), leaf(1)))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneSplit {
workspace: ws,
pane: 1,
axis: TreeAxis::Horizontal,
ratio: 0.4,
new: seed(2),
first: true,
}]
);
assert_converged(&mirror, &want);
}
#[test]
fn closing_a_pane_emits_pane_close_and_the_split_collapses() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, leaf(1))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneClose {
workspace: ws,
pane: 2
}]
);
assert_converged(&mirror, &want);
}
#[test]
fn dragging_a_pane_across_the_layout_emits_one_pane_move() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
// 1 | 2
// ——+——
// 3
let grid = |a: DesiredNode, b: DesiredNode| split(TreeAxis::Vertical, 0.5, a, b);
let before = vec![tab(
id,
grid(split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), leaf(3)),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
// 1 dropped below 3, which leaves 2 holding the top row alone.
let after = vec![tab(
id,
grid(leaf(2), split(TreeAxis::Vertical, 0.5, leaf(3), leaf(1))),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneMove {
workspace: ws,
pane: 1,
to: 3,
axis: TreeAxis::Vertical,
first: false,
}],
"the tab is reshaped in place, not closed and rebuilt"
);
assert_converged(&mirror, &after);
}
#[test]
fn merging_a_tab_into_another_moves_its_panes_and_takes_the_tab_with_them() {
let ws = WorkspaceId::new();
let (host, guest) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![
tab(host, leaf(1)),
tab(guest, split(TreeAxis::Horizontal, 0.5, leaf(2), leaf(3))),
];
diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]);
// The guest tab dropped on the right of pane 1, arriving as the column
// of two it already was.
let after = vec![tab(
host,
split(
TreeAxis::Horizontal,
0.5,
leaf(1),
split(TreeAxis::Vertical, 0.5, leaf(2), leaf(3)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneMove {
workspace: ws,
pane: 2,
to: 1,
axis: TreeAxis::Horizontal,
first: false,
},
ControlRequest::PaneMove {
workspace: ws,
pane: 3,
to: 2,
axis: TreeAxis::Vertical,
first: false,
},
],
"the panes cross one at a time and the emptied tab goes with them, \
rather than a tab's worth of panes being closed and respawned"
);
assert_converged(&mirror, &after);
assert_eq!(mirror.active, Some(host));
}
#[test]
fn a_tab_grafted_above_a_whole_layout_still_converges() {
let ws = WorkspaceId::new();
let (host, guest) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![
tab(host, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2))),
tab(guest, leaf(3)),
];
diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]);
// Dropped against the host's outer edge, so the newcomer sits above the
// whole two-pane layout rather than beside one of its panes. No single
// `PaneMove` can say that, and `migrate_panes` says nothing at all: the
// passes after it have to land the tab anyway, by the rebuild they have
// always fallen back to.
let after = vec![tab(
host,
split(
TreeAxis::Horizontal,
0.33,
leaf(3),
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
),
)];
diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]);
assert_converged(&mirror, &after);
}
#[test]
fn a_pane_leaving_for_a_tab_of_its_own_gives_it_up_before_it_asks_for_it() {
let ws = WorkspaceId::new();
let (held, fresh) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = vec![tab(
held,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
)];
diff(ws, &mut mirror, &before, Some(held), SyncScope::Full, &[]);
// Pane 2 dropped on the strip ahead of the tab it came from, so the tab
// it becomes is desired *first*.
let after = vec![tab(fresh, leaf(2)), tab(held, leaf(1))];
let ops = diff(ws, &mut mirror, &after, Some(fresh), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneClose {
workspace: ws,
pane: 2,
},
ControlRequest::TabCreate {
workspace: ws,
at: Some(0),
pane: seed(2),
tab: Some(fresh),
},
],
"the machine refuses a pane that is in two tabs at once, so the old \
tab lets go before the new one is built"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_move_that_lands_on_a_new_ratio_settles_it_after_the_move() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let before = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
leaf(3),
),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
let after = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
leaf(2),
split(TreeAxis::Vertical, 0.25, leaf(3), leaf(1)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneMove {
workspace: ws,
pane: 1,
to: 3,
axis: TreeAxis::Vertical,
first: false,
},
ControlRequest::PaneSetRatio {
workspace: ws,
tab: id,
path: vec![Side::B],
ratio: 0.25,
},
],
"a move splits at a half, so a wanted ratio needs its own op"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_swap_no_single_op_expresses_rebuilds_the_tab_whole() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let before = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(4)),
),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
// 1 and 4 trade corners: two panes moved, which no one op describes.
let after = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(4), leaf(2)),
split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(1)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert!(
matches!(ops.first(), Some(ControlRequest::TabClose { .. })),
"expected the rebuild fallback, got {ops:?}"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_revived_leaf_emits_pane_replace_with_the_successors_seed() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(9)))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneReplace {
workspace: ws,
old: 2,
new: seed(9),
}]
);
assert_converged(&mirror, &want);
}
#[test]
fn a_ratio_drag_emits_set_ratio_with_the_splits_path() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let nested = |r| {
split(
TreeAxis::Vertical,
0.5,
leaf(1),
split(TreeAxis::Horizontal, r, leaf(2), leaf(3)),
)
};
diff(
ws,
&mut mirror,
&[tab(id, nested(0.5))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, nested(0.7))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneSetRatio {
workspace: ws,
tab: id,
path: vec![Side::B],
ratio: 0.7,
}]
);
assert_converged(&mirror, &want);
}
#[test]
fn closing_a_tab_emits_tab_close_and_heals_the_active_tab() {
let ws = WorkspaceId::new();
let (a, b) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(a, leaf(1)), tab(b, leaf(2))],
Some(b),
SyncScope::Full,
&[],
);
let want = vec![tab(a, leaf(1))];
let ops = diff(ws, &mut mirror, &want, None, SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::TabClose {
workspace: ws,
tab: b
}],
"the heal is the server's own rule, so no active op crosses"
);
assert_converged(&mirror, &want);
assert_eq!(mirror.active, Some(a));
}
#[test]
fn a_tab_reorder_emits_moves_that_land_the_windows_order() {
let ws = WorkspaceId::new();
let (a, b, c) = (TabId::new(), TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let before = [tab(a, leaf(1)), tab(b, leaf(2)), tab(c, leaf(3))];
diff(ws, &mut mirror, &before, Some(c), SyncScope::Full, &[]);
let want = vec![tab(c, leaf(3)), tab(a, leaf(1)), tab(b, leaf(2))];
let ops = diff(ws, &mut mirror, &want, Some(c), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::TabMove {
workspace: ws,
tab: c,
to: 0
}]
);
assert_converged(&mirror, &want);
}
#[test]
fn renaming_and_regrouping_emit_their_label_ops() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, leaf(1))],
Some(id),
SyncScope::Full,
&[],
);
let mut named = tab(id, leaf(1));
named.name = Some("build".into());
named.group = Some("/repo".into());
let want = vec![named];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::TabRename {
workspace: ws,
tab: id,
name: Some("build".into()),
},
ControlRequest::TabSetGroup {
workspace: ws,
tab: id,
group: Some("/repo".into()),
},
]
);
assert_converged(&mirror, &want);
}
#[test]
fn switching_tabs_emits_only_set_active_tab() {
let ws = WorkspaceId::new();
let (a, b) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
let both = [tab(a, leaf(1)), tab(b, leaf(2))];
diff(ws, &mut mirror, &both, Some(b), SyncScope::Full, &[]);
let ops = diff(ws, &mut mirror, &both, Some(a), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::WorkspaceSetActiveTab {
workspace: ws,
tab: a
}]
);
assert_eq!(mirror.active, Some(a));
}
#[test]
fn a_deep_tree_materializes_top_split_first_and_converges() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let want = vec![tab(
id,
split(
TreeAxis::Horizontal,
0.6,
split(TreeAxis::Vertical, 0.3, leaf(1), leaf(2)),
split(TreeAxis::Vertical, 0.7, leaf(3), leaf(4)),
),
)];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::TabCreate {
workspace: ws,
at: Some(0),
pane: seed(1),
tab: Some(id),
},
ControlRequest::PaneSplit {
workspace: ws,
pane: 1,
axis: TreeAxis::Horizontal,
ratio: 0.6,
new: seed(3),
first: false,
},
ControlRequest::PaneSplit {
workspace: ws,
pane: 1,
axis: TreeAxis::Vertical,
ratio: 0.3,
new: seed(2),
first: false,
},
ControlRequest::PaneSplit {
workspace: ws,
pane: 3,
axis: TreeAxis::Vertical,
ratio: 0.7,
new: seed(4),
first: false,
},
]
);
assert_converged(&mirror, &want);
}
#[test]
fn an_unchanged_window_emits_nothing() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))];
diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]),
Vec::new()
);
}
#[test]
fn a_tab_whose_panes_are_all_still_spawning_is_held_not_closed() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, leaf(1))],
Some(id),
SyncScope::Full,
&[],
);
let ops = diff(ws, &mut mirror, &[], None, SyncScope::Full, &[id]);
assert_eq!(ops, Vec::new());
assert_eq!(mirror.tabs.len(), 1, "the daemon tab survives the wait");
}
#[test]
fn an_additive_diff_never_closes_tabs_the_window_has_not_seen() {
let ws = WorkspaceId::new();
let (a, b) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(a, leaf(1)), tab(b, leaf(2))],
Some(b),
SyncScope::Full,
&[],
);
let fresh = TabId::new();
let ops = diff(
ws,
&mut mirror,
&[tab(fresh, leaf(9))],
Some(fresh),
SyncScope::Additive,
&[],
);
assert_eq!(
ops,
vec![ControlRequest::TabCreate {
workspace: ws,
at: Some(2),
pane: seed(9),
tab: Some(fresh),
}],
"appended after the tabs it has not seen; nothing closed or moved"
);
assert_eq!(mirror.tabs.len(), 3);
}
#[test]
fn deltas_advance_the_mirror_exactly_as_the_writers_operations_did() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut watcher = WsMirror::default();
let tree_tab = TreeTab {
id,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
};
assert!(apply_to_mirror(
&mut watcher,
&LayoutDelta::TabCreated {
at: 0,
tab: tree_tab,
},
));
assert!(apply_to_mirror(
&mut watcher,
&LayoutDelta::ActiveTabChanged { tab: id },
));
assert!(apply_to_mirror(
&mut watcher,
&LayoutDelta::TabRestructured {
tab: TreeTab {
id,
name: None,
sidebar_group: None,
root: PaneNode::Split {
axis: TreeAxis::Vertical,
ratio: 0.5,
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
},
pane: None,
},
));
assert!(apply_to_mirror(
&mut watcher,
&LayoutDelta::RatioChanged {
tab: id,
path: Vec::new(),
ratio: 0.7,
},
));
let mut writer = WsMirror::default();
diff(
ws,
&mut writer,
&[tab(id, leaf(1))],
Some(id),
SyncScope::Full,
&[],
);
let final_state = vec![tab(id, split(TreeAxis::Vertical, 0.7, leaf(1), leaf(2)))];
diff(
ws,
&mut writer,
&final_state,
Some(id),
SyncScope::Full,
&[],
);
assert_eq!(watcher, writer);
}
#[test]
fn a_delta_about_a_tab_the_mirror_does_not_hold_reports_itself() {
let mut mirror = WsMirror::default();
assert!(
!apply_to_mirror(
&mut mirror,
&LayoutDelta::TabRenamed {
tab: TabId::new(),
name: Some("x".into()),
},
),
"an unappliable delta must say so, so the caller re-pulls"
);
assert!(!apply_to_mirror(
&mut mirror,
&LayoutDelta::TabClosed { tab: TabId::new() },
),);
}
#[test]
fn a_lowered_leaf_carries_its_pane_id_and_its_agent_whatever_live_says() {
use tty7_core::core::cli_agent::CLIAgent;
let tab_id = TabId::new();
let ws = tty7_core::core::machine::Workspace {
tabs: vec![TreeTab {
id: tab_id,
name: Some("build".into()),
sidebar_group: Some("/repo".into()),
root: PaneNode::Split {
axis: TreeAxis::Vertical,
ratio: 0.3,
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
}],
active_tab: Some(tab_id),
..Default::default()
};
let panes = vec![
PaneRecord {
id: 1,
cwd: Some("/work".into()),
live: true,
..PaneRecord::new(1)
},
PaneRecord {
id: 2,
cwd: Some("/work/api".into()),
live: false,
agent: Some(AgentFacts {
agent: CLIAgent::Claude,
session_id: Some("sid".into()),
launch_argv: Some(vec!["claude".into()]),
status: None,
}),
..PaneRecord::new(2)
},
];
let session = session_from_tree(&ws, &panes);
assert_eq!(session.tabs.len(), 1);
assert_eq!(session.active, 0);
let tab = &session.tabs[0];
assert_eq!(
tab.tree_id,
Some(tab_id),
"the daemon tab's identity rides along"
);
assert_eq!(tab.name.as_deref(), Some("build"));
let SessionPane::Split { ratio, a, b, .. } = &tab.pane else {
panic!("the split survives the lowering");
};
assert!((ratio - 0.3).abs() < 1e-6);
match &**a {
SessionPane::Leaf { pane_id, cwd, .. } => {
assert_eq!(*pane_id, Some(1), "a live pane re-attaches by its id");
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work")));
}
_ => panic!("leaf"),
}
match &**b {
SessionPane::Leaf {
pane_id,
cwd,
agent,
agent_session_id,
..
} => {
assert_eq!(
*pane_id,
Some(2),
"a pane the tree calls dead still goes down by its id: the flag is a \
cached observation from another process — reloaded as false on every \
server start — and attaching is what settles it. Believing the flag \
here spawned fresh shells over running sessions."
);
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api")));
assert_eq!(*agent, Some(CLIAgent::Claude));
assert_eq!(agent_session_id.as_deref(), Some("sid"));
}
_ => panic!("leaf"),
}
}
/// The regression behind "switching workspaces threw away every session".
///
/// Two servers had started against one config dir — one holding the control
/// socket with an empty pane registry, the other holding the panes — so
/// `MachineGet` answered with every `live` still `false`, the value
/// `load_machine` stamps on a cold read. Erasing the id on that made the
/// restore spawn a fresh shell over each running one, and nineteen live
/// agent sessions went out with it.
///
/// The id has to survive a `live: false`, because nothing here is entitled
/// to declare a pane dead. Attaching is.
#[test]
fn a_pane_the_tree_calls_dead_still_goes_down_by_its_id() {
let tab_id = TabId::new();
let ws = tty7_core::core::machine::Workspace {
tabs: vec![TreeTab {
id: tab_id,
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 7 },
}],
active_tab: Some(tab_id),
..Default::default()
};
let panes = vec![PaneRecord {
id: 7,
cwd: Some("/work".into()),
live: false,
..PaneRecord::new(7)
}];
match &session_from_tree(&ws, &panes).tabs[0].pane {
SessionPane::Leaf { pane_id, .. } => assert_eq!(
*pane_id,
Some(7),
"the attach decides whether pane 7 is still there; this must not pre-empt it"
),
_ => panic!("leaf"),
}
}
#[test]
fn a_dangling_active_tab_in_the_pulled_tree_falls_back_to_the_first() {
let ws = tty7_core::core::machine::Workspace {
tabs: vec![TreeTab {
id: TabId::new(),
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
}],
active_tab: Some(TabId::new()),
..Default::default()
};
assert_eq!(session_from_tree(&ws, &[]).active, 0);
}
#[test]
fn a_pane_id_reused_in_another_tab_is_never_read_as_a_replace() {
let ws = WorkspaceId::new();
let (a, b) = (TabId::new(), TabId::new());
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(a, leaf(1)), tab(b, leaf(2))],
Some(b),
SyncScope::Full,
&[],
);
let want = vec![tab(a, leaf(2)), tab(b, leaf(2))];
let ops = diff(ws, &mut mirror, &want, Some(b), SyncScope::Full, &[]);
assert!(
!ops.iter()
.any(|op| matches!(op, ControlRequest::PaneReplace { .. })),
"got {ops:?}"
);
}
}