mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-04 08:01:59 +00:00
`subject_path_of` picks the group most of a workspace's tabs are in and hands it back as a path. `display_name_of` then titles the window after its last component. A custom group is a name, not a path, so a workspace grouped by hand would have been titled `custom:work` — and one grouped as `work/urgent` would have been chopped to `urgent`. Only repo roots are counted now. A workspace grouped entirely by hand falls through to a pane's cwd, which is a real path and is what the window was titled after before any of this existed. Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
1077 lines
41 KiB
Rust
1077 lines
41 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use gpui::{App, Global};
|
|
use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace};
|
|
use tty7_core::daemon::control::{ControlRequest, ReplyOk};
|
|
use tty7_core::host::HostId;
|
|
|
|
use crate::core::group_key::GroupKey;
|
|
use crate::core::session::WorkspaceId;
|
|
use crate::ui::i18n::{L10nKey, t};
|
|
|
|
/// A write a window made to its own mirror, kept for as long as a pull that
|
|
/// was already on its way when it happened has still to land.
|
|
type MirrorWrite = Box<dyn Fn(&mut Machine)>;
|
|
|
|
#[derive(Default)]
|
|
pub struct MachineMirrors {
|
|
machines: HashMap<HostId, Machine>,
|
|
pulling: Vec<HostId>,
|
|
/// What this client wrote into a mirror while a pull of it was in flight.
|
|
///
|
|
/// The tree a `MachineGet` answers is authoritative for everything the
|
|
/// machine knew when it built it — and what it did not know is exactly the
|
|
/// ops a window has just pushed, since a client is left out of the deltas
|
|
/// its own ops raise. The write here is the only copy of those there is,
|
|
/// and a pull dispatched before it and installed after it took that copy
|
|
/// away: the seeded pane records and the pushed tabs went missing again,
|
|
/// by another route (#612, #604). Replayed on top of the tree that lands,
|
|
/// in the order they were made.
|
|
since_pull: HashMap<HostId, Vec<MirrorWrite>>,
|
|
}
|
|
|
|
impl Global for MachineMirrors {}
|
|
|
|
impl MachineMirrors {
|
|
pub fn machine(cx: &App, host: HostId) -> Option<&Machine> {
|
|
cx.try_global::<Self>()?.machines.get(&host)
|
|
}
|
|
|
|
pub fn ready(cx: &App, host: HostId) -> bool {
|
|
Self::machine(cx, host).is_some()
|
|
}
|
|
|
|
pub fn refresh(cx: &mut App, host: HostId) {
|
|
let client = match crate::ui::tree_sync::tree_control_for(cx, host) {
|
|
crate::ui::tree_sync::TreeLink::Ready(client) => client,
|
|
crate::ui::tree_sync::TreeLink::Unserved => {
|
|
log::debug!("not pulling {host:?}: its server does not serve the machine tree");
|
|
return;
|
|
}
|
|
crate::ui::tree_sync::TreeLink::Down => return,
|
|
};
|
|
if !Self::start_pull(cx, host) {
|
|
return;
|
|
}
|
|
cx.spawn(async move |cx| {
|
|
let pulled = cx
|
|
.background_executor()
|
|
.spawn(async move {
|
|
match client.call(ControlRequest::MachineGet) {
|
|
Ok(ReplyOk::MachineTree(machine)) => Some(machine),
|
|
Ok(other) => {
|
|
log::warn!("MachineGet answered {other:?}");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
log::debug!("could not pull the machine tree: {e}");
|
|
None
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
cx.update(|cx| Self::finish_pull(cx, host, pulled.map(|machine| *machine)));
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// Claims the one pull a host is allowed to have on its way, and starts
|
|
/// keeping what this client writes until it lands. `false` if a pull is
|
|
/// already running: its answer is as fresh as this one's would have been.
|
|
fn start_pull(cx: &mut App, host: HostId) -> bool {
|
|
let mirrors = cx.default_global::<Self>();
|
|
if mirrors.pulling.contains(&host) {
|
|
return false;
|
|
}
|
|
mirrors.pulling.push(host);
|
|
mirrors.since_pull.entry(host).or_default();
|
|
true
|
|
}
|
|
|
|
/// Installs a pulled tree, and replays over it what this client wrote
|
|
/// while it was on its way.
|
|
///
|
|
/// The tree wins on everything it speaks about: it is the machine's own
|
|
/// answer, and a mirror that kept its older values over it would be
|
|
/// resurrecting what a pane's death or another window's op had settled.
|
|
/// What it cannot speak about is an op that had not reached the machine
|
|
/// when it was built — the window's own, whose deltas never come back to
|
|
/// it. Those writes go on top: against a tree that turned out to hold
|
|
/// them anyway each one is a no-op, since the seeded records insert only
|
|
/// and the tabs are the tabs the window pushed.
|
|
fn finish_pull(cx: &mut App, host: HostId, pulled: Option<Machine>) {
|
|
let mirrors = cx.default_global::<Self>();
|
|
mirrors.pulling.retain(|h| *h != host);
|
|
let since = mirrors.since_pull.remove(&host).unwrap_or_default();
|
|
let Some(machine) = pulled else {
|
|
return;
|
|
};
|
|
mirrors.machines.insert(host, machine);
|
|
let machine = mirrors.machines.get_mut(&host).expect("just inserted");
|
|
for write in since {
|
|
write(machine);
|
|
}
|
|
cx.refresh_windows();
|
|
}
|
|
|
|
/// Writes into a mirror what a window knows about its own machine, and
|
|
/// keeps the write for a pull already on its way (see [`Self::finish_pull`]).
|
|
///
|
|
/// A mirror that is not here yet is no reason to drop the write: the pull
|
|
/// that will install it is the very one the write has to survive.
|
|
fn write(cx: &mut App, host: HostId, edit: impl Fn(&mut Machine) + 'static) {
|
|
let mirrors = cx.default_global::<Self>();
|
|
if let Some(machine) = mirrors.machines.get_mut(&host) {
|
|
edit(machine);
|
|
}
|
|
if let Some(since) = mirrors.since_pull.get_mut(&host) {
|
|
since.push(Box::new(edit));
|
|
}
|
|
}
|
|
|
|
pub fn install(cx: &mut App, host: HostId, machine: Machine) {
|
|
cx.default_global::<Self>().machines.insert(host, machine);
|
|
cx.refresh_windows();
|
|
}
|
|
|
|
pub fn apply_delta(cx: &mut App, host: HostId, key: &str, delta: &LayoutDelta) {
|
|
let Ok(id) = key.parse::<WorkspaceId>() else {
|
|
return;
|
|
};
|
|
let applied = match cx.default_global::<Self>().machines.get_mut(&host) {
|
|
Some(machine) => apply(machine, id, delta),
|
|
None => true,
|
|
};
|
|
if !applied {
|
|
log::debug!("machine mirror for {host:?} fell behind; re-pulling");
|
|
Self::refresh(cx, host);
|
|
}
|
|
}
|
|
|
|
pub fn note_synced_workspace(
|
|
cx: &mut App,
|
|
host: HostId,
|
|
machine_ws: WorkspaceId,
|
|
tabs: Vec<Tab>,
|
|
active: Option<TabId>,
|
|
) {
|
|
Self::write(cx, host, move |machine| {
|
|
let ws = match machine.workspaces.iter_mut().find(|w| w.id == machine_ws) {
|
|
Some(ws) => ws,
|
|
None => {
|
|
machine.workspaces.push(Workspace {
|
|
id: machine_ws,
|
|
..Workspace::default()
|
|
});
|
|
machine.workspaces.last_mut().expect("just pushed")
|
|
}
|
|
};
|
|
ws.tabs = tabs.clone();
|
|
ws.active_tab = active;
|
|
});
|
|
}
|
|
|
|
/// Records for the panes this window itself seeded into the machine.
|
|
///
|
|
/// A window is left out of the deltas its own ops raise, and `TabCreated`
|
|
/// carries no pane record for anyone anyway, so the record the machine
|
|
/// mints for a pane this window created reaches it in nothing it is sent.
|
|
/// `PaneFacts` only close that gap when a fact changes, and a pane spawned
|
|
/// into its cwd and left at the prompt never changes one — the mirror held
|
|
/// tabs full of panes it knew nothing about, the window's own workspace
|
|
/// answered no subject path, an unnamed one read "Untitled" (#612). The
|
|
/// window knows what it seeded, so it records that itself. Insert only: a
|
|
/// record already here came from the machine — a pull, a rider on
|
|
/// `TabRestructured`, `PaneFacts` — and outranks what a seed knows.
|
|
pub fn note_seeded_panes(cx: &mut App, host: HostId, records: Vec<PaneRecord>) {
|
|
Self::write(cx, host, move |machine| {
|
|
for record in &records {
|
|
if !machine.panes.iter().any(|p| p.id == record.id) {
|
|
machine.panes.push(record.clone());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The name the machine has for a workspace, from a pull that saw it.
|
|
///
|
|
/// A window is left out of the deltas its own ops raise, so the name it
|
|
/// proposed at creation comes back in nothing it is sent — this is where it
|
|
/// learns the name of the workspace it is showing, and without it the chip
|
|
/// read the directory until some later full pull produced the name and
|
|
/// looked like a rename (#604). `None` is an answer too: a workspace made
|
|
/// by `tty7 new` really has no name, and reads its directory on purpose.
|
|
pub fn note_workspace_name(
|
|
cx: &mut App,
|
|
host: HostId,
|
|
machine_ws: WorkspaceId,
|
|
name: Option<String>,
|
|
) {
|
|
Self::write(cx, host, move |machine| {
|
|
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == machine_ws) {
|
|
ws.name = name.clone();
|
|
}
|
|
});
|
|
cx.refresh_windows();
|
|
}
|
|
|
|
pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) {
|
|
let request = request.clone();
|
|
// Read now rather than inside the write: a replay of the touch is the
|
|
// same touch, and it should not creep forward to the moment a pull
|
|
// happened to land.
|
|
let touched = crate::ui::home::now_secs();
|
|
Self::write(cx, host, move |machine| match &request {
|
|
ControlRequest::WorkspaceRename { workspace, name } => {
|
|
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
|
|
ws.name = name.clone();
|
|
}
|
|
}
|
|
ControlRequest::WorkspaceTouch { workspace } => {
|
|
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
|
|
ws.last_active = touched;
|
|
}
|
|
}
|
|
ControlRequest::WorkspaceRemove { workspace } => {
|
|
machine.workspaces.retain(|w| w.id != *workspace);
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> bool {
|
|
match delta {
|
|
LayoutDelta::WorkspaceCreated { workspace: ws } => {
|
|
machine.workspaces.retain(|w| w.id != ws.id);
|
|
machine.workspaces.push(ws.clone());
|
|
return true;
|
|
}
|
|
LayoutDelta::WorkspaceDeleted => {
|
|
machine.workspaces.retain(|w| w.id != workspace);
|
|
return true;
|
|
}
|
|
LayoutDelta::PaneFacts { pane } => {
|
|
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
|
|
Some(record) => *record = pane.clone(),
|
|
None => machine.panes.push(pane.clone()),
|
|
}
|
|
return true;
|
|
}
|
|
_ => {}
|
|
}
|
|
let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == workspace) else {
|
|
return false;
|
|
};
|
|
match delta {
|
|
LayoutDelta::WorkspaceCreated { .. }
|
|
| LayoutDelta::WorkspaceDeleted
|
|
| LayoutDelta::PaneFacts { .. } => unreachable!("handled above"),
|
|
LayoutDelta::WorkspaceRenamed { name } => {
|
|
ws.name = name.clone();
|
|
true
|
|
}
|
|
LayoutDelta::WorkspaceTouched { last_active } => {
|
|
ws.last_active = *last_active;
|
|
true
|
|
}
|
|
LayoutDelta::ActiveTabChanged { tab } => {
|
|
ws.active_tab = Some(*tab);
|
|
true
|
|
}
|
|
LayoutDelta::TabCreated { at, tab } => {
|
|
ws.tabs.retain(|t| t.id != tab.id);
|
|
let at = (*at).min(ws.tabs.len());
|
|
ws.tabs.insert(at, tab.clone());
|
|
true
|
|
}
|
|
LayoutDelta::TabClosed { tab } => {
|
|
let before = ws.tabs.len();
|
|
ws.tabs.retain(|t| t.id != *tab);
|
|
if ws.tabs.is_empty() {
|
|
ws.active_tab = None;
|
|
}
|
|
ws.tabs.len() != before
|
|
}
|
|
LayoutDelta::TabRenamed { tab, name } => {
|
|
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
|
return false;
|
|
};
|
|
t.name = name.clone();
|
|
true
|
|
}
|
|
LayoutDelta::TabRegrouped { tab, group } => {
|
|
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
|
return false;
|
|
};
|
|
t.sidebar_group = group.clone();
|
|
true
|
|
}
|
|
LayoutDelta::TabMoved { tab, to } => {
|
|
let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else {
|
|
return false;
|
|
};
|
|
let moved = ws.tabs.remove(from);
|
|
ws.tabs.insert((*to).min(ws.tabs.len()), moved);
|
|
true
|
|
}
|
|
LayoutDelta::TabRestructured { tab, pane } => {
|
|
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == tab.id) else {
|
|
return false;
|
|
};
|
|
*t = tab.clone();
|
|
if let Some(pane) = pane {
|
|
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
|
|
Some(record) => *record = pane.clone(),
|
|
None => machine.panes.push(pane.clone()),
|
|
}
|
|
}
|
|
true
|
|
}
|
|
LayoutDelta::RatioChanged { tab, path, ratio } => {
|
|
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
|
return false;
|
|
};
|
|
match t.root.descend_mut(path) {
|
|
Some(tty7_core::core::machine::PaneNode::Split { ratio: r, .. }) => {
|
|
*r = *ratio;
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn view_of<'a>(
|
|
cx: &'a App,
|
|
entry: &crate::core::session::WindowView,
|
|
) -> Option<(&'a Workspace, &'a [PaneRecord])> {
|
|
let machine = MachineMirrors::machine(cx, entry.host_id())?;
|
|
let machine_ws = entry.host.as_ref().map(|r| r.workspace).unwrap_or(entry.id);
|
|
let ws = machine.workspaces.iter().find(|w| w.id == machine_ws)?;
|
|
Some((ws, &machine.panes))
|
|
}
|
|
|
|
pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
|
|
match view_of(cx, entry) {
|
|
Some((ws, panes)) => Some(display_name_of(ws, panes)),
|
|
None => entry.label.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
|
|
if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
|
|
return name.to_string();
|
|
}
|
|
subject_path_of(ws, panes)
|
|
.and_then(|path| {
|
|
std::path::Path::new(&path)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
})
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string())
|
|
}
|
|
|
|
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
|
|
let mut counts: Vec<(String, usize)> = Vec::new();
|
|
// Repo groups only. This answers with a *path*, and its callers treat it
|
|
// as one — `display_name_of` names the window after its last component.
|
|
// A custom group is a name the user typed, so putting one here would
|
|
// title a window `custom:work`, or chop `work/urgent` down to `urgent`.
|
|
// A workspace grouped entirely by hand falls through to a pane's cwd,
|
|
// which is a real path and is what the window showed before any of this.
|
|
for group in ws.tabs.iter().filter_map(|t| {
|
|
let key = GroupKey::decode(t.sidebar_group.as_deref()?)?;
|
|
Some(key.repo_root()?.to_string_lossy().into_owned())
|
|
}) {
|
|
match counts.iter_mut().find(|(g, _)| *g == group) {
|
|
Some((_, n)) => *n += 1,
|
|
None => counts.push((group, 1)),
|
|
}
|
|
}
|
|
let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g);
|
|
let first_cwd = ws
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|t| t.root.pane_ids())
|
|
.find_map(|id| {
|
|
panes
|
|
.iter()
|
|
.find(|p| p.id == id)
|
|
.and_then(|p| p.cwd.as_deref())
|
|
});
|
|
dominant.or_else(|| first_cwd.map(str::to_string))
|
|
}
|
|
|
|
pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> {
|
|
let entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?;
|
|
display_name(cx, entry)
|
|
}
|
|
|
|
/// One tab of some workspace, flattened down to what a list row needs. The
|
|
/// mirror is the only place that knows about workspaces this window does not
|
|
/// own, so the switcher's tab column reads them from here — and so does
|
|
/// `tty7 tab ls`, which is why the reading itself lives in the core.
|
|
pub use tty7_core::core::tab_view::{TabLabel, TabView, tab_views_of};
|
|
|
|
pub fn tab_views_for(cx: &App, client_ws: WorkspaceId) -> Option<(Vec<TabView>, Option<TabId>)> {
|
|
let (ws, panes) = match crate::core::session::WorkspaceStore::all(cx).get(client_ws) {
|
|
Some(entry) => view_of(cx, entry)?,
|
|
// Not in the store: a workspace some other client made, which this
|
|
// window has never opened. It can only be on this machine, and its
|
|
// tree id is the id we were handed.
|
|
None => local_view_of(cx, client_ws)?,
|
|
};
|
|
Some((tab_views_of(ws, panes), ws.active_tab))
|
|
}
|
|
|
|
fn local_view_of(cx: &App, id: WorkspaceId) -> Option<(&Workspace, &[PaneRecord])> {
|
|
let machine = MachineMirrors::machine(cx, HostId::LOCAL)?;
|
|
let ws = machine.workspaces.iter().find(|w| w.id == id)?;
|
|
Some((ws, &machine.panes))
|
|
}
|
|
|
|
/// Does this machine hold a workspace by this id, with tabs in it? A window
|
|
/// opening one has to pull those tabs in: starting empty and saving the empty
|
|
/// session back would erase them.
|
|
pub fn machine_holds_tabs(cx: &App, id: WorkspaceId) -> bool {
|
|
local_view_of(cx, id).is_some_and(|(ws, _)| !ws.tabs.is_empty())
|
|
}
|
|
|
|
/// A workspace this machine holds that the local store has never heard of.
|
|
pub struct UnclaimedWorkspace {
|
|
pub id: WorkspaceId,
|
|
pub name: String,
|
|
pub path: Option<String>,
|
|
pub last_active: u64,
|
|
pub live: bool,
|
|
}
|
|
|
|
/// Workspaces made by the CLI, or by another client — as real as any other,
|
|
/// the only thing they lack is a window here. The switcher lists them so that
|
|
/// `tty7 new` does not look like it did nothing.
|
|
pub fn unclaimed_local_workspaces(cx: &App) -> Vec<UnclaimedWorkspace> {
|
|
let Some(machine) = MachineMirrors::machine(cx, HostId::LOCAL) else {
|
|
return Vec::new();
|
|
};
|
|
let views = crate::core::session::WorkspaceStore::all(cx);
|
|
machine
|
|
.workspaces
|
|
.iter()
|
|
.filter(|ws| views.get(ws.id).is_none())
|
|
.map(|ws| UnclaimedWorkspace {
|
|
id: ws.id,
|
|
name: display_name_of(ws, &machine.panes),
|
|
path: subject_path_of(ws, &machine.panes),
|
|
last_active: ws.last_active,
|
|
live: ws
|
|
.tabs
|
|
.iter()
|
|
.flat_map(|t| t.root.pane_ids())
|
|
.filter_map(|id| machine.panes.iter().find(|p| p.id == id))
|
|
.any(|p| p.live),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
|
|
match view_of(cx, entry) {
|
|
Some((ws, panes)) => subject_path_of(ws, panes).or_else(|| entry.subject.clone()),
|
|
None => entry.subject.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn display_hint(
|
|
cx: &App,
|
|
entry: &crate::core::session::WindowView,
|
|
) -> Option<(String, Option<String>)> {
|
|
let (ws, panes) = view_of(cx, entry)?;
|
|
Some((display_name_of(ws, panes), subject_path_of(ws, panes)))
|
|
}
|
|
|
|
pub fn pane_ids(cx: &App, entry: &crate::core::session::WindowView) -> Option<Vec<u64>> {
|
|
let (ws, _) = match view_of(cx, entry) {
|
|
Some(view) => view,
|
|
None if MachineMirrors::ready(cx, entry.host_id()) => return Some(Vec::new()),
|
|
None => return None,
|
|
};
|
|
Some(ws.tabs.iter().flat_map(|t| t.root.pane_ids()).collect())
|
|
}
|
|
|
|
pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option<usize> {
|
|
pane_ids(cx, entry).map(|ids| ids.len())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use tty7_core::core::machine::{Axis, PaneNode, PaneSeed, Tab, TabId};
|
|
|
|
use super::*;
|
|
|
|
fn machine_with(ws: Workspace) -> Machine {
|
|
Machine {
|
|
workspaces: vec![ws],
|
|
panes: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn leaf_tab(pane: u64) -> Tab {
|
|
Tab::leaf(pane)
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn an_unpulled_machine_falls_back_to_the_stamped_label(cx: &mut gpui::TestAppContext) {
|
|
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
|
|
|
|
cx.update(|cx| {
|
|
let mut view = WindowView::default();
|
|
view.label = Some("api".into());
|
|
view.subject = Some("/repo/api".into());
|
|
let id = view.id;
|
|
let entry = view.clone();
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![view],
|
|
active: None,
|
|
},
|
|
);
|
|
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("api"));
|
|
assert_eq!(subject_path(cx, &entry).as_deref(), Some("/repo/api"));
|
|
assert!(
|
|
display_hint(cx, &entry).is_none(),
|
|
"and a machine that has not answered contributes no new hint"
|
|
);
|
|
|
|
let mut tree = Workspace {
|
|
id,
|
|
name: Some("web".into()),
|
|
..Workspace::default()
|
|
};
|
|
tree.tabs = vec![leaf_tab(1)];
|
|
MachineMirrors::install(cx, HostId::LOCAL, machine_with(tree));
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("web"));
|
|
assert_eq!(
|
|
display_hint(cx, &entry).map(|(label, _)| label).as_deref(),
|
|
Some("web"),
|
|
"which is what the next save stamps"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// #604: a window is left out of the deltas its own ops raise, so the name
|
|
/// the machine gave the workspace it just created reaches it only through
|
|
/// the answer to that create. Once it has, the full tree a rebuild pulls
|
|
/// says the same thing and nothing on screen moves; until it did, the chip
|
|
/// read the directory and the first full pull looked like a rename.
|
|
#[gpui::test]
|
|
fn a_named_workspace_reads_the_same_before_and_after_a_full_pull(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
|
|
|
|
cx.update(|cx| {
|
|
let entry = WindowView::default();
|
|
let id = entry.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![entry.clone()],
|
|
active: None,
|
|
},
|
|
);
|
|
|
|
// What the window knows the moment its create is answered: a
|
|
// workspace of its own, and the name the machine put on it.
|
|
MachineMirrors::install(cx, HostId::LOCAL, Machine::default());
|
|
MachineMirrors::note_synced_workspace(cx, HostId::LOCAL, id, vec![leaf_tab(1)], None);
|
|
MachineMirrors::note_workspace_name(
|
|
cx,
|
|
HostId::LOCAL,
|
|
id,
|
|
Some("keen-marten".to_string()),
|
|
);
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("keen-marten"));
|
|
|
|
// The rebuild, pulling the whole tree: the same answer.
|
|
let pulled = |name: Option<&str>| Machine {
|
|
workspaces: vec![Workspace {
|
|
id,
|
|
name: name.map(str::to_string),
|
|
tabs: vec![leaf_tab(1)],
|
|
..Workspace::default()
|
|
}],
|
|
panes: vec![PaneRecord {
|
|
cwd: Some("/work/verify-main".into()),
|
|
..PaneRecord::new(1)
|
|
}],
|
|
};
|
|
MachineMirrors::install(cx, HostId::LOCAL, pulled(Some("keen-marten")));
|
|
assert_eq!(
|
|
display_name(cx, &entry).as_deref(),
|
|
Some("keen-marten"),
|
|
"the rebuild has nothing new to say, so nothing renames itself"
|
|
);
|
|
|
|
// A name the user chose outranks the one they were given.
|
|
MachineMirrors::install(cx, HostId::LOCAL, pulled(Some("deploy")));
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("deploy"));
|
|
|
|
// And "the machine has no name for it" is an answer too — that is
|
|
// what `tty7 new` leaves behind, and it reads as its directory.
|
|
MachineMirrors::install(cx, HostId::LOCAL, pulled(None));
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("verify-main"));
|
|
MachineMirrors::note_workspace_name(cx, HostId::LOCAL, id, None);
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("verify-main"));
|
|
});
|
|
}
|
|
|
|
/// #612: the deltas that carry a pane's record — `TabRestructured`'s
|
|
/// rider, `PaneFacts` on a change; `TabCreated` carries none at all —
|
|
/// reach every window but the one whose op raised them. The window seeded
|
|
/// the pane, so it records what it seeded; the full tree a rebuild pulls
|
|
/// then says the same thing and nothing on screen moves. Until it did,
|
|
/// the window's own workspace answered no subject path: an unnamed one
|
|
/// read "Untitled", and saving geometry stamped a null subject over what
|
|
/// views.json remembered.
|
|
#[gpui::test]
|
|
fn a_seeded_pane_reads_the_same_before_and_after_a_full_pull(cx: &mut gpui::TestAppContext) {
|
|
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
|
|
|
|
cx.update(|cx| {
|
|
let entry = WindowView::default();
|
|
let id = entry.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![entry.clone()],
|
|
active: None,
|
|
},
|
|
);
|
|
|
|
// What the window knows the moment it pushes its first tab: the
|
|
// tab it made, and the seed it sent along.
|
|
MachineMirrors::install(cx, HostId::LOCAL, Machine::default());
|
|
MachineMirrors::note_synced_workspace(cx, HostId::LOCAL, id, vec![leaf_tab(1)], None);
|
|
let seeded = PaneSeed {
|
|
cwd: Some("/work/verify-main".into()),
|
|
..PaneSeed::bare(1)
|
|
}
|
|
.into_record(true);
|
|
MachineMirrors::note_seeded_panes(cx, HostId::LOCAL, vec![seeded]);
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("verify-main"));
|
|
assert_eq!(
|
|
subject_path(cx, &entry).as_deref(),
|
|
Some("/work/verify-main")
|
|
);
|
|
|
|
// The rebuild, pulling the whole tree: the same answer.
|
|
MachineMirrors::install(
|
|
cx,
|
|
HostId::LOCAL,
|
|
Machine {
|
|
workspaces: vec![Workspace {
|
|
id,
|
|
tabs: vec![leaf_tab(1)],
|
|
..Workspace::default()
|
|
}],
|
|
// The record the machine minted for the same seed, written
|
|
// out rather than the copy above reused: a pull that hands
|
|
// back the very value the window put in is only agreeing
|
|
// with itself.
|
|
panes: vec![PaneRecord {
|
|
cwd: Some("/work/verify-main".into()),
|
|
live: true,
|
|
..PaneRecord::new(1)
|
|
}],
|
|
},
|
|
);
|
|
assert_eq!(display_name(cx, &entry).as_deref(), Some("verify-main"));
|
|
assert_eq!(
|
|
subject_path(cx, &entry).as_deref(),
|
|
Some("/work/verify-main")
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_seed_fills_only_the_records_the_mirror_lacks(cx: &mut gpui::TestAppContext) {
|
|
cx.update(|cx| {
|
|
// Facts the machine broadcast before the sync write got here: a
|
|
// reported title, a deeper cwd. The seed's flat idea of the pane
|
|
// must not roll them back.
|
|
let facts = PaneRecord {
|
|
cwd: Some("/work/deeper".into()),
|
|
osc_title: Some("me@host:~/work/deeper".into()),
|
|
live: true,
|
|
..PaneRecord::new(1)
|
|
};
|
|
MachineMirrors::install(
|
|
cx,
|
|
HostId::LOCAL,
|
|
Machine {
|
|
workspaces: Vec::new(),
|
|
panes: vec![facts.clone()],
|
|
},
|
|
);
|
|
let known = PaneSeed {
|
|
cwd: Some("/work".into()),
|
|
..PaneSeed::bare(1)
|
|
}
|
|
.into_record(false);
|
|
let fresh = PaneSeed {
|
|
cwd: Some("/work/api".into()),
|
|
..PaneSeed::bare(2)
|
|
}
|
|
.into_record(true);
|
|
MachineMirrors::note_seeded_panes(cx, HostId::LOCAL, vec![known, fresh]);
|
|
let machine = MachineMirrors::machine(cx, HostId::LOCAL).expect("installed");
|
|
assert_eq!(machine.panes.len(), 2);
|
|
assert_eq!(
|
|
machine.panes[0], facts,
|
|
"what the machine said outranks the seed"
|
|
);
|
|
assert_eq!(machine.panes[1].cwd.as_deref(), Some("/work/api"));
|
|
assert!(machine.panes[1].live);
|
|
});
|
|
}
|
|
|
|
/// #612 by its other route: a `MachineGet` already on its way when the
|
|
/// window wrote down what it had just pushed was built before that push,
|
|
/// and landing it whole put the mirror back to a tree that has never heard
|
|
/// of the tab or the pane — the same workspace answering no subject path,
|
|
/// with no later op to write it down again. What the window pushed goes
|
|
/// back on top of the tree that lands; what that tree does say still
|
|
/// outranks it; and once it has landed the writes are spent.
|
|
#[gpui::test]
|
|
fn a_pull_already_on_its_way_lands_under_what_the_window_pushed(cx: &mut gpui::TestAppContext) {
|
|
cx.update(|cx| {
|
|
// Nothing pulled yet, which is where a window that opens with its
|
|
// machine's link coming up starts: the pull below is the one that
|
|
// will install this mirror at all.
|
|
let id = WorkspaceId::new();
|
|
assert!(MachineMirrors::start_pull(cx, HostId::LOCAL));
|
|
|
|
// What the window pushes while that pull is on its way: a tab of
|
|
// its own, and the seeds behind the panes in it.
|
|
let pushed = leaf_tab(5);
|
|
MachineMirrors::note_synced_workspace(
|
|
cx,
|
|
HostId::LOCAL,
|
|
id,
|
|
vec![pushed.clone()],
|
|
None,
|
|
);
|
|
let seeded = |pane, cwd: &str| {
|
|
PaneSeed {
|
|
cwd: Some(cwd.into()),
|
|
..PaneSeed::bare(pane)
|
|
}
|
|
.into_record(true)
|
|
};
|
|
MachineMirrors::note_seeded_panes(
|
|
cx,
|
|
HostId::LOCAL,
|
|
vec![seeded(5, "/work/verify-main"), seeded(6, "/work")],
|
|
);
|
|
|
|
// The tree the machine built before any of that: an older tab, and
|
|
// a pane 6 it has since watched move on. Pane 5 it has never seen.
|
|
MachineMirrors::finish_pull(
|
|
cx,
|
|
HostId::LOCAL,
|
|
Some(Machine {
|
|
workspaces: vec![Workspace {
|
|
id,
|
|
tabs: vec![leaf_tab(4)],
|
|
..Workspace::default()
|
|
}],
|
|
panes: vec![PaneRecord {
|
|
cwd: Some("/work/deeper".into()),
|
|
osc_title: Some("me@host:~/work/deeper".into()),
|
|
live: true,
|
|
..PaneRecord::new(6)
|
|
}],
|
|
}),
|
|
);
|
|
|
|
let machine = MachineMirrors::machine(cx, HostId::LOCAL).expect("the pull installed");
|
|
assert_eq!(
|
|
machine.workspaces[0].tabs,
|
|
vec![pushed],
|
|
"the tabs the window pushed are not rolled back by a tree older than them"
|
|
);
|
|
let cwd_of = |pane: u64| {
|
|
machine
|
|
.panes
|
|
.iter()
|
|
.find(|p| p.id == pane)
|
|
.and_then(|p| p.cwd.clone())
|
|
};
|
|
assert_eq!(
|
|
cwd_of(5).as_deref(),
|
|
Some("/work/verify-main"),
|
|
"and the record for the pane it seeded is still the only one there is"
|
|
);
|
|
assert_eq!(
|
|
cwd_of(6).as_deref(),
|
|
Some("/work/deeper"),
|
|
"while a pane the machine did speak about keeps what it said"
|
|
);
|
|
|
|
// Spent: the next pull is the machine's own word, with nothing of
|
|
// the window's replayed under it a second time.
|
|
assert!(MachineMirrors::start_pull(cx, HostId::LOCAL));
|
|
MachineMirrors::finish_pull(cx, HostId::LOCAL, Some(Machine::default()));
|
|
let machine = MachineMirrors::machine(cx, HostId::LOCAL).expect("installed");
|
|
assert!(
|
|
machine.workspaces.is_empty() && machine.panes.is_empty(),
|
|
"a tree that has dropped them says so: {machine:?}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
fn a_workspace_the_store_never_saw_is_still_listed_and_still_readable(
|
|
cx: &mut gpui::TestAppContext,
|
|
) {
|
|
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
|
|
|
|
cx.update(|cx| {
|
|
let mine = WindowView::default();
|
|
let known = mine.id;
|
|
WorkspaceStore::install_for_test(
|
|
cx,
|
|
WindowViews {
|
|
views: vec![mine],
|
|
active: None,
|
|
},
|
|
);
|
|
|
|
// What `tty7 new` leaves behind: on the machine, named, with a
|
|
// tab — and no window here has ever heard of it.
|
|
let theirs = Workspace {
|
|
name: Some("demo".into()),
|
|
tabs: vec![leaf_tab(7)],
|
|
..Workspace::default()
|
|
};
|
|
let cli_made = theirs.id;
|
|
MachineMirrors::install(
|
|
cx,
|
|
HostId::LOCAL,
|
|
Machine {
|
|
workspaces: vec![
|
|
Workspace {
|
|
id: known,
|
|
tabs: vec![leaf_tab(1)],
|
|
..Workspace::default()
|
|
},
|
|
theirs,
|
|
],
|
|
panes: vec![PaneRecord {
|
|
cwd: Some("/repo/demo".into()),
|
|
live: true,
|
|
..PaneRecord::new(7)
|
|
}],
|
|
},
|
|
);
|
|
|
|
let unclaimed = unclaimed_local_workspaces(cx);
|
|
assert_eq!(unclaimed.len(), 1, "the store's own workspace is not new");
|
|
assert_eq!(unclaimed[0].id, cli_made);
|
|
assert_eq!(unclaimed[0].name, "demo");
|
|
assert_eq!(unclaimed[0].path.as_deref(), Some("/repo/demo"));
|
|
assert!(unclaimed[0].live);
|
|
|
|
assert!(
|
|
machine_holds_tabs(cx, cli_made),
|
|
"opening it has to pull those tabs, not save an empty session over them"
|
|
);
|
|
let (tabs, _) = tab_views_for(cx, cli_made).expect("readable without a store entry");
|
|
assert_eq!(tabs.len(), 1);
|
|
assert_eq!(tabs[0].cwd.as_deref(), Some("/repo/demo"));
|
|
|
|
assert!(!machine_holds_tabs(cx, WorkspaceId::new()));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_workspace_created_delta_lands_whole_and_a_deleted_one_removes_it() {
|
|
let mut machine = Machine::default();
|
|
let ws = Workspace::default();
|
|
let id = ws.id;
|
|
assert!(apply(
|
|
&mut machine,
|
|
id,
|
|
&LayoutDelta::WorkspaceCreated { workspace: ws },
|
|
));
|
|
assert_eq!(machine.workspaces.len(), 1);
|
|
assert!(apply(&mut machine, id, &LayoutDelta::WorkspaceDeleted));
|
|
assert!(machine.workspaces.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn structural_deltas_advance_the_mirrored_tree() {
|
|
let ws = Workspace::default();
|
|
let id = ws.id;
|
|
let mut machine = machine_with(ws);
|
|
let tab = leaf_tab(1);
|
|
let tab_id = tab.id;
|
|
assert!(apply(
|
|
&mut machine,
|
|
id,
|
|
&LayoutDelta::TabCreated { at: 0, tab },
|
|
));
|
|
let restructured = Tab {
|
|
id: tab_id,
|
|
name: None,
|
|
sidebar_group: None,
|
|
root: PaneNode::Split {
|
|
axis: Axis::Vertical,
|
|
ratio: 0.5,
|
|
a: Box::new(PaneNode::Leaf { pane: 1 }),
|
|
b: Box::new(PaneNode::Leaf { pane: 2 }),
|
|
},
|
|
};
|
|
assert!(apply(
|
|
&mut machine,
|
|
id,
|
|
&LayoutDelta::TabRestructured {
|
|
tab: restructured,
|
|
pane: Some(PaneRecord::new(2)),
|
|
},
|
|
));
|
|
let ws = &machine.workspaces[0];
|
|
assert_eq!(ws.tabs[0].root.pane_ids(), vec![1, 2]);
|
|
assert_eq!(
|
|
machine.panes.len(),
|
|
1,
|
|
"the rider pane record is upserted into the registry"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tab_created_delta_that_straddled_a_pull_lands_once() {
|
|
let ws = Workspace::default();
|
|
let id = ws.id;
|
|
let mut machine = machine_with(ws);
|
|
let delta = LayoutDelta::TabCreated {
|
|
at: 0,
|
|
tab: leaf_tab(1),
|
|
};
|
|
assert!(apply(&mut machine, id, &delta));
|
|
assert!(apply(&mut machine, id, &delta));
|
|
assert_eq!(
|
|
machine.workspaces[0].tabs.len(),
|
|
1,
|
|
"the second application is the pull/delta overlap, not a second tab"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_delta_about_a_tab_the_mirror_never_saw_asks_for_a_repull() {
|
|
let ws = Workspace::default();
|
|
let id = ws.id;
|
|
let mut machine = machine_with(ws);
|
|
assert!(
|
|
!apply(
|
|
&mut machine,
|
|
id,
|
|
&LayoutDelta::TabRenamed {
|
|
tab: TabId::new(),
|
|
name: Some("x".into()),
|
|
},
|
|
),
|
|
"an unappliable delta must say so, so the caller re-pulls"
|
|
);
|
|
assert!(!apply(
|
|
&mut machine,
|
|
WorkspaceId::new(),
|
|
&LayoutDelta::WorkspaceRenamed { name: None },
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn pane_facts_upsert_the_registry_even_for_a_pane_born_elsewhere() {
|
|
let mut machine = Machine::default();
|
|
let mut record = PaneRecord::new(7);
|
|
record.cwd = Some("/work".into());
|
|
assert!(apply(
|
|
&mut machine,
|
|
WorkspaceId::new(),
|
|
&LayoutDelta::PaneFacts {
|
|
pane: record.clone(),
|
|
},
|
|
));
|
|
record.live = true;
|
|
assert!(apply(
|
|
&mut machine,
|
|
WorkspaceId::new(),
|
|
&LayoutDelta::PaneFacts { pane: record },
|
|
));
|
|
assert_eq!(machine.panes.len(), 1, "updated in place, not duplicated");
|
|
assert!(machine.panes[0].live);
|
|
}
|
|
|
|
#[test]
|
|
fn display_names_derive_from_the_tree_with_the_session_precedence() {
|
|
let mut ws = Workspace::default();
|
|
let mut panes = vec![PaneRecord {
|
|
cwd: Some("/home/me/scratch".into()),
|
|
..PaneRecord::new(1)
|
|
}];
|
|
ws.tabs = vec![leaf_tab(1)];
|
|
assert_eq!(display_name_of(&ws, &panes), "scratch");
|
|
|
|
panes[0].title = "nvim".into();
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"scratch",
|
|
"a pane's process title must not rename its workspace"
|
|
);
|
|
|
|
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"tty7",
|
|
"the repo group wins over the raw cwd"
|
|
);
|
|
|
|
ws.name = Some(" Release prep ".into());
|
|
assert_eq!(display_name_of(&ws, &panes), "Release prep");
|
|
|
|
assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled");
|
|
}
|
|
|
|
/// A custom group is a name, not a path, and this answers with a path —
|
|
/// its caller names the window after the last component. Left in, a
|
|
/// workspace grouped by hand would be titled `custom:work`, and one
|
|
/// grouped as `work/urgent` would be titled `urgent`.
|
|
#[test]
|
|
fn a_custom_group_is_not_a_subject_path() {
|
|
let mut ws = Workspace::default();
|
|
let panes = vec![PaneRecord {
|
|
cwd: Some("/home/me/scratch".into()),
|
|
..PaneRecord::new(1)
|
|
}];
|
|
ws.tabs = vec![leaf_tab(1)];
|
|
|
|
ws.tabs[0].sidebar_group = Some("custom:work/urgent".into());
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"scratch",
|
|
"the cwd answers instead, the way it did before groups existed"
|
|
);
|
|
|
|
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"tty7",
|
|
"and a repo group still outranks the cwd"
|
|
);
|
|
}
|
|
}
|