mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
pane_title_of() picked any non-empty PaneRecord.title, but an idle terminal's foreground process is just the shell itself (zsh, bash, ...). That made almost every idle-shell workspace show up as "zsh" in the sidebar instead of the far more useful cwd/repo-derived name, which defeats the "cwd as final fallback" intent of this change. Skip bare shell process names when picking a pane title, so the process-name fallback only kicks in for genuinely distinctive foreground processes (nvim, an agent, ...).
546 lines
18 KiB
Rust
546 lines
18 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::session::WorkspaceId;
|
|
|
|
#[derive(Default)]
|
|
pub struct MachineMirrors {
|
|
machines: HashMap<HostId, Machine>,
|
|
pulling: Vec<HostId>,
|
|
}
|
|
|
|
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,
|
|
};
|
|
let mirrors = cx.default_global::<Self>();
|
|
if mirrors.pulling.contains(&host) {
|
|
return;
|
|
}
|
|
mirrors.pulling.push(host);
|
|
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| {
|
|
let mirrors = cx.default_global::<Self>();
|
|
mirrors.pulling.retain(|h| *h != host);
|
|
if let Some(machine) = pulled {
|
|
mirrors.machines.insert(host, *machine);
|
|
cx.refresh_windows();
|
|
}
|
|
});
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
|
|
return;
|
|
};
|
|
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;
|
|
ws.active_tab = active;
|
|
}
|
|
|
|
pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) {
|
|
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
|
|
return;
|
|
};
|
|
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 = crate::ui::home::now_secs();
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
if let Some(title) = pane_title_of(ws, panes) {
|
|
return title.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(|| "Untitled".to_string())
|
|
}
|
|
|
|
fn pane_title_of<'a>(ws: &Workspace, panes: &'a [PaneRecord]) -> Option<&'a str> {
|
|
ws.tabs
|
|
.iter()
|
|
.flat_map(|t| t.root.pane_ids())
|
|
.filter_map(|id| panes.iter().find(|p| p.id == id))
|
|
.map(|p| p.title.trim())
|
|
.find(|title| !title.is_empty() && !tty7_core::core::shells::is_bare_shell_name(title))
|
|
}
|
|
|
|
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
|
|
let mut counts: Vec<(&str, usize)> = Vec::new();
|
|
for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) {
|
|
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(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)
|
|
}
|
|
|
|
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, 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"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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), "nvim");
|
|
|
|
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"nvim",
|
|
"a live process name is more distinctive than the cwd group"
|
|
);
|
|
|
|
panes[0].title = "zsh".into();
|
|
assert_eq!(
|
|
display_name_of(&ws, &panes),
|
|
"tty7",
|
|
"an idle shell prompt is not distinctive — cwd/repo name should win"
|
|
);
|
|
|
|
ws.name = Some(" Release prep ".into());
|
|
assert_eq!(display_name_of(&ws, &panes), "Release prep");
|
|
|
|
assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled");
|
|
}
|
|
}
|