mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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:?}"),
|
||||
|
||||
+52
-27
@@ -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
@@ -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);
|
||||
|
||||
@@ -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