diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index fb31eaff..6edad54c 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -58,6 +58,11 @@ pub enum Command { New { #[arg(value_name = "PATH")] path: Option, + #[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)")] @@ -386,6 +391,17 @@ pub enum TabCmd { #[arg(value_name = "INDEX")] index: u64, }, + + #[command(about = "Put a tab in a sidebar group, or take it out of one")] + Group { + #[arg(value_name = "@TAB")] + tab: String, + #[arg( + value_name = "GROUP", + help = "The group to join; omit to leave whatever group it is in" + )] + group: Option, + }, } #[derive(Debug, Subcommand)] @@ -495,11 +511,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, diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index b9b6aa02..f3561e8e 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -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 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) @@ -84,6 +85,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result tab_close(&tab, backend), Some(Command::Tab(TabCmd::Rename { tab, name })) => tab_rename(&tab, name, backend), Some(Command::Tab(TabCmd::Move { tab, index })) => tab_move(&tab, index, backend), + Some(Command::Tab(TabCmd::Group { tab, group })) => tab_group(&tab, group, backend), Some(Command::Pane(PaneCmd::Ls { ws, all })) => pane_ls(ws.as_deref(), all, backend), Some(Command::Pane(PaneCmd::Close { target })) => { pane_close(target.as_deref(), ctx, backend) @@ -165,7 +167,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 +330,7 @@ fn ws_detach(ws: &str, backend: &mut dyn Backend) -> Result { report("", json!({ "detached": id.to_string() })) } -fn new_workspace(path: Option, backend: &mut dyn Backend) -> Result { +fn new_workspace(path: Option, open: bool, backend: &mut dyn Backend) -> Result { let ws = match backend.control(ControlRequest::WorkspaceCreate { name: None, workspace: None, @@ -345,9 +350,22 @@ fn new_workspace(path: Option, backend: &mut dyn Backend) -> Result, 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> = 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 +541,23 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R let tabs: Vec = 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 }), ) } @@ -612,6 +645,24 @@ fn tab_move(tab: &str, index: u64, backend: &mut dyn Backend) -> Result report("", json!({ "tab": tab.to_string(), "to": index })) } +/// The GUI files tabs under headings in its sidebar, and that heading is a +/// field on the tab like any other. Until now only the GUI could write it, +/// so a tab the CLI made landed in the ungrouped pile with no way out. +fn tab_group(tab: &str, group: Option, backend: &mut dyn Backend) -> Result { + let addr = address::parse_tab(tab)?; + let machine = fetch_machine(backend)?; + let (workspace, tab) = resolve::tab(&machine, &addr)?; + let group = group + .map(|g| g.trim().to_string()) + .filter(|g| !g.is_empty()); + backend.control(ControlRequest::TabSetGroup { + workspace, + tab, + group: group.clone(), + })?; + report("", json!({ "tab": tab.to_string(), "group": group })) +} + fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Result { if all { return pane_ls_all(backend); @@ -1459,6 +1510,29 @@ mod tests { to: 0, } ); + + backend.control_calls.clear(); + run_cli(&["tty7", "tab", "group", "@1", " scm "], &ctx, &mut backend); + assert_eq!( + backend.control_calls[1], + ControlRequest::TabSetGroup { + workspace: api.id, + tab: api.tabs[0].id, + group: Some("scm".into()), + } + ); + + backend.control_calls.clear(); + run_cli(&["tty7", "tab", "group", "@1"], &ctx, &mut backend); + assert_eq!( + backend.control_calls[1], + ControlRequest::TabSetGroup { + workspace: api.id, + tab: api.tabs[0].id, + group: None, + }, + "no group named means leave the group" + ); } #[test] @@ -1963,7 +2037,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 { diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index f7b6f680..3de4fc97 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -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 { diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index ae00fae6..db0ca399 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -70,10 +70,17 @@ pub struct Machine { pub panes: Vec, } -#[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, #[serde(default)] pub active_tab: Option, - #[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, } @@ -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(); diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 61bf38c8..07a649d3 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -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; diff --git a/crates/tty7-core/src/core/tab_view.rs b/crates/tty7-core/src/core/tab_view.rs new file mode 100644 index 00000000..f56f1fea --- /dev/null +++ b/crates/tty7-core/src/core/tab_view.rs @@ -0,0 +1,189 @@ +//! 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 serde::{Deserialize, Serialize}; + +use crate::core::cli_agent::{AgentStatus, CLIAgent}; +use crate::core::machine::{PaneRecord, TabId, Workspace}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TabView { + pub id: TabId, + pub name: Option, + /// 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, + pub agent: Option, + pub status: Option, + 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 { + 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"); + } +} diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 447406d6..ac758330 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -177,6 +177,13 @@ pub enum ControlRequest { GuiOpen { path: Option, + /// 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, }, MachineGet, @@ -497,6 +504,8 @@ pub enum ControlEvent { }, GuiOpen { path: Option, + #[serde(default)] + workspace: Option, }, 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)), diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 872e2929..4cb0b1e2 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -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) -> bool { + fn open_gui(&self, path: Option, workspace: Option) -> 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" + ); } } diff --git a/crates/tty7-server/tests/machine_tree.rs b/crates/tty7-server/tests/machine_tree.rs index 104179df..9e1d5a72 100644 --- a/crates/tty7-server/tests/machine_tree.rs +++ b/crates/tty7-server/tests/machine_tree.rs @@ -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")); diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index 576c40cb..d68cdfd3 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -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,11 +200,19 @@ resolve it immediately before use. A full tab UUID also works: `@`. | 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"}` | +| `tab group @TAB [GROUP]` | file it under a sidebar heading, or with no GROUP take it out of one | `{"tab","group"}` | + +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 diff --git a/src/core/session.rs b/src/core/session.rs index ea3a611f..55ce4c1b 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -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 { diff --git a/src/main.rs b/src/main.rs index b298ddce..df8e6e5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 }) diff --git a/src/ui/app.rs b/src/ui/app.rs index 81e0e5c8..2b2de289 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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 { diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 74f4fb59..6af552fd 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -297,49 +297,66 @@ pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option { /// 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, - pub title: String, - pub cwd: Option, - pub agent: Option, - pub status: Option, - 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, Option)> { - 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 { - 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, + 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 { + 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(); diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index c1479f07..a4857c95 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -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:?}"), diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 3215ee94..9852aee7 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -478,6 +478,36 @@ 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()) { + let app: &App = cx; + let rows: Vec = crate::ui::machine_mirror::unclaimed_local_workspaces(app) + .into_iter() + .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 +2154,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 { diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 1c3021a6..6a284f54 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -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::().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,10 @@ 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. + if was_dirty || machine_was_empty { app.update(cx, |app, cx| sync_window(app, cx)); } return; @@ -1435,6 +1439,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 +1491,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::() .windows @@ -1580,13 +1617,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 - } + // Handled before the window is ever reached — a deletion is about + // whether this workspace still exists here at all, which is not a + // question one window's tab list can answer. See + // `on_workspace_deleted`. + LayoutDelta::WorkspaceDeleted => true, LayoutDelta::ActiveTabChanged { tab } => { if let Some(index) = index_of(&self.tabs, *tab) { self.activate_from_delta(index, window, cx); diff --git a/src/ui/windows.rs b/src/ui/windows.rs index aa2f702b..aa702883 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -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) { // Only the GUI process knows which of its windows was focused most recently. // The daemon deliberately routes to a process, then leaves window selection