mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction. - The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one. - for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them. - finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id. - A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart. - Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace. - tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column. - tty7 new --open raises a window on the workspace it just made.
This commit is contained in:
@@ -58,6 +58,11 @@ pub enum Command {
|
||||
New {
|
||||
#[arg(value_name = "PATH")]
|
||||
path: Option<String>,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Also open a window on it, if a GUI is running on this machine"
|
||||
)]
|
||||
open: bool,
|
||||
},
|
||||
|
||||
#[command(about = "Split a pane (= tty7 pane split)")]
|
||||
@@ -495,11 +500,11 @@ mod tests {
|
||||
assert!(matches!(parse(&["tty7", "ls"]).command, Some(Command::Ls)));
|
||||
assert!(matches!(
|
||||
parse(&["tty7", "new"]).command,
|
||||
Some(Command::New { path: None })
|
||||
Some(Command::New { path: None, .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(&["tty7", "new", "C:\\proj"]).command,
|
||||
Some(Command::New { path: Some(p) }) if p == "C:\\proj"
|
||||
Some(Command::New { path: Some(p), .. }) if p == "C:\\proj"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(&["tty7", "agents"]).command,
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
use tty7_core::core::agent_hooks::{HookAgent, HooksState};
|
||||
use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace};
|
||||
use tty7_core::core::session::WorkspaceId;
|
||||
use tty7_core::core::tab_view::tab_views_of;
|
||||
use tty7_core::daemon::control::{CONTROL_VERSION, ControlEvent, ControlRequest, ReplyOk};
|
||||
use tty7_core::daemon::protocol::PROTOCOL_VERSION;
|
||||
|
||||
@@ -71,7 +72,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
|
||||
ws_attach(address::parse_workspace(&ws), backend)
|
||||
}
|
||||
Some(Command::Ws(WsCmd::Detach { ws })) => ws_detach(&ws, backend),
|
||||
Some(Command::New { path }) => new_workspace(path, backend),
|
||||
Some(Command::New { path, open }) => new_workspace(path, open, backend),
|
||||
Some(Command::Run(args)) => run(args, ctx, backend),
|
||||
Some(Command::Split(args)) | Some(Command::Pane(PaneCmd::Split(args))) => {
|
||||
pane_split(args, ctx, backend)
|
||||
@@ -165,7 +166,10 @@ fn launch_gui(
|
||||
.then_some(None)
|
||||
.or_else(|| wire_path.clone().map(Some));
|
||||
let delivered = match request_path {
|
||||
Some(path) => match backend.control(ControlRequest::GuiOpen { path }) {
|
||||
Some(path) => match backend.control(ControlRequest::GuiOpen {
|
||||
path,
|
||||
workspace: None,
|
||||
}) {
|
||||
Ok(ReplyOk::Bool(delivered)) => delivered,
|
||||
Ok(other) => bail!("the server answered GuiOpen with {other:?}"),
|
||||
Err(_) => false,
|
||||
@@ -325,7 +329,7 @@ fn ws_detach(ws: &str, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
report("", json!({ "detached": id.to_string() }))
|
||||
}
|
||||
|
||||
fn new_workspace(path: Option<String>, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
fn new_workspace(path: Option<String>, open: bool, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
let ws = match backend.control(ControlRequest::WorkspaceCreate {
|
||||
name: None,
|
||||
workspace: None,
|
||||
@@ -345,9 +349,39 @@ fn new_workspace(path: Option<String>, backend: &mut dyn Backend) -> Result<Outc
|
||||
},
|
||||
tab: None,
|
||||
})?;
|
||||
// Only when asked: a workspace made from a script has no business
|
||||
// stealing the screen, and the switcher lists it either way.
|
||||
let opened = match open {
|
||||
false => false,
|
||||
true => match backend.control(ControlRequest::GuiOpen {
|
||||
path: None,
|
||||
workspace: Some(ws.id),
|
||||
}) {
|
||||
Ok(ReplyOk::Bool(opened)) => {
|
||||
// The workspace exists by the time we ask, so an unreachable
|
||||
// GUI is worth a word and not an exit code: failing here would
|
||||
// read as "nothing was made".
|
||||
if !opened {
|
||||
eprintln!(
|
||||
"tty7: no GUI is running on this machine; \
|
||||
the workspace was made all the same"
|
||||
);
|
||||
}
|
||||
opened
|
||||
}
|
||||
Ok(other) => bail!("the server answered GuiOpen with {other:?}"),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"tty7: could not ask the GUI to open it ({error:#}); \
|
||||
the workspace was made all the same"
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
};
|
||||
report(
|
||||
ws.id.to_string(),
|
||||
json!({ "id": ws.id.to_string(), "pane": pane }),
|
||||
json!({ "id": ws.id.to_string(), "pane": pane, "opened": opened }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -500,13 +534,22 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
|
||||
.iter()
|
||||
.find(|ws| ws.id == id)
|
||||
.expect("resolve_ws returned an id straight out of this machine");
|
||||
let views = tab_views_of(ws, &machine.panes);
|
||||
let rows: Vec<Vec<String>> = ws
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
.zip(&views)
|
||||
.map(|(tab, view)| {
|
||||
vec![
|
||||
format!("@{}", resolve::ordinal_of(&machine, tab.id).unwrap_or(0)),
|
||||
tab.name.clone().unwrap_or_else(|| "-".to_string()),
|
||||
output::tab_label(view),
|
||||
// The GUI files tabs under a directory and shows its last
|
||||
// segment as the heading; the full path would be the widest
|
||||
// column in the table for no gain.
|
||||
tab.sidebar_group
|
||||
.as_deref()
|
||||
.map(|g| output::path_leaf(g).to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
tab.root.pane_ids().len().to_string(),
|
||||
]
|
||||
})
|
||||
@@ -514,17 +557,23 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
|
||||
let tabs: Vec<Value> = ws
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
.zip(&views)
|
||||
.map(|(tab, view)| {
|
||||
json!({
|
||||
"ordinal": resolve::ordinal_of(&machine, tab.id),
|
||||
"id": tab.id.to_string(),
|
||||
// `name` stays what someone actually named the tab — usually
|
||||
// nothing. `label` is what the table prints.
|
||||
"name": tab.name,
|
||||
"label": output::tab_label(view),
|
||||
"agent": view.agent.map(|a| a.display_name()),
|
||||
"group": tab.sidebar_group,
|
||||
"panes": tab.root.pane_ids(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
report(
|
||||
output::table(&["TAB", "NAME", "PANES"], &rows),
|
||||
output::table(&["TAB", "NAME", "GROUP", "PANES"], &rows),
|
||||
json!({ "workspace": id.to_string(), "tabs": tabs }),
|
||||
)
|
||||
}
|
||||
@@ -1461,6 +1510,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_ls_names_an_unnamed_tab_and_shows_the_leaf_of_its_group() {
|
||||
let mut backend = mock();
|
||||
backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into());
|
||||
|
||||
let out = run_cli(
|
||||
&["tty7", "tab", "ls", "api"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
);
|
||||
|
||||
// @1 was named; @2 was not, so it borrows the leaf of its cwd. The
|
||||
// GROUP column is the heading's last segment, not the whole path.
|
||||
assert_eq!(
|
||||
human(out),
|
||||
"TAB NAME GROUP PANES\n@1 build - 1\n@2 proj sub 2\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_ls_json_keeps_the_literal_name_beside_the_label() {
|
||||
let mut backend = mock();
|
||||
backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into());
|
||||
|
||||
let out = run_cli(
|
||||
&["tty7", "tab", "ls", "api"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
);
|
||||
|
||||
let Outcome::Report(report) = out else {
|
||||
panic!("tab ls must report");
|
||||
};
|
||||
let tabs = report.json["tabs"].as_array().expect("tabs").clone();
|
||||
assert_eq!(tabs[1]["name"], Value::Null, "nobody named this tab");
|
||||
assert_eq!(tabs[1]["label"], "proj", "the table's stand-in travels too");
|
||||
assert_eq!(
|
||||
tabs[1]["group"], "C:\\proj\\sub",
|
||||
"the JSON keeps the whole heading the table abbreviates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_close_hangs_up_every_pane_the_server_removed() {
|
||||
let mut backend = mock();
|
||||
@@ -1963,7 +2054,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
backend.control_calls,
|
||||
vec![ControlRequest::GuiOpen {
|
||||
path: Some(expected.clone())
|
||||
path: Some(expected.clone()),
|
||||
workspace: None,
|
||||
}]
|
||||
);
|
||||
let Outcome::Report(report) = out else {
|
||||
|
||||
@@ -2,11 +2,39 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use tty7_core::core::machine::{Machine, PaneNode, Workspace};
|
||||
use tty7_core::core::session::WorkspaceId;
|
||||
use tty7_core::core::tab_view::{TabLabel, TabView, tab_views_of};
|
||||
use tty7_core::daemon::control::{PaneAgentState, RouteInfo, ServerStatus};
|
||||
use tty7_core::daemon::protocol::{PaneInfo, PaneProcs};
|
||||
|
||||
use crate::resolve;
|
||||
|
||||
/// What to call a tab in a table or a tree. Almost no tab carries a name —
|
||||
/// the GUI's strip reads OSC titles the machine tree never sees — so a column
|
||||
/// printing `tab.name` alone comes out empty for a window full of work. The
|
||||
/// evidence ranking is shared with the GUI; only the rendering is ours.
|
||||
pub fn tab_label(view: &TabView) -> String {
|
||||
match view.label() {
|
||||
TabLabel::Named(name) => name.to_string(),
|
||||
TabLabel::Agent(agent) => agent.display_name().to_string(),
|
||||
// The tree prints every pane's full cwd right underneath, and a table
|
||||
// has no room for one anyway: the leaf is what tells tabs apart.
|
||||
TabLabel::Cwd(cwd) => path_leaf(cwd).to_string(),
|
||||
TabLabel::Process(title) => title.to_string(),
|
||||
TabLabel::Unknown => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The last segment of a path, for columns that have room for a word and not
|
||||
/// for a path. Both separators: the same server answers a Windows client, and
|
||||
/// `C:\proj` has to lose its head too.
|
||||
pub fn path_leaf(path: &str) -> &str {
|
||||
let trimmed = path.trim_end_matches(['/', '\\']);
|
||||
match trimmed.rsplit(['/', '\\']).next() {
|
||||
Some(leaf) if !leaf.is_empty() => leaf,
|
||||
_ => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Display columns, not bytes: a CJK path is two columns per char and three
|
||||
/// bytes, so padding by `len()` would push every later column out of line.
|
||||
fn width(s: &str) -> usize {
|
||||
@@ -138,11 +166,11 @@ pub fn workspace_tree(ws: &Workspace, machine: &Machine) -> String {
|
||||
ws.name.as_deref().unwrap_or("-"),
|
||||
resolve::short_id(&ws.id)
|
||||
);
|
||||
for tab in &ws.tabs {
|
||||
for (tab, view) in ws.tabs.iter().zip(tab_views_of(ws, &machine.panes)) {
|
||||
let ordinal = resolve::ordinal_of(machine, tab.id).unwrap_or(0);
|
||||
match &tab.name {
|
||||
Some(name) => out.push_str(&format!(" @{ordinal} {name}\n")),
|
||||
None => out.push_str(&format!(" @{ordinal}\n")),
|
||||
match view.label() {
|
||||
TabLabel::Unknown => out.push_str(&format!(" @{ordinal}\n")),
|
||||
_ => out.push_str(&format!(" @{ordinal} {}\n", tab_label(&view))),
|
||||
}
|
||||
render_node(&mut out, &tab.root, machine, 2);
|
||||
}
|
||||
@@ -347,13 +375,55 @@ mod tests {
|
||||
fn the_tree_shows_tabs_splits_and_cwds_by_indentation() {
|
||||
let m = two_workspace_machine();
|
||||
let rendered = workspace_tree(&m.workspaces[0], &m);
|
||||
// @1 was named; @2 was not, so it borrows the leaf of its cwd rather
|
||||
// than printing nothing at all.
|
||||
let expected = format!(
|
||||
"api ({})\n @1 build\n %1 C:\\proj\n @2\n h 50%\n %2 C:\\proj\n %3 C:\\proj\\sub\n",
|
||||
"api ({})\n @1 build\n %1 C:\\proj\n @2 proj\n h 50%\n %2 C:\\proj\n %3 C:\\proj\\sub\n",
|
||||
crate::resolve::short_id(&m.workspaces[0].id)
|
||||
);
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_tab_borrows_an_agent_then_a_place_then_its_process() {
|
||||
let view = |f: &dyn Fn(&mut TabView)| {
|
||||
let mut v = TabView {
|
||||
id: tty7_core::core::machine::TabId::new(),
|
||||
name: None,
|
||||
title: String::new(),
|
||||
cwd: None,
|
||||
agent: None,
|
||||
status: None,
|
||||
live: true,
|
||||
panes: 1,
|
||||
};
|
||||
f(&mut v);
|
||||
v
|
||||
};
|
||||
assert_eq!(
|
||||
tab_label(&view(&|v| v.name = Some("deploy".into()))),
|
||||
"deploy"
|
||||
);
|
||||
assert_eq!(
|
||||
tab_label(&view(
|
||||
&|v| v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude)
|
||||
)),
|
||||
"Claude Code"
|
||||
);
|
||||
assert_eq!(
|
||||
tab_label(&view(&|v| v.cwd = Some("/Users/me/repo/tty7".into()))),
|
||||
"tty7"
|
||||
);
|
||||
assert_eq!(
|
||||
tab_label(&view(&|v| v.cwd = Some("C:\\proj\\sub\\".into()))),
|
||||
"sub",
|
||||
"a Windows path loses its head and its trailing separator"
|
||||
);
|
||||
assert_eq!(tab_label(&view(&|v| v.cwd = Some("/".into()))), "/");
|
||||
assert_eq!(tab_label(&view(&|v| v.title = "zsh".into())), "zsh");
|
||||
assert_eq!(tab_label(&view(&|_| {})), "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn procs_render_as_a_process_tree_plus_ports() {
|
||||
let procs = PaneProcs {
|
||||
|
||||
@@ -70,10 +70,17 @@ pub struct Machine {
|
||||
pub panes: Vec<PaneRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
/// Proof that a connection is the one holding the workspace, so it stays
|
||||
/// between that connection and the server: it goes over no wire and onto
|
||||
/// no disk. A peer asking who holds a workspace gets the name and the
|
||||
/// time, never the means to pose as them.
|
||||
#[serde(skip)]
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub hostname: String,
|
||||
#[serde(default)]
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
@@ -99,7 +106,11 @@ pub struct Workspace {
|
||||
pub tabs: Vec<Tab>,
|
||||
#[serde(default)]
|
||||
pub active_tab: Option<TabId>,
|
||||
#[serde(skip)]
|
||||
/// Who is holding this workspace right now. Answered over the wire so a
|
||||
/// peer can see the workspace is spoken for, but stripped before the
|
||||
/// document is written: an attachment belongs to a live connection, and
|
||||
/// one read back at boot would name a holder that no longer exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<Attachment>,
|
||||
}
|
||||
|
||||
@@ -1061,7 +1072,11 @@ impl MachineStore {
|
||||
}
|
||||
|
||||
fn persist(&self, m: &Machine) -> io::Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(m).map_err(io::Error::other)?;
|
||||
let mut doc = m.clone();
|
||||
for ws in &mut doc.workspaces {
|
||||
ws.attachment = None;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&doc).map_err(io::Error::other)?;
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -1930,6 +1945,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_travels_by_name_and_never_by_token() {
|
||||
let (store, _dir, ws, _tab) = store_with_tab();
|
||||
store.attach(ws, Attachment::new("secret-token", "laptop"));
|
||||
|
||||
// What `MachineGet` hands a peer: `tty7 ls` reads its ATTACHED column
|
||||
// out of this, so a held workspace has to say so here.
|
||||
let wire = serde_json::to_string(&store.machine()).unwrap();
|
||||
assert!(wire.contains("laptop"), "{wire}");
|
||||
assert!(!wire.contains("secret-token"), "{wire}");
|
||||
|
||||
let seen: Machine = serde_json::from_str(&wire).unwrap();
|
||||
let held = seen.workspaces.iter().find(|w| w.id == ws).unwrap();
|
||||
assert_eq!(held.attachment.as_ref().unwrap().hostname, "laptop");
|
||||
assert!(held.attachment.as_ref().unwrap().token.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_dies_with_its_workspace() {
|
||||
let (store, _dir, ws, _tab) = store_with_tab();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod session;
|
||||
pub mod shells;
|
||||
#[allow(dead_code)]
|
||||
pub mod ssh_profile;
|
||||
pub mod tab_view;
|
||||
pub mod threads;
|
||||
pub mod window_state;
|
||||
pub mod worktree;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! What a tab looks like to someone who is not the window showing it.
|
||||
//!
|
||||
//! A window renders its own tabs from live terminals: OSC titles, agent
|
||||
//! chatter, unread counts. Everyone else — the switcher listing a workspace
|
||||
//! it does not own, `tty7 tab ls` on the other side of a socket — has only
|
||||
//! the machine tree. This is the reading of that tree, kept in one place so
|
||||
//! the CLI and the GUI name a tab the same way.
|
||||
|
||||
use crate::core::cli_agent::{AgentStatus, CLIAgent};
|
||||
use crate::core::machine::{PaneRecord, TabId, Workspace};
|
||||
|
||||
/// Deliberately not serialisable: it is a reading of the machine tree, and
|
||||
/// both sides that want one have the tree already. Putting it on the wire
|
||||
/// would be sending a conclusion where the evidence has already gone.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TabView {
|
||||
pub id: TabId,
|
||||
pub name: Option<String>,
|
||||
/// The foreground process of the tab's leading pane — "zsh", "vim". Not
|
||||
/// the OSC title: the tree never sees one.
|
||||
pub title: String,
|
||||
pub cwd: Option<String>,
|
||||
pub agent: Option<CLIAgent>,
|
||||
pub status: Option<AgentStatus>,
|
||||
pub live: bool,
|
||||
pub panes: usize,
|
||||
}
|
||||
|
||||
/// Where a tab's displayed name comes from, best evidence first. Callers
|
||||
/// render it themselves: a path is abbreviated one way in a 20-column tab
|
||||
/// strip and another way in a terminal table, and only the GUI has a
|
||||
/// translated string for a tab with nothing to say.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TabLabel<'a> {
|
||||
/// Someone named this tab, so nothing else gets a say.
|
||||
Named(&'a str),
|
||||
/// No name, but an agent is running in it — which is what anyone
|
||||
/// scanning a list of tabs is looking for.
|
||||
Agent(CLIAgent),
|
||||
/// The working directory of the tab's leading pane.
|
||||
Cwd(&'a str),
|
||||
/// The foreground process name. Thin, but it beats nothing.
|
||||
Process(&'a str),
|
||||
/// A tab holding a pane the tree knows nothing about.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TabView {
|
||||
pub fn label(&self) -> TabLabel<'_> {
|
||||
if let Some(name) = self
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
{
|
||||
return TabLabel::Named(name);
|
||||
}
|
||||
if let Some(agent) = self.agent {
|
||||
return TabLabel::Agent(agent);
|
||||
}
|
||||
if let Some(cwd) = self.cwd.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
|
||||
return TabLabel::Cwd(cwd);
|
||||
}
|
||||
match self.title.trim() {
|
||||
"" => TabLabel::Unknown,
|
||||
title => TabLabel::Process(title),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec<TabView> {
|
||||
ws.tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let ids = tab.root.pane_ids();
|
||||
let records: Vec<&PaneRecord> = ids
|
||||
.iter()
|
||||
.filter_map(|id| panes.iter().find(|p| p.id == *id))
|
||||
.collect();
|
||||
// The first pane stands in for the tab, the same way the strip shows
|
||||
// its focused leaf — but any pane running an agent wins, since that
|
||||
// is what someone scanning the list is looking for.
|
||||
let head = records.first();
|
||||
let facts = records.iter().find_map(|p| p.agent.as_ref());
|
||||
TabView {
|
||||
id: tab.id,
|
||||
name: tab.name.clone(),
|
||||
title: head.map(|p| p.title.clone()).unwrap_or_default(),
|
||||
cwd: head.and_then(|p| p.cwd.clone()),
|
||||
agent: facts.map(|f| f.agent),
|
||||
status: facts.and_then(|f| f.status),
|
||||
live: records.iter().any(|p| p.live),
|
||||
panes: ids.len(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::machine::{AgentFacts, Tab};
|
||||
|
||||
fn view() -> TabView {
|
||||
TabView {
|
||||
id: TabId::new(),
|
||||
name: None,
|
||||
title: String::new(),
|
||||
cwd: None,
|
||||
agent: None,
|
||||
status: None,
|
||||
live: true,
|
||||
panes: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_prefers_the_name_then_the_agent_then_the_place() {
|
||||
let named = TabView {
|
||||
name: Some(" deploy ".into()),
|
||||
agent: Some(CLIAgent::Claude),
|
||||
cwd: Some("/work".into()),
|
||||
..view()
|
||||
};
|
||||
assert_eq!(named.label(), TabLabel::Named("deploy"));
|
||||
|
||||
let working = TabView {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
cwd: Some("/work".into()),
|
||||
..view()
|
||||
};
|
||||
assert_eq!(working.label(), TabLabel::Agent(CLIAgent::Claude));
|
||||
|
||||
let plain = TabView {
|
||||
cwd: Some("/work".into()),
|
||||
title: "zsh".into(),
|
||||
..view()
|
||||
};
|
||||
assert_eq!(plain.label(), TabLabel::Cwd("/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_name_is_no_name_and_a_bare_shell_falls_back_to_its_process() {
|
||||
let blank = TabView {
|
||||
name: Some(" ".into()),
|
||||
title: "zsh".into(),
|
||||
..view()
|
||||
};
|
||||
assert_eq!(blank.label(), TabLabel::Process("zsh"));
|
||||
assert_eq!(view().label(), TabLabel::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_is_read_through_its_leading_pane_but_any_agent_in_it_wins() {
|
||||
let mut ws = Workspace::default();
|
||||
let mut tab = Tab::leaf(1);
|
||||
tab.root = crate::core::machine::PaneNode::Split {
|
||||
axis: crate::core::machine::Axis::Horizontal,
|
||||
ratio: 0.5,
|
||||
a: Box::new(crate::core::machine::PaneNode::Leaf { pane: 1 }),
|
||||
b: Box::new(crate::core::machine::PaneNode::Leaf { pane: 2 }),
|
||||
};
|
||||
ws.tabs.push(tab);
|
||||
|
||||
let panes = vec![
|
||||
PaneRecord {
|
||||
cwd: Some("/work".into()),
|
||||
title: "zsh".into(),
|
||||
live: true,
|
||||
..PaneRecord::new(1)
|
||||
},
|
||||
PaneRecord {
|
||||
agent: Some(AgentFacts {
|
||||
agent: CLIAgent::Claude,
|
||||
session_id: None,
|
||||
launch_argv: None,
|
||||
status: None,
|
||||
}),
|
||||
..PaneRecord::new(2)
|
||||
},
|
||||
];
|
||||
|
||||
let views = tab_views_of(&ws, &panes);
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].cwd.as_deref(), Some("/work"));
|
||||
assert_eq!(views[0].agent, Some(CLIAgent::Claude));
|
||||
assert_eq!(views[0].panes, 2);
|
||||
assert!(views[0].live, "one live pane makes the tab live");
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,13 @@ pub enum ControlRequest {
|
||||
|
||||
GuiOpen {
|
||||
path: Option<String>,
|
||||
/// A workspace that already exists on this machine, for the GUI to
|
||||
/// open a window onto. Without it the GUI picks its own — which is
|
||||
/// what `tty7 [PATH]` wants, and what a workspace the CLI just made
|
||||
/// does not: that one has an id, and any other window would be the
|
||||
/// wrong one.
|
||||
#[serde(default)]
|
||||
workspace: Option<WorkspaceId>,
|
||||
},
|
||||
|
||||
MachineGet,
|
||||
@@ -497,6 +504,8 @@ pub enum ControlEvent {
|
||||
},
|
||||
GuiOpen {
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
workspace: Option<WorkspaceId>,
|
||||
},
|
||||
Layout {
|
||||
workspace: String,
|
||||
@@ -1442,6 +1451,7 @@ mod tests {
|
||||
ControlRequest::WatchClose { id: 7 },
|
||||
ControlRequest::GuiOpen {
|
||||
path: Some("/home/me/proj".into()),
|
||||
workspace: None,
|
||||
},
|
||||
ControlRequest::AgentStates,
|
||||
ControlRequest::Routes,
|
||||
@@ -1569,6 +1579,7 @@ mod tests {
|
||||
},
|
||||
ControlEvent::GuiOpen {
|
||||
path: Some("/home/me/proj".into()),
|
||||
workspace: Some(WorkspaceId::new()),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -2165,7 +2176,13 @@ mod tests {
|
||||
},
|
||||
s(20),
|
||||
),
|
||||
(R::GuiOpen { path: None }, s(5)),
|
||||
(
|
||||
R::GuiOpen {
|
||||
path: None,
|
||||
workspace: None,
|
||||
},
|
||||
s(5),
|
||||
),
|
||||
(R::AgentStates, s(5)),
|
||||
(R::Routes, s(5)),
|
||||
(R::Status, s(5)),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::daemon::control::{
|
||||
CONTROL_VERSION, ControlClientMsg, ControlEvent, ControlHello, ControlHelloOk, ControlReply,
|
||||
ControlRequest, ControlServerMsg, GIT_STREAM_CHUNK, GIT_STREAM_CHUNK_MAX, LinkShutdown,
|
||||
MAX_CONCURRENT_GIT_STREAMS, PaneAgentState, ReplyOk, ServerStatus, WATCH_BURST_CAP, WireError,
|
||||
WireErrorKind, feature, server_started,
|
||||
WireErrorKind, WorkspaceId, feature, server_started,
|
||||
};
|
||||
use crate::daemon::duplex::{Duplex, Halves};
|
||||
use crate::daemon::protocol::PaneInfo;
|
||||
@@ -113,7 +113,7 @@ impl AttachRegistry {
|
||||
.retain(|gui| gui.conn != conn);
|
||||
}
|
||||
|
||||
fn open_gui(&self, path: Option<String>) -> bool {
|
||||
fn open_gui(&self, path: Option<String>, workspace: Option<WorkspaceId>) -> bool {
|
||||
// The newest GUI connection belongs to the most recently started app
|
||||
// process. Window recency is resolved inside that process, where GPUI
|
||||
// owns the authoritative focus state.
|
||||
@@ -127,7 +127,10 @@ impl AttachRegistry {
|
||||
let Some((conn, sink)) = target else {
|
||||
return false;
|
||||
};
|
||||
let event = ControlServerMsg::Event(ControlEvent::GuiOpen { path: path.clone() });
|
||||
let event = ControlServerMsg::Event(ControlEvent::GuiOpen {
|
||||
path: path.clone(),
|
||||
workspace,
|
||||
});
|
||||
if sink.send(&event).is_ok() {
|
||||
return true;
|
||||
}
|
||||
@@ -661,9 +664,10 @@ fn run_request(
|
||||
detach_workspace(conn, &id)?;
|
||||
(ReplyOk::Unit, Vec::new())
|
||||
}
|
||||
ControlRequest::GuiOpen { path } => {
|
||||
(ReplyOk::Bool(conn.attachments.open_gui(path)), Vec::new())
|
||||
}
|
||||
ControlRequest::GuiOpen { path, workspace } => (
|
||||
ReplyOk::Bool(conn.attachments.open_gui(path, workspace)),
|
||||
Vec::new(),
|
||||
),
|
||||
|
||||
ControlRequest::MachineGet => (
|
||||
ReplyOk::MachineTree(Box::new(machine_with_live_panes(conn)?)),
|
||||
@@ -1900,23 +1904,45 @@ mod gui_registry_tests {
|
||||
#[test]
|
||||
fn gui_open_is_delivered_only_while_a_gui_is_registered() {
|
||||
let registry = AttachRegistry::default();
|
||||
assert!(!registry.open_gui(Some("/work".into())));
|
||||
assert!(!registry.open_gui(Some("/work".into()), None));
|
||||
|
||||
let bytes = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::new(Sink::new(SharedWriter(Arc::clone(&bytes))));
|
||||
registry.register_gui(7, sink);
|
||||
assert!(registry.open_gui(Some("/work".into())));
|
||||
assert!(registry.open_gui(Some("/work".into()), None));
|
||||
|
||||
let frame = bytes.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
ControlServerMsg::read(&mut Cursor::new(frame)).unwrap(),
|
||||
ControlServerMsg::Event(ControlEvent::GuiOpen {
|
||||
path: Some("/work".into())
|
||||
path: Some("/work".into()),
|
||||
workspace: None,
|
||||
})
|
||||
);
|
||||
|
||||
registry.unregister_gui(7);
|
||||
assert!(!registry.open_gui(None));
|
||||
assert!(!registry.open_gui(None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_open_carries_the_workspace_the_caller_named() {
|
||||
let registry = AttachRegistry::default();
|
||||
let bytes = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::new(Sink::new(SharedWriter(Arc::clone(&bytes))));
|
||||
registry.register_gui(7, sink);
|
||||
|
||||
let made = WorkspaceId::new();
|
||||
assert!(registry.open_gui(None, Some(made)));
|
||||
|
||||
let frame = bytes.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
ControlServerMsg::read(&mut Cursor::new(frame)).unwrap(),
|
||||
ControlServerMsg::Event(ControlEvent::GuiOpen {
|
||||
path: None,
|
||||
workspace: Some(made),
|
||||
}),
|
||||
"the GUI has to be told which workspace, not left to guess a window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -397,6 +397,30 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
"the tree's own record says who holds the workspace"
|
||||
);
|
||||
|
||||
// And a peer asking for the tree is told the same. `tty7 ls` fills its
|
||||
// ATTACHED column from this answer, so an attachment that lived only in
|
||||
// the server's memory read to everyone else as "nobody is holding it".
|
||||
match desktop
|
||||
.control
|
||||
.call(ControlRequest::MachineGet)
|
||||
.expect("machine tree")
|
||||
{
|
||||
ReplyOk::MachineTree(m) => {
|
||||
let seen = m
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|w| w.id == ws.id)
|
||||
.expect("the shared workspace");
|
||||
let held = seen.attachment.as_ref().expect("held by the laptop");
|
||||
assert_eq!(held.hostname, "laptop");
|
||||
assert!(
|
||||
held.token.is_empty(),
|
||||
"the holder's token stays on the holder's connection"
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
|
||||
match attach(&desktop).expect("takeover") {
|
||||
ReplyOk::Attached { took_over_from } => {
|
||||
assert_eq!(took_over_from.as_deref(), Some("laptop"));
|
||||
|
||||
@@ -63,6 +63,10 @@ from a stand-in.
|
||||
Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`.
|
||||
JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`.
|
||||
|
||||
ATTACHED names the host holding the workspace — a GUI window, or another
|
||||
client — and is `-` when nobody is. It is the hostname only; the token that
|
||||
proves the hold never leaves the connection that owns it.
|
||||
|
||||
### `tty7 run [--keep] [--cwd DIR] [--ws WORKSPACE] -- CMD...`
|
||||
Spawns a pane running `CMD`, streams its output to stdout, waits, and exits
|
||||
with its code. The command must come after `--`; anything after `--` belongs to
|
||||
@@ -77,9 +81,13 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo.
|
||||
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
|
||||
output. The combined stream is not valid JSON; read the last line.
|
||||
|
||||
### `tty7 new [PATH]`
|
||||
### `tty7 new [PATH] [--open]`
|
||||
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints
|
||||
the workspace id. JSON: `{"id","pane"}`.
|
||||
the workspace id. JSON: `{"id","pane","opened"}`.
|
||||
|
||||
`--open` also puts a window on it, if a GUI is running on this machine — say
|
||||
so when you make a workspace for someone to look at. Without it the workspace
|
||||
is still listed in the GUI's switcher; it just waits there to be opened.
|
||||
|
||||
### `tty7 split [%PANE] (--v|--h) [--ratio R]`
|
||||
Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell
|
||||
@@ -192,12 +200,24 @@ resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `tab ls [WORKSPACE]` | tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","panes":[..]}]}` |
|
||||
| `tab ls [WORKSPACE]` | tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[..]}]}` |
|
||||
| `tab new [WORKSPACE] [--cwd DIR]` | add a tab with a fresh shell | `{"tab","pane"}` |
|
||||
| `tab close @TAB` | close the tab and every pane in it | `{"closed"}` |
|
||||
| `tab rename @TAB NAME` | name or rename | `{"tab","name"}` |
|
||||
| `tab move @TAB INDEX` | reposition within its workspace | `{"tab","to"}` |
|
||||
|
||||
GROUP is the heading the GUI's sidebar files the tab under, shown by its last
|
||||
segment (`group` in the JSON is the whole value). Read-only from here: with the
|
||||
default repo grouping the GUI recomputes it from the tab's working directory,
|
||||
so anything written from outside would be overwritten on the next render.
|
||||
|
||||
Almost no tab has a `name`: the GUI's tab strip reads OSC titles, which the
|
||||
machine tree never sees. So the NAME column — and `label` in the JSON — falls
|
||||
back through the best evidence there is: the name if someone set one, else the
|
||||
agent running in the tab ("Claude Code"), else the last segment of its cwd,
|
||||
else the foreground process. `name` in the JSON stays literal, so a script can
|
||||
still tell a real name from a stand-in.
|
||||
|
||||
## `pane` — panes
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|
||||
+45
-2
@@ -37,9 +37,21 @@ impl WorkspaceStore {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
return WorkspaceId::new();
|
||||
};
|
||||
let id = id.filter(|id| store.views.get(*id).is_some());
|
||||
let view = match id {
|
||||
Some(id) => store.views.get_mut(id).expect("filtered above"),
|
||||
// A named workspace keeps its name even when this client has never
|
||||
// opened it: the CLI and other clients make workspaces too, and the
|
||||
// id in the machine's tree is the one a window has to claim. Taking
|
||||
// a fresh id here would have opened an empty stranger instead.
|
||||
Some(id) => match store.views.views.iter().position(|w| w.id == id) {
|
||||
Some(at) => &mut store.views.views[at],
|
||||
None => {
|
||||
store.views.views.push(WindowView {
|
||||
id,
|
||||
..WindowView::default()
|
||||
});
|
||||
store.views.views.last_mut().expect("just pushed")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
store.views.views.push(WindowView::default());
|
||||
store.views.views.last_mut().expect("just pushed")
|
||||
@@ -228,6 +240,37 @@ mod tests {
|
||||
assert!(crosses_machines(b1, g));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn claiming_a_workspace_the_store_never_saw_keeps_the_id_it_was_given(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
// `claim` saves, and a test has no business writing the real views.
|
||||
let _ = tty7_core::core::config::set_config_dir(
|
||||
std::env::temp_dir().join(format!("tty7-session-test-{}", std::process::id())),
|
||||
);
|
||||
cx.update(|cx| {
|
||||
WorkspaceStore::install_for_test(cx, WindowViews::default());
|
||||
|
||||
// The id came off the machine tree — the CLI made this one.
|
||||
let on_the_machine = WorkspaceId::new();
|
||||
assert_eq!(
|
||||
WorkspaceStore::claim(cx, Some(on_the_machine)),
|
||||
on_the_machine,
|
||||
"a fresh id here would have opened an empty stranger instead"
|
||||
);
|
||||
assert_eq!(
|
||||
WorkspaceStore::claim(cx, Some(on_the_machine)),
|
||||
on_the_machine,
|
||||
"claiming it twice finds the entry rather than piling up"
|
||||
);
|
||||
assert_eq!(WorkspaceStore::all(cx).views.len(), 1);
|
||||
|
||||
let fresh = WorkspaceStore::claim(cx, None);
|
||||
assert_ne!(fresh, on_the_machine);
|
||||
assert_eq!(WorkspaceStore::all(cx).views.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_ids_group_by_machine_not_by_workspace() {
|
||||
let build = RemoteTarget::Alias {
|
||||
|
||||
+4
-1
@@ -230,7 +230,10 @@ fn forward_open_path(open_path: Option<&std::path::Path>) -> bool {
|
||||
"this computer",
|
||||
);
|
||||
let client = ControlClient::connect(&hello)?;
|
||||
let reply = client.request(ControlRequest::GuiOpen { path: Some(path) });
|
||||
let reply = client.request(ControlRequest::GuiOpen {
|
||||
path: Some(path),
|
||||
workspace: None,
|
||||
});
|
||||
client.close();
|
||||
reply
|
||||
})
|
||||
|
||||
+7
-1
@@ -469,7 +469,13 @@ impl Tty7App {
|
||||
let is_remote = WorkspaceStore::all(cx)
|
||||
.get(workspace)
|
||||
.is_some_and(|w| w.is_remote());
|
||||
let hydrate = known && (restore || is_remote);
|
||||
// Tabs that exist on the machine are shown whatever the restore
|
||||
// setting says: that setting decides whether a window comes back at
|
||||
// launch, not whether an open one shows what is really in it. The
|
||||
// `else` arm below saves this window's session, and saving an empty
|
||||
// one over a live tree would erase it.
|
||||
let on_machine = id.is_some_and(|id| crate::ui::machine_mirror::machine_holds_tabs(cx, id));
|
||||
let hydrate = on_machine || (known && (restore || is_remote));
|
||||
let session = hydrate.then(Session::default);
|
||||
let app = Self::with_session_at(Some(workspace), session, initial_cwd, window, cx);
|
||||
if hydrate {
|
||||
|
||||
+117
-36
@@ -297,49 +297,66 @@ pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> {
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TabView {
|
||||
pub id: TabId,
|
||||
pub name: Option<String>,
|
||||
pub title: String,
|
||||
pub cwd: Option<String>,
|
||||
pub agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
pub status: Option<crate::core::cli_agent::AgentStatus>,
|
||||
pub live: bool,
|
||||
pub panes: usize,
|
||||
}
|
||||
/// 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 entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?;
|
||||
let (ws, panes) = view_of(cx, entry)?;
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec<TabView> {
|
||||
ws.tabs
|
||||
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()
|
||||
.map(|tab| {
|
||||
let ids = tab.root.pane_ids();
|
||||
let records: Vec<&PaneRecord> = ids
|
||||
.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()
|
||||
.filter_map(|id| panes.iter().find(|p| p.id == *id))
|
||||
.collect();
|
||||
// The first pane stands in for the tab, the same way the strip shows
|
||||
// its focused leaf — but any pane running an agent wins, since that
|
||||
// is what someone scanning the list is looking for.
|
||||
let head = records.first();
|
||||
let facts = records.iter().find_map(|p| p.agent.as_ref());
|
||||
TabView {
|
||||
id: tab.id,
|
||||
name: tab.name.clone(),
|
||||
title: head.map(|p| p.title.clone()).unwrap_or_default(),
|
||||
cwd: head.and_then(|p| p.cwd.clone()),
|
||||
agent: facts.map(|f| f.agent),
|
||||
status: facts.and_then(|f| f.status),
|
||||
live: records.iter().any(|p| p.live),
|
||||
panes: ids.len(),
|
||||
}
|
||||
.flat_map(|t| t.root.pane_ids())
|
||||
.filter_map(|id| machine.panes.iter().find(|p| p.id == id))
|
||||
.any(|p| p.live),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -430,6 +447,70 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -1047,7 +1047,11 @@ pub(crate) fn drain_events(cx: &mut gpui::App) {
|
||||
crate::ui::tree_sync::resync_window_from_tree(cx, workspace);
|
||||
}
|
||||
}
|
||||
ControlEvent::GuiOpen { path } if host.is_local() => {
|
||||
ControlEvent::GuiOpen { workspace, .. } if host.is_local() && workspace.is_some() => {
|
||||
let workspace = workspace.expect("guarded above");
|
||||
crate::ui::windows::open_named_workspace_from_cli(cx, workspace);
|
||||
}
|
||||
ControlEvent::GuiOpen { path, .. } if host.is_local() => {
|
||||
crate::ui::windows::open_from_cli(cx, path.map(std::path::PathBuf::from));
|
||||
}
|
||||
other => log::debug!("unhandled control event from {host:?}: {other:?}"),
|
||||
|
||||
+61
-27
@@ -478,6 +478,45 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
// Workspaces this machine holds that the store has never heard of: the
|
||||
// CLI makes them too, and one that never appears here looks to the
|
||||
// person who ran `tty7 new` like nothing happened at all. They open
|
||||
// like any other row — the id in the tree is the id a window claims.
|
||||
if let Some(slot) = groups.iter().position(|g| g.key.is_empty()) {
|
||||
// Measured against the rows already listed rather than against the
|
||||
// store, which is what put them there: the block above lists this
|
||||
// window's own workspace before the store has caught up with it,
|
||||
// and two rows under one id would be two ways into one window.
|
||||
let listed: Vec<WorkspaceId> = groups
|
||||
.iter()
|
||||
.flat_map(|g| g.rows.iter().map(|r| r.id))
|
||||
.collect();
|
||||
let app: &App = cx;
|
||||
let rows: Vec<Row> = crate::ui::machine_mirror::unclaimed_local_workspaces(app)
|
||||
.into_iter()
|
||||
.filter(|ws| !listed.contains(&ws.id))
|
||||
.map(|ws| Row {
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
path: ws
|
||||
.path
|
||||
.map(|p| crate::ui::home::display_path(std::path::Path::new(&p)))
|
||||
.unwrap_or_default(),
|
||||
when: crate::ui::home::relative_time(now, ws.last_active),
|
||||
live: match ws.live {
|
||||
true => Liveness::Alive,
|
||||
false => Liveness::Stopped,
|
||||
},
|
||||
open: false,
|
||||
current: false,
|
||||
adopt: None,
|
||||
remote_id: None,
|
||||
tabs: self.tab_rows_for(ws.id, app),
|
||||
})
|
||||
.collect();
|
||||
groups[slot].rows.extend(rows);
|
||||
}
|
||||
|
||||
for group in &mut groups {
|
||||
group.rows.sort_by(|a, b| {
|
||||
b.current
|
||||
@@ -2124,34 +2163,29 @@ impl TabRow {
|
||||
/// neither: `PaneRecord::title` is the *foreground process name* ("zsh"), so
|
||||
/// the cwd and the agent stand in for it here.
|
||||
fn tab_view_label(view: &crate::ui::machine_mirror::TabView, index: usize) -> String {
|
||||
if let Some(name) = view
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
{
|
||||
return name.to_string();
|
||||
let unnamed = || {
|
||||
t_fmt(
|
||||
L10nKey::TabUnnamedShell,
|
||||
&[("n", &((index + 1).to_string()))],
|
||||
)
|
||||
};
|
||||
match view.label() {
|
||||
crate::ui::machine_mirror::TabLabel::Named(name) => name.to_string(),
|
||||
crate::ui::machine_mirror::TabLabel::Agent(agent) => agent.display_name().to_string(),
|
||||
// A cwd can shorten away to nothing (a bare "user@host:"), and the
|
||||
// process name is still worth more than a number.
|
||||
crate::ui::machine_mirror::TabLabel::Cwd(cwd) => {
|
||||
match crate::ui::tab_strip::short_title(cwd) {
|
||||
shortened if !shortened.trim().is_empty() => shortened,
|
||||
_ => match view.title.trim() {
|
||||
"" => unnamed(),
|
||||
title => title.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
crate::ui::machine_mirror::TabLabel::Process(title) => title.to_string(),
|
||||
crate::ui::machine_mirror::TabLabel::Unknown => unnamed(),
|
||||
}
|
||||
if let Some(agent) = view.agent {
|
||||
return agent.display_name().to_string();
|
||||
}
|
||||
let from_cwd = view
|
||||
.cwd
|
||||
.as_deref()
|
||||
.map(crate::ui::tab_strip::short_title)
|
||||
.unwrap_or_default();
|
||||
if !from_cwd.trim().is_empty() {
|
||||
return from_cwd;
|
||||
}
|
||||
// Last resort: the bare process name, which at least says something.
|
||||
let title = view.title.trim();
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
}
|
||||
t_fmt(
|
||||
L10nKey::TabUnnamedShell,
|
||||
&[("n", &((index + 1).to_string()))],
|
||||
)
|
||||
}
|
||||
|
||||
impl Group {
|
||||
|
||||
+115
-9
@@ -1365,12 +1365,13 @@ fn finish_hydration(
|
||||
};
|
||||
let host = WorkspaceStore::host_of(cx, client_ws);
|
||||
crate::ui::machine_mirror::MachineMirrors::install(cx, host, machine);
|
||||
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;
|
||||
};
|
||||
let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. });
|
||||
state.informed |= mirror.tabs.is_empty();
|
||||
state.informed |= machine_was_empty;
|
||||
state.sync = SyncPhase::Primed(mirror);
|
||||
// The machine answered, so the explanation has been overtaken by events
|
||||
// and a later outage deserves its own.
|
||||
@@ -1383,7 +1384,19 @@ fn finish_hydration(
|
||||
return;
|
||||
};
|
||||
if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() {
|
||||
if was_dirty {
|
||||
// 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;
|
||||
@@ -1435,6 +1448,34 @@ fn finish_hydration(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -1459,6 +1500,11 @@ pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: Layo
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(delta, LayoutDelta::WorkspaceDeleted) {
|
||||
on_workspace_deleted(cx, client_ws);
|
||||
return;
|
||||
}
|
||||
|
||||
let mirror_ok = match cx
|
||||
.default_global::<TreeSync>()
|
||||
.windows
|
||||
@@ -1494,6 +1540,10 @@ pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: Layo
|
||||
|
||||
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 { .. }
|
||||
@@ -1580,13 +1630,11 @@ impl Tty7App {
|
||||
| LayoutDelta::WorkspaceTouched { .. }
|
||||
| LayoutDelta::WorkspaceRenamed { .. }
|
||||
| LayoutDelta::PaneFacts { .. } => true,
|
||||
LayoutDelta::WorkspaceDeleted => {
|
||||
log::info!(
|
||||
"workspace {} was deleted on its machine; keeping the window",
|
||||
self.workspace
|
||||
);
|
||||
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);
|
||||
@@ -1877,6 +1925,64 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -236,6 +236,17 @@ pub fn open_at(
|
||||
refresh_menu(cx);
|
||||
}
|
||||
|
||||
/// A named workspace is the one that gets the window: the CLI made it, knows
|
||||
/// its id, and no other window would do. Everything else is `tty7 [PATH]`,
|
||||
/// where the CLI has no opinion and this process picks.
|
||||
pub fn open_named_workspace_from_cli(cx: &mut App, workspace: WorkspaceId) {
|
||||
cx.activate(true);
|
||||
open(cx, Some(workspace));
|
||||
if let Some(handle) = WindowRegistry::window_for(cx, workspace) {
|
||||
let _ = handle.update(cx, |_, window, _| window.activate_window());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_from_cli(cx: &mut App, path: Option<std::path::PathBuf>) {
|
||||
// Only the GUI process knows which of its windows was focused most recently.
|
||||
// The daemon deliberately routes to a process, then leaves window selection
|
||||
|
||||
Reference in New Issue
Block a user