diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index af529fd5..c81d706d 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -124,14 +124,15 @@ impl Backend for RealBackend { } fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option) -> Result { + let workspace = workspace.to_string(); let session = self .pane_client()? .spawn( cwd.map(PathBuf::from), SESSION_SIZE, None, - Some("tty7-cli".into()), - Some(workspace.to_string()), + Some(workspace.clone()), + Some(workspace), ) .context("spawning a shell")?; let pane = session.pane_id(); @@ -230,14 +231,18 @@ impl Backend for RealBackend { args: args.to_vec(), args_are_tty7_defaults: false, }; + // A `run` with no workspace is nobody's pane, so it is left unowned + // rather than stamped: an owner names the workspace that may attach to + // it, and there is none until `--keep` files it into a tab. + let workspace = spec.workspace.map(|ws| ws.to_string()); let session = self .pane_client()? .spawn( spec.cwd.map(PathBuf::from), SESSION_SIZE, Some(shell), - Some("tty7-cli".into()), - spec.workspace.map(|ws| ws.to_string()), + workspace.clone(), + workspace, ) .with_context(|| format!("spawning `{program}`"))?; let pane = session.pane_id(); diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index 3de4fc97..069021fb 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -136,19 +136,32 @@ pub fn pane_table(machine: &Machine, only: Option) -> String { /// The server's registry rather than the machine tree, so orphans appear. `held` /// answers which workspace holds a pane, if any; a pane with no holder is shown /// as `-` under WS, which is the whole point of the listing. +/// +/// OWNER names the workspace allowed to attach to the pane, and for a pane in +/// its own workspace's tree that is the WS beside it — so the column only +/// speaks when the two disagree, which is the case worth reading: an orphan +/// still remembering where it belongs, or a pane the holder cannot attach to. pub fn registry_table(panes: &[PaneInfo], held: &dyn Fn(u64) -> Option) -> String { if panes.is_empty() { return "no panes\n".to_string(); } + let short = |id: &str| -> String { id.chars().take(8).collect() }; let rows: Vec> = panes .iter() .map(|info| { + let holder = held(info.pane_id); + let owner = match (&info.owner, &holder) { + (None, _) => "-".to_string(), + (Some(owner), Some(holder)) if owner == holder => "-".to_string(), + (Some(owner), _) => short(owner), + }; vec![ format!("%{}", info.pane_id), - held(info.pane_id) - .map(|ws| ws.chars().take(8).collect()) + holder + .as_deref() + .map(short) .unwrap_or_else(|| "-".to_string()), - info.owner.clone().unwrap_or_else(|| "-".to_string()), + owner, info.cwd .as_ref() .map(|p| p.display().to_string()) @@ -307,6 +320,55 @@ mod tests { ); } + #[test] + fn owner_speaks_only_when_it_disagrees_with_the_workspace_holding_the_pane() { + let held = "9fd8072f-465c-4016-9a81-8143bff1240c"; + let elsewhere = "76698a44-3f13-4961-8fed-90d0b3defff1"; + let pane = |id: u64, owner: Option<&str>| PaneInfo { + pane_id: id, + cwd: None, + title: "zsh".into(), + alive: true, + owner: owner.map(str::to_string), + }; + let rendered = registry_table( + &[ + pane(1, Some(held)), + pane(2, Some(elsewhere)), + pane(3, None), + pane(4, Some(elsewhere)), + ], + &|id| (id != 4).then(|| held.to_string()), + ); + + let owner_of = |pane: &str| -> String { + rendered + .lines() + .find(|line| line.starts_with(pane)) + .unwrap_or_else(|| panic!("{pane} is listed: {rendered}")) + .split_whitespace() + .nth(2) + .expect("PANE WS OWNER") + .to_string() + }; + assert_eq!( + owner_of("%1"), + "-", + "repeating the WS beside it says nothing" + ); + assert_eq!( + owner_of("%2"), + "76698a44", + "a holder that may not attach is the whole reason to look" + ); + assert_eq!(owner_of("%3"), "-", "nobody claims it"); + assert_eq!( + owner_of("%4"), + "76698a44", + "an orphan still remembers where it belongs" + ); + } + #[test] fn wide_characters_are_padded_by_display_width_not_byte_length() { // "项目" is 6 bytes but occupies 4 columns. Padding by len() would add diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index a17826f9..7283b928 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -38,6 +38,10 @@ fn main() { "tab_close_terminates_every_pane_in_the_tab", tab_close_terminates_every_pane_in_the_tab, ), + ( + "every_pane_the_cli_files_names_its_workspace_as_owner", + every_pane_the_cli_files_names_its_workspace_as_owner, + ), ( "run_streams_output_and_passes_the_exit_code", run_streams_output_and_passes_the_exit_code, @@ -430,6 +434,47 @@ fn tab_close_terminates_every_pane_in_the_tab(daemon: &Daemon) { } } +/// A pane's owner is the workspace allowed to attach to it, and the GUI +/// respawns over anything else — so every way the CLI makes a pane has to +/// stamp that id, not a name of its own. It used to write a literal +/// "tty7-cli", which left a CLI-built workspace rebuilt from scratch the +/// first time a window opened on it: fresh shells, the live ones orphaned. +fn every_pane_the_cli_files_names_its_workspace_as_owner(daemon: &Daemon) { + let created = daemon.run_json(&["new", &workdir()]); + let ws_id = created["id"] + .as_str() + .expect("new prints the workspace id") + .to_string(); + + let tab = daemon.run_json(&["tab", "new", &ws_id, "--cwd", &workdir()]); + let tabbed = tab["pane"].as_u64().expect("tab new prints the pane id"); + daemon.run_json(&["split", &format!("%{tabbed}"), "--horizontal"]); + + let listed = daemon.run_json(&["pane", "ls", "--all"]); + let panes = listed["panes"] + .as_array() + .expect("pane ls --all prints the daemon registry"); + let ours: Vec<&serde_json::Value> = panes + .iter() + .filter(|p| p["workspace"].as_str() == Some(ws_id.as_str())) + .collect(); + // `run --keep` files a pane the same way, but its command has to exit for + // the CLI to return, and the registry drops the pane with it — so the + // three that outlive their command are what can be read back here. + assert_eq!( + ours.len(), + 3, + "new, tab new and split each filed one pane: {listed}" + ); + for pane in ours { + assert_eq!( + pane["owner"].as_str(), + Some(ws_id.as_str()), + "a pane its workspace holds must name that workspace as owner: {pane}" + ); + } +} + fn run_streams_output_and_passes_the_exit_code(daemon: &Daemon) { let echo = one_shot("echo tty7_e2e_run_marker"); let mut args: Vec<&str> = vec!["run", "--"]; diff --git a/src/ui/app.rs b/src/ui/app.rs index 520e98b1..edc020c9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6359,7 +6359,15 @@ fn pane_attachable( None => false, Some(None) => true, Some(Some(recorded)) => { - let ours = *recorded == owner.to_string(); + // Only a workspace id is a claim. Anything else is a client + // stamping its own name — `tty7` before this release wrote a + // literal "tty7-cli" — and refusing on it strands every pane the + // CLI ever made, respawning over a live shell the tree just told + // us belongs here. + let Ok(recorded) = recorded.parse::() else { + return true; + }; + let ours = recorded == owner; if !ours { log::warn!( "restore: pane {id} is owned by workspace {recorded}, not {owner}; \ @@ -7170,6 +7178,7 @@ mod tests { (1, Some(ours.to_string())), (2, Some(theirs.to_string())), (3, None), + (5, Some("tty7-cli".to_string())), ] .into_iter() .collect(); @@ -7186,6 +7195,11 @@ mod tests { pane_attachable(Some(&alive), 3, ours), "an unowned pane is legacy" ); + assert!( + pane_attachable(Some(&alive), 5, ours), + "an owner that names no workspace is not a rival's claim: older CLIs \ + wrote their own name there, and respawning strands the live pane" + ); assert!( !pane_attachable(Some(&alive), 4, ours), "a dead id never attaches"