diff --git a/CHANGELOG.md b/CHANGELOG.md index cef5e28e..0deb0bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shows through the whole workspace, and the settings panel stays opaque. macOS and Linux keep the existing blur toggle. +- **Panes come back showing what was on them after the background service dies + unexpectedly** — a crash, a `kill -9` or a reboot takes the shells with it + either way, but the screens no longer go with them. A capped tail of each + pane's output is kept at `/scrollback/*.bin` (0600 on unix, behind + the config directory's ACL on Windows; 256 KiB per pane, written at most + every 30s and only for panes whose output moved), and a pane that reopens on + a dead predecessor's id is handed it. A planned restart already carried the + live ptys across untouched; this covers the deaths nothing gets to prepare + for. There is no switch: the moment anyone learns they wanted this is the + moment a service has already died, so it is on for everyone. The bytes are + dropped as soon as nothing can ask for them — closing a pane deletes its + file at once, a restore consumes it, and a periodic pass collects the rest. + A pane's shell is recorded alongside, so a git bash pane no longer comes + back as PowerShell. + ### Fixed - **An SFTP upload no longer sits in the browser under its temporary name** — diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 797c5ec0..8daa3486 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -346,6 +346,7 @@ fn new_workspace(path: Option, open: bool, backend: &mut dyn Backend) -> cwd: path, ssh_spec: None, agent: None, + shell: None, }, tab: None, })?; @@ -418,6 +419,7 @@ fn run(args: RunArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Resu cwd, ssh_spec: None, agent: None, + shell: None, }, first: false, })?; @@ -595,6 +598,7 @@ fn tab_new( cwd, ssh_spec: None, agent: None, + shell: None, }, tab: None, })? { @@ -1397,6 +1401,7 @@ mod tests { cwd: Some("C:\\newproj".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, }, @@ -1635,6 +1640,7 @@ mod tests { cwd: Some("C:\\elsewhere".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, } @@ -1668,6 +1674,7 @@ mod tests { cwd: Some("C:\\proj".into()), ssh_spec: None, agent: None, + shell: None, }, first: false, }, @@ -1898,6 +1905,7 @@ mod tests { cwd: Some("C:\\proj".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, }, diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 71894e13..adb7d3c6 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -47,6 +47,7 @@ pub fn two_workspace_machine() -> Machine { title: String::new(), ssh_spec: None, agent: None, + shell: None, live: true, }; Machine { diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 50a40989..6f4f95ee 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -277,17 +277,6 @@ pub struct Config { pub agent_commands: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, - /// Keep a capped tail of each pane's output on disk, so a daemon that dies - /// without getting to hand off — a crash, a `kill -9`, a reboot — comes - /// back to panes that still show what was in them. - /// - /// Off by default, and the default is the interesting part. What the ring - /// holds is whatever the pane printed, which routinely includes secrets: - /// an echoed token, the output of `env`, an agent's transcript. In memory - /// they die with the daemon. Writing them down is the entire feature and - /// also its entire cost, so it is the user who decides to pay it. - #[serde(default)] - pub persist_scrollback: bool, /// Give each pane its own shell history instead of one file every pane /// appends to and reads back. /// @@ -556,7 +545,6 @@ impl Default for Config { command_frecency: HashMap::new(), agent_commands: HashMap::new(), restore_agent_sessions: true, - persist_scrollback: false, per_pane_history: false, } } diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index db0ca399..67c2526e 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::core::cli_agent::CLIAgent; use crate::core::session::WorkspaceId; -use crate::daemon::protocol::NativeSshSpec; +use crate::daemon::protocol::{NativeSshSpec, ShellSpec}; pub const MACHINE_FILE: &str = "machine.json"; @@ -276,6 +276,15 @@ pub struct PaneRecord { pub ssh_spec: Option>, #[serde(default)] pub agent: Option, + /// What the pane is actually running, resolved: the spawn's override if it + /// had one, otherwise the shell the config named at the time. + /// + /// Without it a pane rebuilt from this tree comes back on whatever the + /// default shell is now, so a daemon restart silently turns a bash pane + /// into a PowerShell one. The rest of the record describes where a pane is + /// and what is running in it; this is the part that says what it *is*. + #[serde(default)] + pub shell: Option, #[serde(default)] pub live: bool, } @@ -288,6 +297,7 @@ impl PaneRecord { title: String::new(), ssh_spec: None, agent: None, + shell: None, live: false, } } @@ -313,6 +323,11 @@ pub struct PaneSeed { pub ssh_spec: Option>, #[serde(default)] pub agent: Option, + /// See [`PaneRecord::shell`]. Carried here too so a pane that reaches the + /// tree as a seed — a split, a `tty7` CLI call — names its shell from the + /// start rather than only once the daemon has observed it. + #[serde(default)] + pub shell: Option, } impl PaneSeed { @@ -322,6 +337,7 @@ impl PaneSeed { cwd: None, ssh_spec: None, agent: None, + shell: None, } } @@ -332,6 +348,7 @@ impl PaneSeed { title: String::new(), ssh_spec: self.ssh_spec.map(|s| Box::new(s.without_secrets())), agent: self.agent, + shell: self.shell, live, } } @@ -1364,6 +1381,7 @@ mod tests { cwd: Some(cwd.to_string()), ssh_spec: None, agent: None, + shell: None, } } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 0118858c..3c0e5046 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -17,6 +17,12 @@ pub enum SessionPane { cwd: Option, #[serde(default)] pane_id: Option, + /// The shell this pane was running. Without it a pane that has to be + /// respawned — its daemon restarted, or this is a cold start — comes + /// back on whatever the default shell is, which is how a bash pane + /// turns into a PowerShell one. + #[serde(default)] + shell: Option, #[serde(default)] ssh_spec: Option>, #[serde(default)] diff --git a/crates/tty7-core/src/daemon/handoff.rs b/crates/tty7-core/src/daemon/handoff.rs index b608dd9f..e82db450 100644 --- a/crates/tty7-core/src/daemon/handoff.rs +++ b/crates/tty7-core/src/daemon/handoff.rs @@ -83,6 +83,8 @@ struct PaneRecord { integration_dir: Option, size: WinSize, cwd: Option, + #[serde(default)] + shell: Option, shell_active: bool, at_prompt: bool, last_exit: Option, @@ -227,6 +229,7 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result integration_dir: pane.integration_dir.clone(), size: pane.size, cwd: pane.cwd.clone(), + shell: pane.shell_spec.clone(), shell_active: pane.shell_active, at_prompt: pane.at_prompt, last_exit: pane.last_exit, @@ -358,6 +361,7 @@ pub fn adopt(fd: RawFd) -> Option { size: record.size, ring, cwd: record.cwd, + shell_spec: record.shell, shell_active: record.shell_active, at_prompt: record.at_prompt, last_exit: record.last_exit, @@ -400,6 +404,7 @@ mod tests { bytes: output.to_vec(), }], cwd: Some(PathBuf::from("/work")), + shell_spec: None, shell_active: true, at_prompt: true, last_exit: Some(0), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 2e37489d..e0d4b252 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -111,6 +111,10 @@ struct SpawnConfig { initial_cwd: Option, integration_dir: Option, remote: Option, + /// The shell this pane actually got, after the override and the config have + /// been resolved against each other. Recorded so the machine tree can name + /// it — see [`crate::core::machine::PaneRecord::shell`]. + shell: Option, } /// Why a configured shell cannot be run, in one sentence, before anything @@ -156,12 +160,21 @@ fn build_spawn_config( anyhow::bail!(problem); } let remote = wsl_remote_context(configured.as_ref()); + // Taken before the chosen shell is consumed by the command builder: what + // goes in the tree is what was resolved here, not the possibly-empty + // override the caller sent. + let shell = configured.as_ref().map(|c| ShellSpec { + program: c.program.clone(), + args: c.args.clone(), + args_are_tty7_defaults: c.args_are_tty7_defaults, + }); let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd, pane, workspace)?; Ok(SpawnConfig { cmd, initial_cwd, integration_dir, remote, + shell, }) } @@ -630,6 +643,9 @@ struct PaneState { observer_seq: u64, cwd: Option, shell: ShellState, + /// What this pane is running, for the machine tree to record. Distinct from + /// `shell` above, which is the shell-integration state. + shell_spec: Option, remote: Option, agent: Option, agent_argv: Option>, @@ -954,6 +970,11 @@ pub struct Carried { pub size: WinSize, pub ring: Vec, pub cwd: Option, + /// What the pane is running. Nothing on the other side of the exec can work + /// it out again — the command line belongs to a child this image never + /// spawned — so a handoff that dropped it would leave the tree naming no + /// shell for a pane that plainly has one. + pub shell_spec: Option, pub shell_active: bool, pub at_prompt: bool, pub last_exit: Option, @@ -1268,6 +1289,7 @@ impl DaemonPane { observer_seq: 0, cwd: spawn.initial_cwd, shell: ShellState::default(), + shell_spec: spawn.shell.clone(), remote: spawn.remote.clone(), agent: None, agent_session: None, @@ -1415,6 +1437,7 @@ impl DaemonPane { size: st.ring.tail_size(), ring: st.ring.snapshot(), cwd: st.cwd.clone(), + shell_spec: st.shell_spec.clone(), shell_active: st.shell.active, at_prompt: st.shell.at_prompt, last_exit: st.shell.last_exit_code, @@ -1477,6 +1500,7 @@ impl DaemonPane { observers: Vec::new(), observer_seq: 0, cwd: carried.cwd, + shell_spec: carried.shell_spec, shell: ShellState { active: carried.shell_active, at_prompt: carried.at_prompt, @@ -1526,6 +1550,9 @@ impl DaemonPane { subscriber_epoch: 0, observers: Vec::new(), observer_seq: 0, + // A native ssh pane is not running a shell of this machine's; what + // it is, `ssh_spec` already says. + shell_spec: None, cwd: None, shell: ShellState::default(), remote: Some(remote), @@ -1814,12 +1841,15 @@ impl DaemonPane { && let (Some(before), Some(after)) = (facts_before, facts_after) && facts_changed(&before, &after) { - let (cwd, agent) = after; + let (cwd, agent, shell) = after; crate::core::machine::observe_pane(pane, |p| { if cwd.is_some() { p.cwd = cwd; } p.agent = agent; + if shell.is_some() { + p.shell = shell; + } if alive { p.live = true; } @@ -2373,7 +2403,13 @@ fn agent_state_snapshot(st: &PaneState) -> Option (Option, Option) { +type ObservedFacts = ( + Option, + Option, + Option, +); + +fn observed_facts(st: &PaneState) -> ObservedFacts { let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); let agent = st.agent.map(|agent| crate::core::machine::AgentFacts { agent, @@ -2385,14 +2421,13 @@ fn observed_facts(st: &PaneState) -> (Option, Option, Option), - after: &(Option, Option), -) -> bool { - before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) +fn facts_changed(before: &ObservedFacts, after: &ObservedFacts) -> bool { + before.0 != after.0 + || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) + || before.2 != after.2 } fn agent_facts_changed( @@ -3923,6 +3958,7 @@ mod tests { subscriber_epoch: 0, observers: Vec::new(), observer_seq: 0, + shell_spec: None, cwd: None, shell: ShellState::default(), remote: None, @@ -3939,7 +3975,7 @@ mod tests { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; let mut st = test_state(true); - assert_eq!(observed_facts(&st), (None, None)); + assert_eq!(observed_facts(&st), (None, None, None)); st.cwd = Some(PathBuf::from("/work/api")); st.agent = Some(CLIAgent::Claude); @@ -3951,7 +3987,7 @@ mod tests { ..Default::default() }); - let (cwd, agent) = observed_facts(&st); + let (cwd, agent, _shell) = observed_facts(&st); assert_eq!(cwd.as_deref(), Some("/work/api")); let agent = agent.expect("an agent in the foreground is a fact"); assert_eq!(agent.agent, CLIAgent::Claude); @@ -3964,7 +4000,7 @@ mod tests { assert_eq!(agent.status, Some(AgentStatus::Working)); st.agent_session = None; - let (_, agent) = observed_facts(&st); + let (_, agent, _) = observed_facts(&st); assert_eq!( agent.unwrap().launch_argv.as_deref(), Some(&["claude".to_string()][..]) @@ -3998,6 +4034,7 @@ mod tests { launch_argv: Some(vec!["claude".to_string()]), status: None, }), + shell: None, }, None, None, diff --git a/crates/tty7-core/src/daemon/scrollback.rs b/crates/tty7-core/src/daemon/scrollback.rs index 3dbe114a..5c545b6d 100644 --- a/crates/tty7-core/src/daemon/scrollback.rs +++ b/crates/tty7-core/src/daemon/scrollback.rs @@ -22,10 +22,15 @@ //! screen or two, not the ring's whole 8 MiB. The value of scrollback decays //! steeply with distance from the bottom, and every byte here is a byte of //! someone's terminal sitting on disk. -//! - **It is off unless asked for.** These bytes include whatever was echoed -//! into the pane: tokens, `env` output, an agent's transcript. In memory -//! they die with the daemon. On disk they outlive it, which is the whole -//! point and also the whole risk, so the choice is the user's. +//! - **It is not asked for.** These bytes include whatever was echoed into the +//! pane: tokens, `env` output, an agent's transcript. In memory they die +//! with the daemon; on disk they outlive it, which is the whole point and +//! also the whole risk. It was a setting once, defaulting to off. That was +//! wrong about *when* the choice gets made: the moment anyone learns they +//! wanted this is the moment a daemon has already died, and by then the +//! switch could only be flipped for next time. A feature that exists to +//! survive an unscheduled event cannot be opt-in. So the cost is paid for +//! everyone, and the two bullets below are what keep it small. //! - **It is dropped as soon as it is meaningless.** A pane that is closed, or //! that no workspace refers to any more, has its file removed. Retention is //! by relevance, not by calendar: a snapshot of a pane nobody will reopen is @@ -143,11 +148,6 @@ fn take<'a>(cur: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { Some(head) } -/// Whether the user has asked for scrollback to outlive the daemon. -pub fn enabled() -> bool { - crate::core::config::Config::load().persist_scrollback -} - fn dir() -> Option { crate::core::config::config_path("scrollback") } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 2ff1dee8..e1450f02 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -159,44 +159,44 @@ fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet { let mut ids: std::collections::HashSet = registry.list().into_iter().map(|p| p.pane_id).collect(); if let Some(store) = crate::core::machine::observed_store() { + let machine = store.machine(); ids.extend( - store - .machine() + machine .workspaces .iter() .flat_map(|w| w.tabs.iter()) .flat_map(|t| t.root.pane_ids()), ); + // And the pane list, not only the panes some tab currently stands on. + // The two disagree while a window is between layouts — a pane whose + // tab has been taken down and not yet put back is still a pane the + // tree knows about — and the cost of being wrong is asymmetric: an + // extra file is swept a tick later, a missing one is somebody's + // terminal. + ids.extend(machine.panes.iter().map(|p| p.id)); } ids } -/// Keep each pane's stored screen roughly current. +/// Keep each pane's stored screen roughly current, and collect what no pane +/// can be asked about any more. /// /// Only panes whose ring has moved are written, so an idle machine does no IO /// at all, and the busy pane that most needs a fresh copy is the one that gets -/// it. Turning the setting off mid-run is honoured here too: the next tick -/// clears the directory rather than leaving terminal output on disk that the -/// user has just said they do not want stored. -fn spawn_scrollback_writer(registry: Arc) { +/// it. +/// +/// The two sweeps ride along here rather than at startup because this is where +/// the question they ask can be answered: by now the registry holds this +/// daemon's panes and the windows have had time to put their trees back. Both +/// take the same set, because a pane whose screen is still worth restoring is +/// exactly a pane whose commands are still worth carrying. +fn spawn_snapshot_keeper(registry: Arc) { let spawned = std::thread::Builder::new() .name("tty7-scrollback".into()) .spawn(move || { let mut marks: HashMap = HashMap::new(); - let mut storing = crate::daemon::scrollback::enabled(); loop { std::thread::sleep(crate::daemon::scrollback::SNAPSHOT_INTERVAL); - let enabled = crate::daemon::scrollback::enabled(); - if !enabled { - if storing { - log::info!("scrollback persistence turned off; dropping what was stored"); - crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); - marks.clear(); - storing = false; - } - continue; - } - storing = true; for pane in registry.all() { if marks.get(&pane.id) == Some(&pane.scrollback_mark()) { continue; @@ -205,7 +205,9 @@ fn spawn_scrollback_writer(registry: Arc) { crate::daemon::scrollback::save(pane.id, &segments); marks.insert(pane.id, mark); } - crate::daemon::scrollback::sweep(&restorable_pane_ids(®istry)); + let restorable = restorable_pane_ids(®istry); + crate::daemon::scrollback::sweep(&restorable); + crate::daemon::history::sweep(&restorable); marks.retain(|id, _| registry.get(*id).is_some()); } }); @@ -221,9 +223,6 @@ fn spawn_scrollback_writer(registry: Arc) { /// covers the ones we do, and makes the copy exact rather than up to /// [`SNAPSHOT_INTERVAL`](crate::daemon::scrollback::SNAPSHOT_INTERVAL) stale. fn store_scrollback_now(registry: &Registry) { - if !crate::daemon::scrollback::enabled() { - return; - } for pane in registry.all() { let (segments, _) = pane.scrollback_snapshot(); crate::daemon::scrollback::save(pane.id, &segments); @@ -239,10 +238,12 @@ fn store_scrollback_now(registry: &Registry) { fn restored_screen( request: crate::daemon::protocol::RestoreFrom, ) -> Option { - if !crate::daemon::scrollback::enabled() { - return None; - } let segments = crate::daemon::scrollback::load(request.pane_id)?; + // Dropped either way — this is the one request that will ever be made about + // this pane, so nothing is served by keeping the file past it. What the + // emptiness check decides is whether a *restore* happened, not whether the + // file stays: a snapshot holding nothing is not a screen to hand over, but + // it is still a file nobody will read again. crate::daemon::scrollback::forget(request.pane_id); if segments.is_empty() { return None; @@ -559,22 +560,26 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { } spawn_orphan_sweep(registry.clone()); - // Before the writer starts: a daemon that has just come up owns no panes, - // so everything on disk belongs to panes the machine tree either still - // names — those are the ones a window is about to ask to restore — or has - // forgotten, and the latter are nobody's to restore any more. - let restorable = restorable_pane_ids(®istry); - if crate::daemon::scrollback::enabled() { - crate::daemon::scrollback::sweep(&restorable); - } else { - crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); - } - // A daemon that was killed outright never retired anything, so the files of - // panes that died with it are still here. Their commands cannot be - // recovered — the mark saying which were new belongs to a shell that is - // gone — so what is left is not to hoard them. - crate::daemon::history::sweep(&restorable); - spawn_scrollback_writer(registry.clone()); + // No sweeping here, deliberately — of either kind. Startup is the one + // moment this process knows least: it owns no panes yet, and the windows + // that know which of a dead daemon's files are still wanted cannot say so + // until the endpoint below is listening. Answering "is anyone going to ask + // for this?" here answers it when nobody can — and the answer deletes. A + // tree that failed to parse makes it worse, because `read_machine` + // quarantines it and hands back an empty `Machine`, so one bad file would + // take every pane's screen and every pane's history with it. + // + // History was swept here until it was noticed that a restore carries the + // dead pane's commands to its successor (`history::carry`, at the top of + // the `Spawn` handler): sweeping before the window can ask deletes the very + // file the request is about. Same shape as the scrollback bug, one file + // over. + // + // Both sweeps run on the writer's tick instead, with the registry filled in + // and the tree caught up. That is soon enough: nothing here is serving a + // request in the meantime, and a daemon killed outright left its files for + // exactly this pass to collect. + spawn_snapshot_keeper(registry.clone()); for stream in listener.incoming() { match stream { diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 9ad1846f..71864287 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -1716,6 +1716,7 @@ mod aggregate_tests { cwd: Some("/repo/tty7".into()), ssh_spec: None, agent: None, + shell: None, }, None, None, diff --git a/crates/tty7-server/tests/client_lib.rs b/crates/tty7-server/tests/client_lib.rs index 26896704..b17a18a0 100644 --- a/crates/tty7-server/tests/client_lib.rs +++ b/crates/tty7-server/tests/client_lib.rs @@ -137,6 +137,7 @@ fn seed(pane: u64) -> PaneSeed { cwd: Some("/home/me/proj".into()), ssh_spec: None, agent: None, + shell: None, } } diff --git a/crates/tty7-server/tests/machine_tree.rs b/crates/tty7-server/tests/machine_tree.rs index 9e1d5a72..4ec69818 100644 --- a/crates/tty7-server/tests/machine_tree.rs +++ b/crates/tty7-server/tests/machine_tree.rs @@ -111,10 +111,8 @@ fn machine_file(dir: &tempfile::TempDir) -> PathBuf { fn seed(pane: u64, cwd: &str) -> PaneSeed { PaneSeed { - pane, cwd: Some(cwd.to_string()), - ssh_spec: None, - agent: None, + ..PaneSeed::bare(pane) } } diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs new file mode 100644 index 00000000..04436e72 --- /dev/null +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -0,0 +1,403 @@ +//! A pane's screen survives the daemon being stopped and started. +//! +//! The unit tests under `daemon::scrollback` cover the file: it round-trips, it +//! is trimmed from the front, the sweep keeps what it is told to keep. None of +//! that answers the question the feature exists for, which is whether a window +//! that comes back to a restarted daemon is shown what its panes had on them. +//! That answer involves a real daemon writing a real snapshot, dying, and a +//! second daemon handing the bytes to the client that asks — so this runs all +//! of it and reads the wire. +//! +//! The ordering is the whole difficulty. A client cannot ask for a restore +//! until the daemon is listening, so anything the daemon throws away *while +//! starting up* is thrown away before the only party who knows what is still +//! wanted has been able to say so. + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use tty7_core::client::{ControlClient, PaneClient}; +use tty7_core::core::machine::PaneSeed; +use tty7_core::daemon::control::{ControlHello, ControlRequest, ReplyOk}; +use tty7_core::daemon::protocol::{ClientMsg, DaemonMsg, RestoreFrom, ShellSpec, WinSize}; +use tty7_core::daemon::transport; + +const READY_WITHIN: Duration = Duration::from_secs(30); +const STREAM_WITHIN: Duration = Duration::from_secs(30); +const STOP_WITHIN: Duration = Duration::from_secs(30); + +const MARKER: &[u8] = b"tty7_screen_kept"; + +/// One instance's directories, outliving the daemons that serve them — the +/// point of the test is what is on disk between two of them. +struct Instance { + dir: tempfile::TempDir, +} + +impl Instance { + fn new() -> Instance { + // No config: keeping each pane's screen is what the daemon does, not + // something it is asked to do. + Instance { + dir: tempfile::TempDir::new().unwrap(), + } + } + + fn path(&self) -> &std::path::Path { + self.dir.path() + } + + /// What the daemon writes down for clients to find it by: a socket on unix, + /// a port-and-token file on Windows. + fn endpoint(&self) -> PathBuf { + #[cfg(unix)] + let name = "daemon.sock"; + #[cfg(windows)] + let name = "daemon.port"; + self.dir.path().join(name) + } + + fn panes(&self) -> PaneClient { + PaneClient::at(self.endpoint()) + } + + fn control_endpoint(&self) -> PathBuf { + #[cfg(unix)] + let name = "control.sock"; + #[cfg(windows)] + let name = "control.port"; + self.dir.path().join(name) + } + + fn snapshot_of(&self, pane_id: u64) -> Option> { + std::fs::read( + self.dir + .path() + .join("scrollback") + .join(format!("{pane_id}.bin")), + ) + .ok() + } + + /// The tree a window would have left behind: one workspace, one tab, and + /// that tab standing on the pane whose screen we want back. + fn record_tab_on(&self, pane_id: u64) { + let tree = format!( + r#"{{ + "workspaces": [ + {{ + "id": "11111111-2222-3333-4444-555555555555", + "name": null, + "last_active": 1786343761, + "tabs": [ + {{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": null, + "sidebar_group": null, + "root": {{ "Leaf": {{ "pane": {pane_id} }} }} + }} + ], + "active_tab": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }} + ], + "panes": [ + {{ "id": {pane_id}, "cwd": null, "title": "", "ssh_spec": null, "agent": null, "live": false }} + ] +}}"# + ); + std::fs::write(self.dir.path().join("machine.json"), tree).unwrap(); + } + + fn start(&self) -> Running { + let child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) + .arg("--daemon") + .arg("--config-dir") + .arg(self.path()) + .env("TTY7_DATA_DIR", self.path()) + .env("TTY7_CONTROL_SOCK", self.path().join("control.sock")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start tty7-server --daemon"); + let running = Running { + child, + stopped: false, + }; + let deadline = Instant::now() + READY_WITHIN; + loop { + if self.panes().version().is_ok() { + return running; + } + assert!( + Instant::now() < deadline, + "the daemon did not open its pane endpoint within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Ask the daemon to go, the way the restart in Settings asks. This is the + /// path that takes each pane's last snapshot on the way out, so a test that + /// killed the process instead would be testing the periodic writer. + fn stop(&self, mut running: Running) { + let mut stream = + transport::connect_endpoint_at(&self.endpoint()).expect("connect to ask for shutdown"); + ClientMsg::Shutdown + .encode(&mut stream) + .expect("send Shutdown"); + stream.flush().ok(); + drop(stream); + + let deadline = Instant::now() + STOP_WITHIN; + loop { + match running.child.try_wait() { + Ok(Some(_)) => { + running.stopped = true; + return; + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => panic!("the daemon did not exit within {STOP_WITHIN:?}"), + Err(e) => panic!("waiting for the daemon failed: {e}"), + } + } + } + + /// The `Spawn` a window sends for a pane whose `Attach` found nothing: a + /// new shell, asked to open showing what the dead one had. + fn spawn_restoring(&self, dead: u64) -> (u64, Vec) { + let mut stream = + transport::connect_endpoint_at(&self.endpoint()).expect("connect to spawn"); + ClientMsg::Spawn { + cwd: None, + size: size(), + shell: Some(interactive_shell()), + owner: None, + workspace: None, + restore: Some(RestoreFrom { + pane_id: dead, + banner: Some("the shell below is new".to_string()), + }), + } + .encode(&mut stream) + .expect("send Spawn"); + + let pane_id = match DaemonMsg::read(&mut stream) { + Ok(DaemonMsg::Spawned { pane_id }) => pane_id, + other => panic!("expected Spawned, got {other:?}"), + }; + + // Everything the daemon replays sits in the socket ahead of whatever + // the new shell writes, so a bounded drain is enough: the replay is + // already queued by the time `Spawned` is read. + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bound the drain"); + let mut replayed = Vec::new(); + while let Ok(msg) = DaemonMsg::read(&mut stream) { + match msg { + DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => { + replayed.extend_from_slice(&bytes) + } + _ => {} + } + } + (pane_id, replayed) + } +} + +struct Running { + child: Child, + stopped: bool, +} + +impl Drop for Running { + fn drop(&mut self) { + if !self.stopped { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn size() -> WinSize { + WinSize { + cols: 100, + rows: 30, + cell_w: 8, + cell_h: 16, + } +} + +fn interactive_shell() -> ShellSpec { + #[cfg(unix)] + let program = "/bin/sh"; + #[cfg(windows)] + let program = "cmd.exe"; + ShellSpec { + program: program.into(), + args: Vec::new(), + args_are_tty7_defaults: false, + } +} + +fn windows_contain(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +fn collect_until(session: &mut tty7_core::client::PaneSession, marker: &[u8]) -> Vec { + let mut seen: Vec = Vec::new(); + loop { + match session.recv() { + Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => { + seen.extend_from_slice(&bytes); + if windows_contain(&seen, marker) { + return seen; + } + } + Ok(DaemonMsg::Exited { code }) => panic!( + "the pane exited ({code:?}) before {:?} appeared; saw {:?}", + String::from_utf8_lossy(marker), + String::from_utf8_lossy(&seen) + ), + Ok(_) => {} + Err(e) => panic!( + "the pane stream ended early: {e}; saw {:?}", + String::from_utf8_lossy(&seen) + ), + } + } +} + +/// Put a marker on a pane's screen and give the daemon back the pane's id. +fn pane_showing_the_marker(instance: &Instance) -> u64 { + let mut session = instance + .panes() + .spawn(None, size(), Some(interactive_shell()), None, None) + .expect("spawn a pane"); + session + .set_recv_timeout(Some(STREAM_WITHIN)) + .expect("bound the stream reads"); + let pane_id = session.pane_id(); + session + .input(format!("echo {}\r", String::from_utf8_lossy(MARKER)).as_bytes()) + .expect("the shell takes input"); + collect_until(&mut session, MARKER); + // Detach rather than kill: the pane outlives this connection, which is the + // state a daemon restart finds its panes in. + session.detach().expect("detach"); + pane_id +} + +/// The case the feature is for: nothing has told the new daemon anything yet, +/// because the window that would tell it is still waiting for it to listen. +#[test] +fn a_restarted_daemon_still_has_the_screen_when_the_window_asks() { + let instance = Instance::new(); + let running = instance.start(); + let dead = pane_showing_the_marker(&instance); + instance.stop(running); + + let stored = instance + .snapshot_of(dead) + .expect("the shutdown wrote the pane's screen"); + assert!( + windows_contain(&stored, MARKER), + "the snapshot on disk does not hold the pane's screen" + ); + + let _restarted = instance.start(); + let (_new_pane, replayed) = instance.spawn_restoring(dead); + assert!( + windows_contain(&replayed, MARKER), + "the restarted daemon did not give the pane back its screen; \ + the client received {:?}", + String::from_utf8_lossy(&replayed) + ); +} + +/// What a pane is running has to reach the tree, because that is the only +/// place a window rebuilding the pane can read it from. Without it the rebuild +/// falls back to the default shell, which is how a restart turns a bash pane +/// into a PowerShell one. +#[test] +fn the_tree_records_the_shell_a_pane_is_running() { + let instance = Instance::new(); + let _running = instance.start(); + let pane = pane_showing_the_marker(&instance); + + // A pane reaches the tree by being put in a tab, which is what the window + // does right after it spawns one — carrying the same seed it spawned with. + // Until then the daemon has nothing to record its facts against, so this is + // the pane's first chance to say what it is running, and for a pane that + // then sits at a prompt it is the only one: the daemon's own observation + // rides on a fact *changing*, and a pane's shell never does. + let control = ControlClient::connect_at( + &instance.control_endpoint(), + &ControlHello::host_rpc("probe", "probe"), + ) + .expect("control handshake"); + let ws = match control + .request(ControlRequest::WorkspaceCreate { + name: Some("probe".into()), + workspace: None, + }) + .expect("create a workspace") + { + ReplyOk::WorkspaceTree(ws) => *ws, + other => panic!("expected WorkspaceTree, got {other:?}"), + }; + control + .request(ControlRequest::TabCreate { + workspace: ws.id, + at: None, + pane: PaneSeed { + shell: Some(interactive_shell()), + ..PaneSeed::bare(pane) + }, + tab: None, + }) + .expect("put the pane in a tab"); + + let wanted = interactive_shell().program; + let deadline = Instant::now() + STREAM_WITHIN; + loop { + let tree = + std::fs::read_to_string(instance.path().join("machine.json")).unwrap_or_default(); + // The record is keyed by pane id and the program is a plain string in + // it; matching on both together is enough to say this pane's shell — + // not some other pane's — reached the tree. + if tree.contains(&format!("\"id\": {pane}")) && tree.contains(&wanted) { + return; + } + assert!( + Instant::now() < deadline, + "the tree never recorded pane {pane}'s shell ({wanted}); it holds: {tree}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// The same thing for an instance whose tree still names the pane. This one +/// passes on its own merits today; it is here so a fix for the case above +/// cannot be one that quietly stops honouring the tree. +#[test] +fn a_screen_comes_back_when_the_tree_still_names_its_pane() { + let instance = Instance::new(); + let running = instance.start(); + let dead = pane_showing_the_marker(&instance); + instance.stop(running); + instance.record_tab_on(dead); + + let _restarted = instance.start(); + let (_new_pane, replayed) = instance.spawn_restoring(dead); + assert!( + windows_contain(&replayed, MARKER), + "a pane the tree still names came back blank; the client received {:?}", + String::from_utf8_lossy(&replayed) + ); +} diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 51ba3362..6ce9ca85 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -311,11 +311,25 @@ impl RemoteTerminal { // predates the field would silently drop it. Sending it anyway would // cost nothing but would make the log claim a restore that never // happened, so the local case is the only one that asks. - let restore = restore.filter(|_| { - route.is_local() - && crate::daemon::spawn::local_daemon_supports( - crate::daemon::protocol::FEATURE_RESTORE_SCROLLBACK, - ) + let restore = restore.and_then(|want| { + let local = route.is_local(); + let supported = crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_RESTORE_SCROLLBACK, + ); + if local && supported { + return Some(want); + } + // Said out loud, because the symptom of dropping it here is a pane + // that opens blank — which is also what a pane with nothing stored + // looks like, and what a daemon that refused would produce. Three + // causes, one appearance; without this line the only way to tell + // them apart is to read the source. + log::info!( + "not asking to restore pane {}'s screen: local={local} \ + daemon-supports-restore={supported}", + want.pane_id + ); + None }); ClientMsg::Spawn { diff --git a/src/ui/app.rs b/src/ui/app.rs index 70d8aa89..f3336c97 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2539,14 +2539,6 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.restore_session = on); } - /// The daemon reads this from the config file on its own — it is the one - /// holding the output — so there is nothing to tell it here. Turning it off - /// also removes what was already stored, which the daemon does on its next - /// pass rather than leaving the bytes behind. - pub(crate) fn set_persist_scrollback(&mut self, on: bool, cx: &mut Context) { - self.update_config(cx, |cfg| cfg.persist_scrollback = on); - } - /// Takes effect on the next pane: a shell is told where its history lives /// when it starts, and nothing can move it afterwards. pub(crate) fn set_per_pane_history(&mut self, on: bool, cx: &mut Context) { @@ -6566,6 +6558,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { SessionPane::Leaf { cwd: spawn.working_directory.clone(), pane_id: spawn.restore_pane, + shell: spawn.shell.clone(), ssh_spec: None, agent: spawn.agent, agent_session_id: spawn.agent_session_id.clone(), @@ -6577,6 +6570,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { SessionPane::Leaf { cwd: view.spawnable_cwd(), pane_id: Some(view.pane_id), + // `None` for a pane this window attached to rather than + // spawned: it never knew what was on the other end. The tree + // does — the daemon records it — and that is what a restore + // reads, so the gap here costs nothing it can see. + shell: view.shell_spec(), ssh_spec: view.ssh_spec(), agent: view.agent(), agent_session_id: view.agent_session().and_then(|s| s.session_id), @@ -6597,6 +6595,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { Pane::Empty => SessionPane::Leaf { cwd: None, pane_id: None, + shell: None, ssh_spec: None, agent: None, agent_session_id: None, @@ -6634,7 +6633,26 @@ pub(crate) fn alive_panes_on( } } -fn pane_attachable( +/// Whether this window may stand on `id` — as the pane it attaches to, or as +/// the dead predecessor whose screen a fresh pane opens showing. +/// +/// Liveness deliberately does not come into it, and that is the whole point. +/// A pane missing from the listing is usually one whose daemon has just +/// restarted, which is exactly when its stored screen is worth asking for. +/// Ruling the id out there threw away the only thing that could ask: the +/// window spawned a pane that had never heard of a predecessor, so no attach +/// was tried, no restore was requested, and the screen the daemon still had on +/// disk was swept a tick later, unread. +/// +/// There used to be a second predicate here that also required the id to be +/// listed, and the attach site consulted it. Nothing does now: the attach is +/// simply tried, and a pane that really is gone fails it and falls through to +/// the fresh spawn — the same outcome the listing was consulted to predict, +/// reached by asking the daemon instead of guessing ahead of it. +/// +/// Ownership does come into it. Another workspace's pane is not this window's +/// to attach to, and its screen is not this window's to show. +fn pane_free_for( alive: Option<&std::collections::HashMap>>, id: u64, owner: crate::core::session::WorkspaceId, @@ -6647,7 +6665,7 @@ fn pane_attachable( return true; }; match alive.get(&id) { - None => false, + None => true, Some(None) => true, Some(Some(recorded)) => { // Only a workspace id is a claim. Anything else is a client @@ -6736,6 +6754,7 @@ fn session_to_pane( SessionPane::Leaf { cwd, pane_id, + shell, ssh_spec, agent, agent_session_id, @@ -6745,7 +6764,10 @@ fn session_to_pane( leaf_shares_the_window_daemon(workspace.is_some(), ssh_spec.is_some()); let restore = match workspace.is_some() { true => (*pane_id).filter(|_| same_daemon), - false => (*pane_id).filter(|id| same_daemon && pane_attachable(alive, *id, owner)), + // Not `pane_attachable`: a dead pane's id is what the restore + // is keyed on, so it has to survive being dead. The attach is + // still attempted first and still gives way to a fresh spawn. + false => (*pane_id).filter(|id| same_daemon && pane_free_for(alive, *id, owner)), }; if restore.is_none() { if let Some(spec) = ssh_spec.clone() { @@ -6762,7 +6784,7 @@ fn session_to_pane( font_size, cwd.clone(), restore, - None, + shell.clone(), window, cx, ) { @@ -7362,7 +7384,7 @@ mod window_drag_tests { mod tests { use super::{ CloseReason, TabAgentSession, clear_window_override_values, close_prompt, - leaf_shares_the_window_daemon, mru_order, pane_attachable, parse_ssh_connect_input, + leaf_shares_the_window_daemon, mru_order, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, }; @@ -7475,33 +7497,57 @@ mod tests { .collect(); assert!( - pane_attachable(Some(&alive), 1, ours), + pane_free_for(Some(&alive), 1, ours), "our own pane attaches" ); assert!( - !pane_attachable(Some(&alive), 2, ours), + !pane_free_for(Some(&alive), 2, ours), "another workspace's pane must spawn fresh instead" ); assert!( - pane_attachable(Some(&alive), 3, ours), + pane_free_for(Some(&alive), 3, ours), "an unowned pane is legacy" ); assert!( - pane_attachable(Some(&alive), 5, ours), + pane_free_for(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" - ); - assert!( - pane_attachable(None, 4, ours), + pane_free_for(None, 4, ours), "a failed List says nothing about pane 4; the attach itself must decide, \ because respawning on a transient RPC error destroys a live session" ); } + #[test] + fn a_dead_pane_keeps_its_id_so_its_screen_can_be_asked_for() { + let ours = crate::core::session::WorkspaceId::new(); + let theirs = crate::core::session::WorkspaceId::new(); + let alive: std::collections::HashMap> = + [(1, Some(ours.to_string())), (2, Some(theirs.to_string()))] + .into_iter() + .collect(); + + // The restart case: every pane the window held is missing from the new + // daemon's listing. Their ids are the only handle on the screens it + // still has stored, so being dead must not erase them — this is what + // made a restarted server come back to a row of blank shells. + assert!( + pane_free_for(Some(&alive), 4, ours), + "a dead pane's id has to survive; the restore is keyed on it" + ); + + // What being free does not mean: helping yourself to a pane that is + // alive and belongs to another workspace, whose screen is not this + // window's to show either. + assert!( + !pane_free_for(Some(&alive), 2, ours), + "another workspace's pane is not ours to restore from" + ); + assert!(pane_free_for(Some(&alive), 1, ours), "our own pane is ours"); + } + #[test] fn a_native_ssh_leaf_in_a_remote_window_is_not_looked_up_in_the_remote_daemon() { assert!(!leaf_shares_the_window_daemon(true, true)); diff --git a/src/ui/home.rs b/src/ui/home.rs index cf70bd7a..9e06cf7a 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -293,6 +293,7 @@ mod tests { fn leaf(cwd: Option<&str>) -> SessionPane { SessionPane::Leaf { + shell: None, cwd: cwd.map(PathBuf::from), pane_id: None, ssh_spec: None, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b9751096..6914debd 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1483,14 +1483,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { adds is written back when it closes, so nothing is lost. Applies to bash and zsh \ panes that tty7 can set up; a shell started with your own arguments is left alone." } - L10nKey::SettingsPersistScrollback => "Keep pane output on disk", - L10nKey::SettingsPersistScrollbackDescription => { - "If the background service dies without warning — a crash, or a reboot — panes come \ - back showing what was on them instead of blank. The processes are gone either way; \ - this restores the picture. It writes a capped tail of every pane's output to disk, \ - including anything printed there: tokens, the output of `env`, an agent's \ - transcript. Off means that output only ever lives in memory." - } L10nKey::PanelMoreChangedFiles => { "… and {count} more changed files — run git diff to see them." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 3ed1c505..a8b2cccb 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1530,14 +1530,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { 新しいペインは空ではなく既存の履歴から始まり、追加された分はペインを閉じるときに書き戻されるので失われません。\ tty7 が設定できる bash と zsh のペインが対象で、独自の引数で起動したシェルはそのままです" } - L10nKey::SettingsPersistScrollback => "ペインの出力をディスクに残す", - L10nKey::SettingsPersistScrollbackDescription => { - "バックグラウンドサービスが引き継ぎの間もなく落ちた場合(クラッシュや再起動)、\ - ペインは空ではなく、そこにあった内容を表示して戻ります。プロセスはいずれにせよ失われ、\ - ここで戻るのは画面だけです。各ペインの出力の末尾を上限つきでディスクに書き込みます。\ - そこに表示されたもの(トークン、`env` の出力、エージェントの記録)も含みます。\ - オフなら、その出力はメモリ上にしか存在しません。" - } L10nKey::PanelMoreChangedFiles => { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1a448775..489d29f7 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1226,8 +1226,6 @@ l10n_keys! { PaneRestoredScreenBanner, AppRestartServerBodyInPlace, SettingsDaemonStaleDescInPlace, - SettingsPersistScrollback, - SettingsPersistScrollbackDescription, SettingsPerPaneHistory, SettingsPerPaneHistoryDescription, } diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 1761731e..ec546971 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1406,12 +1406,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { 新面板会从你已有的历史开始,而不是一片空白;面板关闭时,它新增的部分会写回原来的历史文件,不会丢。\ 只对 tty7 能接管的 bash 和 zsh 面板生效;用你自己参数启动的 shell 不受影响。" } - L10nKey::SettingsPersistScrollback => "把面板输出留在磁盘上", - L10nKey::SettingsPersistScrollbackDescription => { - "后台服务如果没来得及交接就没了(崩溃、重启机器),面板回来时会显示原先的内容,而不是一片空白。\ - 进程无论如何都救不回来,这里恢复的只是画面。它会把每个面板输出的末尾(有上限)写到磁盘上,\ - 包括那里打印过的一切:token、`env` 的输出、agent 的对话记录。关掉则这些输出只存在于内存里。" - } L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", L10nKey::ScmFilesChanged => "{count} 个文件改动", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1b9db4c1..704e9f3e 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4743,7 +4743,6 @@ impl Tty7App { NewTabPosition::End => 1, }; let restore_session = cfg.restore_session; - let persist_scrollback = cfg.persist_scrollback; let remember_window_size = cfg.remember_window_size; let show_tray_icon = cfg.show_tray_icon; let tab_bar_idx = match cfg.tab_bar_position { @@ -4804,10 +4803,6 @@ impl Tty7App { .checked(restore_session) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx))) .into_any_element(); - let persist_scrollback_switch = crate::ui::theme::switch("wt-persist-scrollback", cx) - .checked(persist_scrollback) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_persist_scrollback(*on, cx))) - .into_any_element(); let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx) .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) @@ -4901,12 +4896,6 @@ impl Tty7App { restore_switch, cx, )) - .child(self.settings_row( - t(L10nKey::SettingsPersistScrollback), - t(L10nKey::SettingsPersistScrollbackDescription), - persist_scrollback_switch, - cx, - )) .child(self.settings_row( t(L10nKey::SettingsShowTrayIcon), t(L10nKey::SettingsShowTrayIconDesc), diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index b0829d79..05c3a2e6 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -179,6 +179,11 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option Option SessionPane match node { PaneNode::Leaf { pane } => { let record = panes.iter().find(|p| p.id == *pane); - let (cwd, ssh_spec, agent) = match record { + let (cwd, ssh_spec, agent, shell) = match record { Some(r) => ( r.cwd.clone().map(std::path::PathBuf::from), r.ssh_spec.clone(), r.agent.clone(), + r.shell.clone(), ), - None => (None, None, None), + None => (None, None, None, None), }; SessionPane::Leaf { cwd, @@ -1210,6 +1217,7 @@ fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane // trying to guess — except it is right. `live` stays a hint for // what to show, never the judge of what to destroy. pane_id: Some(*pane), + shell, ssh_spec, agent: agent.as_ref().map(|a| a.agent), agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()), @@ -2259,6 +2267,7 @@ mod tests { cwd: Some(format!("/work/{pane}")), ssh_spec: None, agent: None, + shell: None, } }