fix(workspace): make the CLI and the GUI agree on what exists

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, and consulted the machine tree only
for the ones it already knew about.

- The switcher listed only workspaces this client had opened, so anything
  the CLI made was missing from the GUI entirely — `tty7 new` looked like
  it had done nothing. The local group now merges what the machine holds,
  the way the remote groups already did, and `claim` keeps the id it was
  handed instead of quietly opening a fresh workspace beside it. Opening
  one hydrates from the tree whatever `restore_session` says: the setting
  decides whether a window comes back at launch, not whether an open one
  shows what is really in it, and the alternative was saving an empty
  session over live tabs.

- A workspace deleted from another client left its row behind here, opening
  onto nothing. It is now forgotten with it. A window still showing one
  keeps it — `ws rm` leaves the panes running — and puts the workspace back
  under the same id rather than carrying on writing to a tree that has no
  record of it.

- `tty7 ls` could never fill its ATTACHED column: `Workspace::attachment`
  was `serde(skip)`, which kept it off the disk as intended and off the
  wire as a side effect, so a workspace held by a GUI window read to every
  other client as unheld. It now travels, minus the token that proves the
  hold, and is stripped when the document is written instead.

- `tab ls` and `ws tree` printed `tab.name`, which almost no tab has: the
  GUI's strip names tabs from OSC titles the tree never sees. Both now fall
  back through agent, cwd leaf and process name — the same ranking the
  switcher uses, moved into the core so there is one of it — and `tab ls`
  gained a GROUP column.

- `sidebar_group` was readable from the CLI and writable only from the GUI;
  `tab group @TAB [GROUP]` closes that.

Also `tty7 new --open`, which asks a running GUI to put a window on the
workspace it just made: `GuiOpen` now carries a workspace id, since a
workspace with an id has no business being routed to whichever window was
focused last.
This commit is contained in:
l0ng-ai
2026-08-09 13:39:51 +08:00
parent b4e7add65d
commit ea0a80e84f
18 changed files with 784 additions and 110 deletions
+18 -2
View File
@@ -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)")]
@@ -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<String>,
},
}
#[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,
+84 -9
View File
@@ -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)
@@ -84,6 +85,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
Some(Command::Tab(TabCmd::Close { tab })) => 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<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 +350,22 @@ 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 = open
&& matches!(
backend.control(ControlRequest::GuiOpen {
path: None,
workspace: Some(ws.id),
}),
Ok(ReplyOk::Bool(true))
);
if open && !opened {
eprintln!("no GUI is running on this machine; the workspace was made all the same");
}
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 +518,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 +541,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 }),
)
}
@@ -612,6 +645,24 @@ fn tab_move(tab: &str, index: u64, backend: &mut dyn Backend) -> Result<Outcome>
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<String>, backend: &mut dyn Backend) -> Result<Outcome> {
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<Outcome> {
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 {
+75 -5
View File
@@ -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 {
+35 -3
View File
@@ -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();
+1
View File
@@ -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;
+189
View File
@@ -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<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");
}
}
+18 -1
View File
@@ -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)),
+36 -10
View File
@@ -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"
);
}
}
+24
View File
@@ -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"));
+19 -3
View File
@@ -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: `@<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"}` |
| `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
+45 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+5 -1
View File
@@ -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:?}"),
+52 -27
View File
@@ -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<Row> = 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 {
+44 -9
View File
@@ -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,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::<TreeSync>()
.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);
+11
View File
@@ -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