mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
* 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>
4982 lines
181 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|