Merge main into the sidebar-diff branch

#260 landed the daemon-owned workspace tree, which took `WorkspaceStore` out
of the control server's test module. Its two helpers there — `temp_store` and
`ws_record` — went with it and have no callers left, so they are dropped
rather than carried; the git-stream tests that had grown alongside them stay.
This commit is contained in:
l0ng-ai
2026-07-29 21:59:55 -07:00
45 changed files with 9905 additions and 5316 deletions
+28
View File
@@ -81,6 +81,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **The machine that runs your panes now owns their layout** — the workspace,
tab and pane tree has moved out of the app and into the background service, so
one machine has one tree that every client of it reads: the window on it, a
laptop connected to it across the world, and (next) the session CLI. Clients
send named edits ("split this pane", "rename that tab") and receive the
incremental changes other clients make, which is what lets two windows on one
machine both land their work instead of the last one to save winning. A pane's
working directory, its coding agent and whether it is still running are now
observed by the service that owns the PTY rather than remembered by whichever
client last wrote a file — so after a service restart every pane is *known*
dead and revives into its recorded directory with its agent conversation
resumed, with no guessing about which saved ids survived.
Two consequences worth knowing before you upgrade:
- **Saved layouts do not carry over.** The tree is a new file
(`~/.local/share/tty7/machine.json`) and the old `session.json` is not read;
the upgrade also replaces the background service, which ends the panes it was
holding. The first launch after upgrading comes up on a fresh workspace, and
tabs from before it are not recoverable. `views.json` (window geometry and
which workspaces you had open) replaces `session.json` for the client's own
half; the old file is left on disk, unread.
- **Windows keeps its panes but not its layout, for now.** The tree is served
over the same control channel remote machines use, and that channel is
Unix-socket-only today, so on Windows tabs do not come back across a restart.
Panes, splits, agents and shell integration are unaffected within a session.
(#260)
- **The prompt editor's soft newline is now a rebindable action** — `Shift+Enter`
and `Alt+Enter` have inserted a literal newline into the command editor since
the multi-line prompt editor landed, but the chords were hardcoded in the key
Generated
-1
View File
@@ -9606,7 +9606,6 @@ dependencies = [
name = "tty7-server"
version = "26.7.6"
dependencies = [
"serde_json",
"tempfile",
"tty7-core",
]
+34 -3
View File
@@ -963,7 +963,7 @@ pub fn default_config_dir() -> Option<PathBuf> {
}
/// Resolve a file under the config directory (no `dirs` dep). Shared by every
/// config-dir file (`config.json`, `session.json`, `history`).
/// config-dir file (`config.json`, `views.json`, `history`).
pub fn config_path(file: &str) -> Option<PathBuf> {
Some(config_dir()?.join(file))
}
@@ -988,8 +988,30 @@ pub fn strip_bom(text: &str) -> &str {
/// old file or the new one intact — never a truncated/half-written file that
/// fails to parse and silently reverts the user's settings to defaults. The temp
/// lives in the same directory so the rename stays on one filesystem (atomic).
/// Shared by `Config::save` and `Session::save`.
/// Shared by `Config::save` and `WindowViews::save`.
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
write_atomic_mode(path, bytes, false)
}
/// [`write_atomic`], with the target owner-only from the first instant its final
/// name exists.
///
/// The mode is set on the *temp* file, before the rename, for the same reason
/// [`bind_control_socket`](crate::host::server::bind_control_socket) tightens
/// the umask around its `bind` rather than chmod-ing afterwards: a fix-up on the
/// next line is a window in which the file is readable, and under a `umask 002`
/// — the default wherever user-private groups are configured — that window is
/// group-readable. For documents whose contents are the user's business alone:
/// `machine.json` names every workspace's directories, SSH users and hosts, and
/// agent session ids.
///
/// A no-op difference on Windows, which has no mode bits: the config directory's
/// own ACL is the boundary there, as it is for the daemon's port file.
pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
write_atomic_mode(path, bytes, true)
}
fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> {
use std::io::Write as _;
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
// Per-process-unique temp name so two concurrent writers don't clobber the
@@ -1001,7 +1023,16 @@ pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()>
std::process::id()
));
{
let mut f = std::fs::File::create(&tmp)?;
let mut open = std::fs::OpenOptions::new();
open.write(true).create(true).truncate(true);
#[cfg(unix)]
if private {
use std::os::unix::fs::OpenOptionsExt as _;
open.mode(0o600);
}
#[cfg(not(unix))]
let _ = private;
let mut f = open.open(&tmp)?;
f.write_all(bytes)?;
f.flush()?;
let _ = f.sync_all();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,6 +17,7 @@ pub mod crash;
pub mod git;
pub mod gitignore;
pub mod logfile;
pub mod machine;
// SSH connection-manager data layer (WS1). Its public API is consumed by the
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
// so parts of it read as dead code until those merge.
@@ -30,5 +31,4 @@ pub mod shells;
pub mod ssh_profile;
pub mod threads;
pub mod window_state;
pub mod workspace_store;
pub mod worktree;
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,5 +1,5 @@
//! Persisted last-window geometry, stored at `window.json` in the config dir
//! (alongside `config.json` / `session.json`). The quit hook in `ui::app`
//! (alongside `config.json` / `views.json`). The quit hook in `ui::app`
//! writes the window's final bounds here unconditionally; startup reads it
//! back only when `Config::remember_window_size` is on, so toggling the
//! setting off and on again still restores the most recent quit's geometry.
@@ -7,9 +7,9 @@
//! reads fall back to "nothing remembered", writes are atomic.
//!
//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because
//! [`Workspace`](super::session::Workspace) embeds it and `session.json` has to
//! parse without gpui. Converting to and from `Bounds` is the GUI crate's job —
//! see its `core::window_state::WindowGeometry` extension trait.
//! [`WindowView`](super::session::WindowView) embeds it and `views.json` is
//! parsed in this gpui-free crate. Converting to and from `Bounds` is the GUI
//! crate's job — see its `core::window_state::WindowGeometry` extension trait.
use serde::{Deserialize, Serialize};
File diff suppressed because it is too large Load Diff
+230 -52
View File
@@ -90,11 +90,25 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// comparing dialect numbers, so a capability that doesn't move the number is a
/// capability the far machine never gets. A [`feature`] string is the right
/// answer only for something two current servers can genuinely disagree about
/// (the workspace store, which depends on how the server was started); "this
/// (the machine tree, which depends on how the server was started); "this
/// build knows the request and older ones don't" is what the number is for.
///
/// ## History
///
/// - **v3** — the machine-tree migration. The workspace/tab/pane tree moved
/// into the daemon: `MachineGet` / `WorkspaceTree`, the semantic tree verbs
/// (workspace/tab/pane create, close, rename, move, split, ratio, replace),
/// and the [`ControlEvent::Layout`] / [`ControlEvent::LayoutResync`] pushes
/// — seventeen new request variants in all — while the retired opaque-record
/// verbs
/// (`workspace_list` / `workspace_get` / `workspace_put` /
/// `workspace_delete` and the `workspace_changed` event) left the dialect
/// entirely. A v2 peer meeting any of the new variants fails the whole
/// decode (no `#[serde(other)]`), and this build meeting a v2 server's
/// record verbs would answer unknown-variant errors forever. The
/// [`feature::MACHINE_TREE`] bit still exists *within* v3, because two
/// current servers can genuinely differ on it (a box with no home
/// directory serves files but no tree).
/// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab
/// dropdown. Not a `feature` string: every server from this build on answers
/// it, so the only thing a capability bit would have bought is that a machine
@@ -102,7 +116,7 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server
/// and install this build's instead, which is the actual fix.
/// - **v1** — the dialect at the time remote workspaces landed.
pub const CONTROL_VERSION: u32 = 2;
pub const CONTROL_VERSION: u32 = 3;
/// This process's identity as a control server, minted once on first use.
///
@@ -239,10 +253,20 @@ pub mod feature {
pub const CONTROL: &str = "control";
/// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can
/// back a remote `Host`. Distinct from [`CONTROL`] because a peer could
/// speak the dialect while exposing only the workspace store.
/// speak the dialect while exposing only the workspace tree.
pub const HOST_RPC: &str = "host-rpc";
/// Serves the `Workspace*` requests.
pub const WORKSPACE_STORE: &str = "workspace-store";
// `"workspace-store"` is a burned name: it advertised the retired
// opaque-record scheme (verbs `workspace_list` / `workspace_get` /
// `workspace_put` / `workspace_delete`, event `workspace_changed`), all of
// which are burned with it. Never re-advertise or re-mint any of them with
// a different meaning.
/// Serves the machine-owned workspace tree: the `MachineGet` /
/// `WorkspaceTree` pulls, the semantic tree operations, and the
/// [`super::ControlEvent::Layout`] pushes. Advertised only when the server
/// actually carries a [`crate::core::machine::MachineStore`], so a client
/// learns from the handshake whether the tree verbs are worth a round
/// trip.
pub const MACHINE_TREE: &str = "machine-tree";
/// Can be launched as `--stdio` and bridge its own stdin/stdout to the
/// machine-local socket (the fallback when `AllowStreamLocalForwarding` is
/// off, the only option under WSL, and how the CI end-to-end test runs).
@@ -264,6 +288,13 @@ pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
// the wire, not a wire-only copy of it.
pub use crate::core::shells::{DetectedShell, ShellInventory};
// And for the machine tree: the daemon's own tree types are the wire types, so
// a schema drift between the store and the dialect is a compile error rather
// than a silent mistranslation. `WorkspaceId` rides along because every tree
// verb addresses a workspace by it.
pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId};
pub use crate::core::session::WorkspaceId;
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
@@ -275,7 +306,8 @@ pub use crate::core::shells::{DetectedShell, ShellInventory};
/// are routinely different operating systems. Remote paths are UTF-8 POSIX; a
/// non-UTF-8 name on the server is returned lossily by `ReadDir`, matching what
/// the file tree already does locally with `to_string_lossy`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
// Not `Eq`: the machine-tree verbs carry split ratios, and `f32` has no `Eq`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlRequest {
// ----- liveness ---------------------------------------------------------
@@ -402,18 +434,10 @@ pub enum ControlRequest {
id: u64,
},
// ----- workspace store (M5; the slots exist, the server doesn't yet) -----
WorkspaceList,
WorkspaceGet {
id: String,
},
WorkspacePut {
id: String,
json: serde_json::Value,
},
WorkspaceDelete {
id: String,
},
// The opaque record store's verbs — `workspace_list` / `workspace_get` /
// `workspace_put` / `workspace_delete` — lived here until the machine tree
// below replaced them. Their serde names are burned (see `feature`); do
// not re-mint them with a different meaning.
// ----- attachment (M6's takeover) ---------------------------------------
/// Claim a workspace for this connection's session, taking it over from
@@ -437,6 +461,126 @@ pub enum ControlRequest {
WorkspaceDetach {
id: String,
},
// ----- machine tree (the daemon-owned structure) ------------------------
// The semantic replacement for the retired opaque record verbs: instead of
// a whole-record `Put` (last-writer-wins the moment two clients write),
// each operation names its edit, the server validates it against the tree
// it owns, and everyone else hears an incremental
// [`ControlEvent::Layout`]. Positions cross as `u64` for the same reason
// `Search`'s limits do: a 32-bit server clamps rather than wraps.
/// The whole tree — every workspace, tab and pane record on the machine.
/// The full pull a client starts from before applying deltas.
MachineGet,
/// One workspace of the tree, whole. `NotFound` when the machine has no
/// such workspace; also the re-pull a client falls back to when it cannot
/// apply a delta.
WorkspaceTree {
workspace: WorkspaceId,
},
/// Create an empty workspace; its first tab arrives as its own operation.
/// Answers the newborn [`ReplyOk::WorkspaceTree`]. `workspace` lets the
/// client mint the id — a window names its workspace before any round trip
/// completes — and `None` has the daemon mint one, as before. A taken id
/// is refused, never adopted.
WorkspaceCreate {
name: Option<String>,
#[serde(default)]
workspace: Option<WorkspaceId>,
},
WorkspaceRename {
workspace: WorkspaceId,
name: Option<String>,
},
/// Forget a tree workspace and everything under it. Named `Remove` because
/// `WorkspaceDelete` was the retired record store's verb, and its serde
/// name stays burned.
WorkspaceRemove {
workspace: WorkspaceId,
},
/// Stamp a workspace as just-focused, for pickers ordered by recency.
WorkspaceTouch {
workspace: WorkspaceId,
},
WorkspaceSetActiveTab {
workspace: WorkspaceId,
tab: TabId,
},
/// Create a tab holding `pane` at position `at` (clamped; `None` appends).
/// `pane` is the seed for a pane the client already spawned over the pane
/// protocol — PTYs come from there, the tree only adopts them. `tab` is
/// the client-minted identity (see `WorkspaceCreate::workspace`); `None`
/// has the daemon mint one.
TabCreate {
workspace: WorkspaceId,
at: Option<u64>,
pane: PaneSeed,
#[serde(default)]
tab: Option<TabId>,
},
/// Close a tab. Answers [`ReplyOk::Panes`]: the pane ids that left the
/// tree, for the caller to kill — the tree does bookkeeping, not process
/// teardown.
TabClose {
workspace: WorkspaceId,
tab: TabId,
},
TabRename {
workspace: WorkspaceId,
tab: TabId,
name: Option<String>,
},
TabMove {
workspace: WorkspaceId,
tab: TabId,
to: u64,
},
/// Record the tab's sidebar repo group, as resolved by the client.
TabSetGroup {
workspace: WorkspaceId,
tab: TabId,
group: Option<String>,
},
/// Split the leaf holding `pane`; `new` seeds the freshly-spawned second
/// pane, `first` puts it on the upper/left side.
PaneSplit {
workspace: WorkspaceId,
pane: u64,
axis: Axis,
ratio: f32,
new: PaneSeed,
first: bool,
},
/// Close one pane, collapsing its split (or the whole tab when it was the
/// last pane). Answers [`ReplyOk::Panes`] like `TabClose`.
PaneClose {
workspace: WorkspaceId,
pane: u64,
},
/// Move a split's divider. `path` addresses the split from the tab root,
/// and a path the tree no longer has refuses rather than guessing.
PaneSetRatio {
workspace: WorkspaceId,
tab: TabId,
path: Vec<Side>,
ratio: f32,
},
/// tmux's `move-pane`: take `pane` out of where it is and re-split it next
/// to `to`, dissolving the source tab if that emptied it.
PaneMove {
workspace: WorkspaceId,
pane: u64,
to: u64,
axis: Axis,
first: bool,
},
/// The revival: rebind the leaf holding dead pane `old` to freshly-spawned
/// successor `new`, spending the old registry record.
PaneReplace {
workspace: WorkspaceId,
old: u64,
new: PaneSeed,
},
}
impl ControlRequest {
@@ -475,13 +619,29 @@ impl ControlRequest {
// spawns `wsl.exe -l -q`, which is slow enough to deserve the same
// budget as git.
Shells => Duration::from_secs(20),
WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => {
Duration::from_secs(10)
}
// An attach is bookkeeping plus at most one push to a peer that may
// be wedged — the push is `try`-shaped on the server, so this only
// has to cover a slow link, not a slow client.
WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10),
// Tree operations are a locked mutation plus one small file write,
// so the budget covers a slow disk, not slow work.
MachineGet
| WorkspaceTree { .. }
| WorkspaceCreate { .. }
| WorkspaceRename { .. }
| WorkspaceRemove { .. }
| WorkspaceTouch { .. }
| WorkspaceSetActiveTab { .. }
| TabCreate { .. }
| TabClose { .. }
| TabRename { .. }
| TabMove { .. }
| TabSetGroup { .. }
| PaneSplit { .. }
| PaneClose { .. }
| PaneSetRatio { .. }
| PaneMove { .. }
| PaneReplace { .. } => Duration::from_secs(10),
}
}
@@ -502,7 +662,7 @@ impl ControlRequest {
// ---------------------------------------------------------------------------
/// A reply to one request: the operation's value, or why it couldn't run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ControlReply {
#[serde(rename = "ok")]
Ok(ReplyOk),
@@ -523,7 +683,7 @@ impl ControlReply {
/// The successful half of a reply. One variant per result *shape*, not per
/// request — several requests answer `Unit`, and `Stat` and `WriteFile` both
/// answer `Meta`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplyOk {
Unit,
@@ -542,8 +702,6 @@ pub enum ReplyOk {
WatchId(u64),
/// [`ControlRequest::Shells`]: what that machine can launch.
Shells(ShellInventory),
/// The workspace store's payload (M5).
Json(serde_json::Value),
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
/// machine whose session was displaced, so the client that *did* the taking
/// can say so — only the notice going the other way is specified,
@@ -551,6 +709,17 @@ pub enum ReplyOk {
Attached {
took_over_from: Option<String>,
},
/// [`ControlRequest::MachineGet`]: the machine's whole tree. Boxed for the
/// same reason the tree replies below are: `ReplyOk` values live on the
/// dispatch stack, and the common replies must not pay for the big ones.
MachineTree(Box<Machine>),
/// One workspace of the tree ([`ControlRequest::WorkspaceTree`] /
/// [`ControlRequest::WorkspaceCreate`]).
WorkspaceTree(Box<crate::core::machine::Workspace>),
/// The tab an operation created ([`ControlRequest::TabCreate`]).
TabTree(Box<Tab>),
/// Pane ids an operation removed from the tree, for the caller to kill.
Panes(Vec<u64>),
}
/// An operation that could not be performed.
@@ -657,7 +826,7 @@ impl WireErrorKind {
// ---------------------------------------------------------------------------
/// An unsolicited server push, carried on [`kind::EVENT`] with `req_id == 0`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlEvent {
/// Filesystem changes, coalesced and deduplicated by the server over a
@@ -724,9 +893,35 @@ pub enum ControlEvent {
workspace: String,
by: String,
},
WorkspaceChanged {
id: String,
// `workspace_changed` was the retired record store's change notice; its
// serde name is burned along with the record verbs.
/// One incremental change to one tree workspace on this machine — the
/// push half of the machine-tree verbs. The writer never receives its own
/// operation back (origin exclusion, so an optimistically-applied edit is
/// not applied twice); every other client applies the delta to its live
/// window or, when it cannot, re-pulls the workspace with
/// [`ControlRequest::WorkspaceTree`].
///
/// `workspace` is the [`WorkspaceId`] rendered as a string, matching how
/// `Preempted` names its.
Layout {
workspace: String,
delta: LayoutDelta,
},
/// The server dropped at least one [`Layout`](Self::Layout) push for this
/// connection (its per-connection delta queue overflowed — see
/// [`crate::host::server::LAYOUT_EVENT_QUEUE`]): the peer's mirrors are
/// now wrong in a way no later delta repairs. So the client re-pulls the
/// machine whole and resyncs its windows — the identical recovery a delta
/// that will not apply already triggers, just server-announced instead of
/// stumbled into. Connection-wide, because drops happen at the queue, not
/// per workspace; the watch dialect's `WatchOverflow` is the precedent.
///
/// It arrives *instead of* the deltas the queue was still holding, not
/// ahead of them: those are older than the gap and already inside the tree
/// the client is about to pull, so delivering them after the pull would
/// walk the client backwards through history it has already left behind.
LayoutResync,
}
/// Where control events that are nobody's *local* business end up.
@@ -734,7 +929,7 @@ pub enum ControlEvent {
/// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and
/// `WatchOverflow` into the subscription that asked for them, because those
/// belong to a caller that is still holding a `WatchSub`. The rest —
/// `Preempted`, `PaneExited`, `AgentStatus`, `WorkspaceChanged` — are about a
/// `Preempted`, `PaneExited`, `AgentStatus`, `Layout` — are about a
/// *window*, and the host layer has no window.
///
/// A process-wide observer rather than a parameter on `connect_with` because
@@ -935,7 +1130,7 @@ fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> {
// ---------------------------------------------------------------------------
/// A control frame travelling client → server.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub enum ControlClientMsg {
Hello(ControlHello),
Request {
@@ -1042,7 +1237,7 @@ impl ControlClientMsg {
}
/// A control frame travelling server → client.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub enum ControlServerMsg {
HelloOk(ControlHelloOk),
Response {
@@ -1174,7 +1369,7 @@ pub const CLOSE_GRACE: Duration = Duration::from_millis(500);
/// A reply as the caller receives it: the value, plus the blob if the frame
/// carried one.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct ControlResponse {
pub reply: ReplyOk,
pub blob: Vec<u8>,
@@ -1981,13 +2176,6 @@ mod tests {
dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()],
},
ControlRequest::WatchClose { id: 7 },
ControlRequest::WorkspaceList,
ControlRequest::WorkspaceGet { id: "w1".into() },
ControlRequest::WorkspacePut {
id: "w1".into(),
json: serde_json::json!({ "tabs": [1, 2, 3] }),
},
ControlRequest::WorkspaceDelete { id: "w1".into() },
]
}
@@ -2034,7 +2222,6 @@ mod tests {
stderr: vec![0x00, 0xff, 0xfe, b'\n'],
})),
ControlReply::Ok(ReplyOk::WatchId(42)),
ControlReply::Ok(ReplyOk::Json(serde_json::json!({ "a": [1, null] }))),
ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no such file")),
ControlReply::Err(WireError::new(
WireErrorKind::PermissionDenied,
@@ -2082,7 +2269,6 @@ mod tests {
workspace: "w1".into(),
by: "other-laptop".into(),
},
ControlEvent::WorkspaceChanged { id: "w1".into() },
]
}
@@ -2747,16 +2933,6 @@ mod tests {
},
s(20),
),
(R::WorkspaceList, s(10)),
(R::WorkspaceGet { id: "w".into() }, s(10)),
(
R::WorkspacePut {
id: "w".into(),
json: serde_json::Value::Null,
},
s(10),
),
(R::WorkspaceDelete { id: "w".into() }, s(10)),
];
assert_eq!(
cases.len(),
@@ -2795,7 +2971,9 @@ mod tests {
assert_eq!(ok.home, "/home/me");
assert!(ok.has_feature(feature::CONTROL));
assert!(ok.has_feature(feature::HOST_RPC));
assert!(!ok.has_feature(feature::WORKSPACE_STORE));
// The retired record store's bit is a burned name and must
// never come back.
assert!(!ok.has_feature("workspace-store"));
}
other => panic!("expected HelloOk, got {other:?}"),
}
+176
View File
@@ -630,6 +630,12 @@ impl OutputGate {
/// PTY master, writer, child) so a single `Mutex` guards everything the reader
/// thread and the connection threads both touch.
struct PaneState {
/// The registry id of the pane this state belongs to — [`DaemonPane::id`],
/// duplicated here so the code paths that only ever see the state (the
/// signal appliers, [`DeathReporter::report`]) can name the pane when
/// publishing an observation to the machine tree
/// ([`crate::core::machine::observe_pane`]).
id: u64,
/// The replay ring: raw PTY bytes bounded to `RING_CAP`, segmented by the
/// geometry they were recorded under so `attach` can replay each stretch
/// at the width it was written for. Also the owner of the pane's current
@@ -812,7 +818,14 @@ impl DeathReporter {
}
let mut st = state.lock().unwrap();
st.alive = false;
let pane = st.id;
if shutting_down.load(Ordering::SeqCst) {
drop(st);
// Even a teardown the owner initiated is a death the tree must
// hear about: the record's `live == false` *is* the client-visible
// "awaiting revival" state, and it must not depend on which thread
// noticed the child go.
crate::core::machine::observe_pane(pane, |p| p.live = false);
return;
}
let subscribed = st.subscriber.is_some();
@@ -820,6 +833,7 @@ impl DeathReporter {
let _ = sub.send(DaemonMsg::Exited { code: None });
}
drop(st);
crate::core::machine::observe_pane(pane, |p| p.live = false);
// A subscriber's later detach reclaims the pane, so only an *unattached*
// death needs `on_dead` — and it fires at most once.
if subscribed {
@@ -870,6 +884,7 @@ impl DaemonPane {
let writer = pair.master.take_writer()?;
let state = Arc::new(Mutex::new(PaneState {
id,
ring: ReplayRing::new(size),
subscriber: None,
subscriber_epoch: 0,
@@ -979,6 +994,7 @@ impl DaemonPane {
};
let state = Arc::new(Mutex::new(PaneState {
id,
ring: ReplayRing::new(size),
subscriber: None,
subscriber_epoch: 0,
@@ -1240,7 +1256,24 @@ impl DaemonPane {
let probed_cwd = poll_now.then(&foreground_cwd_fn).flatten();
let tr1 = trace.then(std::time::Instant::now);
// Whether this chunk carries anything that *could*
// move a fact the tree records. An ordinary output
// chunk carries none of them, and must not pay for
// two snapshots and a compare per read: a build's
// worth of stdout is thousands of chunks and no
// facts at all.
let may_change_facts = signals.cwd.is_some()
|| !signals.agent_events.is_empty()
|| signals.notification.is_some()
// A prompt boundary: on Windows the agent
// identity rides the `133;C` capture, and
// everywhere the OSC 7 cwd travels with it.
|| !signals.shell.is_empty()
|| remote.is_some()
|| agent.is_some()
|| probed_cwd.is_some();
let mut st = state.lock().unwrap();
let facts_before = may_change_facts.then(|| observed_facts(&st));
st.ring.append(bytes);
if let Some(sub) = &st.subscriber {
// A send error just means the client is gone; ignore
@@ -1265,6 +1298,47 @@ impl DaemonPane {
if let Some(tr1) = tr1 {
tr_disp_t += tr1.elapsed();
}
// Publish what this chunk changed to the machine
// tree — outside the state lock, because the store
// broadcasts to every client of this machine and
// this thread's stalls are the child's write
// stalls. Gated twice over: `may_change_facts`
// keeps plain output free, and the compare below
// keeps a re-reported cwd from becoming a store
// mutation.
let pane = st.id;
// Read *with* the facts, not assumed: on Windows
// the exit monitor can report the death (flipping
// `alive`) while this thread is still draining
// ConPTY's buffered output, and the death report
// is latched — a "proof of life" published here
// after it would mark a dead pane live forever.
let alive = st.alive;
let facts_after = may_change_facts.then(|| observed_facts(&st));
drop(st);
if let (Some(before), Some(after)) = (facts_before, facts_after)
&& facts_changed(&before, &after)
{
let (cwd, agent) = after;
crate::core::machine::observe_pane(pane, |p| {
// An unknown cwd never clears a seeded one:
// the spawn directory in the record is
// better revival information than nothing.
if cwd.is_some() {
p.cwd = cwd;
}
// The agent fact applies wholesale — its
// `None` means the agent left the
// foreground, and a revival must not
// resume a session that already ended.
p.agent = agent;
// Output is proof of life — but only while
// the pane still is; see `alive` above.
if alive {
p.live = true;
}
});
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break, // EIO after hangup, etc.
@@ -1882,6 +1956,62 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 {
st.subscriber_epoch
}
/// The slice of a pane's state the machine tree records about it — the cwd a
/// successor would spawn in, and the agent facts a successor would resume.
/// Captured before and after a chunk's signal application so the (rare) change
/// is published outside the state lock; see the reader loop.
///
/// The cwd crosses as a `String` because the tree's records do (the dialect's
/// path rule); the loss, if any, happens here where it can be seen next to the
/// path that caused it.
fn observed_facts(st: &PaneState) -> (Option<String>, Option<crate::core::machine::AgentFacts>) {
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,
session_id: st.agent_session.as_ref().and_then(|s| s.session_id.clone()),
// The session's own argv record wins — it survives the chip clearing —
// with the identity poll's capture as the fallback until it is stamped.
launch_argv: st
.agent_session
.as_ref()
.and_then(|s| s.launch_argv.clone())
.or_else(|| st.agent_argv.clone()),
status: st.agent_session.as_ref().map(|s| s.status),
});
(cwd, agent)
}
/// Whether a chunk's facts are worth a store mutation.
///
/// The coarse agent status is deliberately **outside** the gate: it flips on
/// every hook event (working ↔ waiting ↔ idle), each of which would otherwise
/// rewrite `machine.json` from the PTY reader thread, and it is documented
/// display-only. It still *rides along* — whenever a load-bearing fact
/// changes, the record published carries the current status too.
fn facts_changed(
before: &(Option<String>, Option<crate::core::machine::AgentFacts>),
after: &(Option<String>, Option<crate::core::machine::AgentFacts>),
) -> bool {
before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref())
}
/// [`facts_changed`]'s agent half: equality over every field but the status.
/// Compared field by field rather than by cloning-and-blanking, because this
/// runs on the pane's reader thread and the argv it would clone is a `Vec` of
/// `String`s.
fn agent_facts_changed(
before: Option<&crate::core::machine::AgentFacts>,
after: Option<&crate::core::machine::AgentFacts>,
) -> bool {
match (before, after) {
(None, None) => false,
(Some(a), Some(b)) => {
a.agent != b.agent || a.session_id != b.session_id || a.launch_argv != b.launch_argv
}
_ => true,
}
}
/// Apply sniffed signals to the shared state and notify the subscriber of any cwd
/// / prompt change. Called with the state lock held.
fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
@@ -3649,6 +3779,9 @@ mod tests {
/// A fresh `PaneState` for the PTY-less state-machine tests.
fn test_state(alive: bool) -> PaneState {
PaneState {
// Unit tests publish observations nowhere (no store is installed
// in this process), so the id is never consulted.
id: 0,
ring: ReplayRing::new(ws(80, 24)),
subscriber: None,
subscriber_epoch: 0,
@@ -3662,6 +3795,49 @@ mod tests {
}
}
/// What the machine tree is told about a pane is exactly what a successor
/// needs: the cwd as a string, the session's own argv over the poll's
/// capture (the session record survives chip churn), and the coarse
/// status. No agent, no facts — a revival must not resume a session that
/// was never there.
#[test]
fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
let mut st = test_state(true);
assert_eq!(observed_facts(&st), (None, None));
st.cwd = Some(PathBuf::from("/work/api"));
st.agent = Some(CLIAgent::Claude);
st.agent_argv = Some(vec!["claude".into()]);
st.agent_session = Some(AgentSessionState {
status: AgentStatus::Working,
session_id: Some("sess-1".into()),
launch_argv: Some(vec!["claude".into(), "--model".into(), "opus".into()]),
..Default::default()
});
let (cwd, agent) = 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);
assert_eq!(agent.session_id.as_deref(), Some("sess-1"));
assert_eq!(
agent.launch_argv.as_deref(),
Some(&["claude".to_string(), "--model".into(), "opus".into()][..]),
"the session's own argv outranks the identity poll's capture"
);
assert_eq!(agent.status, Some(AgentStatus::Working));
// The poll's capture is the fallback until the session stamps its own.
st.agent_session = None;
let (_, agent) = observed_facts(&st);
assert_eq!(
agent.unwrap().launch_argv.as_deref(),
Some(&["claude".to_string()][..])
);
}
/// The full daemon-side rich-status path: sentinel OSC events sniffed out
/// of the byte stream drive the pane's session state machine, identify the
/// agent when argv detection hasn't, and stream every change to the
+28 -13
View File
@@ -51,6 +51,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
///
/// ## History
///
/// - **v4** — the daemon serves the machine tree. `tty7 --daemon` now runs
/// the shared `run_daemon`: a control listener (carrying the daemon-owned
/// workspace tree) beside the pane listener. No pane frame changed, so by
/// the letter of the rule above this is additive — but the *service* is
/// not: a v3 daemon has no control socket at all, and a GUI from this
/// build that silently adopted one would connect its control link into the
/// void forever — every window hydrating from a tree that never answers,
/// which renders as empty windows with no error anywhere. The bump routes
/// that meeting into `ensure_running`'s existing keep-or-restart prompt,
/// where "restart the background service" is the fix.
/// - **v3** — the [`control`](super::control) dialect (kinds 60-63) and
/// [`DaemonVersion::features`]. By the rule above this is *additive* and
/// would not earn a bump on its own: a v2 daemon meeting a control frame
@@ -66,7 +76,7 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
/// downgrade (a v2 GUI spawns the pane, a v1 GUI later attaches to it), but
/// loses it silently. The handshake now catches that skew and asks.
/// - **v1** — the dialect at the time versioning landed.
pub const PROTOCOL_VERSION: u32 = 3;
pub const PROTOCOL_VERSION: u32 = 4;
/// Capability string for [`DaemonVersion::features`]: this daemon records
/// which workspace each pane was spawned for and reports it in `List`'s
@@ -93,13 +103,17 @@ pub struct DaemonVersion {
/// every user whose daemon happens to predate it.
#[serde(default)]
pub features: Vec<String>,
/// Identity of this daemon *process*, minted once at startup. Pane ids are
/// only meaningful within one daemon process — after a restart the numbers
/// start over from 1 and land on unrelated shells — so a client that
/// persists pane ids records this next to them and treats a mismatch as
/// "every saved id is stale" (see `Workspace::daemon_instance`). Empty for
/// daemons that predate the field; the remote `tty7-server` announces the
/// same identity through its control hello.
/// Identity of this daemon *process*, minted once at startup — the same
/// identity the control hello announces
/// ([`ControlHelloOk::instance`](crate::daemon::control::ControlHelloOk::instance)),
/// which is what reconnect logic actually consults to tell "the link
/// blinked" from "a different process answers now". PTYs die with the
/// process, so a changed instance means every previously live pane is
/// gone; the machine tree records the same fact per pane (`load_machine`
/// clears every `live` flag on open), and a daemon carrying a tree seeds
/// its pane ids *past* everything the tree names rather than restarting
/// from 1, so a stale id can never alias a new shell. Empty for daemons
/// that predate the field — "unknown", never "restarted".
#[serde(default)]
pub instance: String,
}
@@ -113,10 +127,11 @@ impl DaemonVersion {
DaemonVersion {
protocol: PROTOCOL_VERSION,
build: env!("CARGO_PKG_VERSION").to_string(),
// The local session daemon speaks the pane protocol only. The
// control dialect is served by `tty7-server`, which advertises
// `control` / `host-rpc` itself; claiming them here would make the
// GUI open a control connection this process cannot answer.
// This reply describes the *pane* socket only. The control
// dialect lives on the daemon's separate control socket, whose
// own `ControlHelloOk` announces `control` / `host-rpc` /
// `machine-tree` for itself; claiming them here would say the
// pane socket speaks frames it does not.
//
// `pane-owner` *is* a pane-protocol capability, so every process
// serving panes from this build advertises it.
@@ -2630,7 +2645,7 @@ mod tests {
#[test]
fn the_local_daemon_does_not_claim_the_control_dialect() {
let v = DaemonVersion::current();
assert_eq!(v.protocol, 3);
assert_eq!(v.protocol, 4);
assert!(
!v.has_feature(crate::daemon::control::feature::CONTROL),
"the session daemon must not advertise a dialect it cannot serve"
+1 -1
View File
@@ -142,7 +142,7 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(240);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteChannel {
/// Host RPC, the workspace store, event pushes — `daemon::control`.
/// Host RPC, the machine tree, event pushes — `daemon::control`.
#[default]
Control,
/// One pane: `Spawn`/`Attach`/`Input`/`Output` — `daemon::protocol`.
+250 -7
View File
@@ -46,6 +46,36 @@ impl Registry {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
/// Never mint an id `machine`'s tree already names — see the caller in
/// [`run`] for the aliasing failures this closes. The registry and the
/// leaves are checked both: a pane record can outlive its leaf briefly,
/// and either one aliased is one too many.
fn seed_ids_past(&self, machine: &crate::core::machine::Machine) {
let max = machine
.panes
.iter()
.map(|p| p.id)
.chain(
machine
.workspaces
.iter()
.flat_map(|w| w.tabs.iter())
.flat_map(|t| t.root.pane_ids()),
)
.max()
.unwrap_or(0);
// Saturating: a tree (or a hostile seed) naming u64::MAX must not
// panic the daemon at startup. The counter parking at the ceiling is
// a bounded absurdity; overflowing is a dead process.
let next = max.saturating_add(1);
// fetch_max rather than store: harmless today (this runs before any
// spawn), but a seed must never move the counter backwards.
let before = self.next_id.fetch_max(next, Ordering::Relaxed);
if next > before {
log::info!("pane ids start at {next} (the tree names panes up to {max})");
}
}
fn insert(&self, pane: Arc<DaemonPane>) {
self.panes.lock().unwrap().insert(pane.id, pane);
}
@@ -87,6 +117,59 @@ impl Registry {
}
}
/// How often the orphan sweep looks, which doubles as its grace period: a pane
/// is only reported after it has been unreferenced across two consecutive
/// looks, so a freshly-spawned pane whose adopting operation is still in
/// flight is never flagged.
const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
/// Periodically report live panes the machine tree does not reference.
///
/// **Log-only, on purpose.** An unreferenced pane is not proof of a leak:
/// a native-SSH pane opened inside a *remote* workspace's window runs in this
/// (the client's) daemon while belonging to the other machine's tree, so it is
/// unreferenced here by design — and a reclaim would kill a session the user
/// is looking at. Until the tree provably references everything legitimate,
/// the sweep's job is to make leaks observable, not to act on them; killing
/// can be layered on once the log has shown the false-positive rate is zero.
fn spawn_orphan_sweep(registry: Arc<Registry>) {
let spawned = std::thread::Builder::new()
.name("tty7-orphan-sweep".into())
.spawn(move || {
let mut previous: std::collections::HashSet<u64> = std::collections::HashSet::new();
loop {
std::thread::sleep(ORPHAN_SWEEP_INTERVAL);
// No tree served (a pane-only daemon) means no opinion.
let Some(store) = crate::core::machine::observed_store() else {
continue;
};
let machine = store.machine();
let referenced: std::collections::HashSet<u64> = machine
.workspaces
.iter()
.flat_map(|w| w.tabs.iter())
.flat_map(|t| t.root.pane_ids())
.collect();
let orphans: std::collections::HashSet<u64> = registry
.list()
.into_iter()
.filter(|p| p.alive && !referenced.contains(&p.pane_id))
.map(|p| p.pane_id)
.collect();
for id in orphans.intersection(&previous) {
log::info!(
"pane {id} is running but no workspace tree references it \
(kept; the sweep only reports see spawn_orphan_sweep)"
);
}
previous = orphans;
}
});
if let Err(e) = spawned {
log::warn!("could not start the orphan-pane sweep: {e}");
}
}
/// Resolve a pane id to its live native-SSH connection, for the SFTP control
/// handlers. Errors (as a client-facing string) when the pane is unknown or isn't
/// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or
@@ -103,6 +186,82 @@ fn ssh_connection_for(
})
}
/// Run the *whole* daemon — panes **and** control — until killed. The one
/// entry point behind both `tty7 --daemon` and `tty7-server --daemon`.
///
/// Local and remote are deliberately the same shape: a machine is a machine,
/// whether the client sits on it or an ocean away, and the design's terminal
/// state is "one machine = one daemon = one workspace tree". That tree is
/// served over the control dialect, so the *local* daemon has to speak it too —
/// which is why this lives here rather than staying a `tty7-server` detail.
///
/// Control comes up first, and on its own thread: a machine that cannot host
/// panes (no pty, a locked-down container) should still be able to back a
/// workspace's files, so a control failure is logged and stepped over rather
/// than being fatal. The pane listener then owns this thread until the process
/// is killed, exactly as [`run`] always has.
///
/// Both platforms serve it, over the transport each one's pane socket already
/// uses: a Unix-domain socket gated by its file permissions, or a loopback
/// `TcpListener` gated by the token in a user-private marker file. The tree is
/// what a client's layout *is* now, so a platform without a control listener is
/// a platform where tabs do not come back — which is not a difference a build
/// gets to have.
pub fn run_daemon() -> anyhow::Result<()> {
// Reported on **stderr**, not only the log: a headless server's log file is
// off unless `TTY7_LOG` asks for it, and the bound path is this daemon's
// one observable answer to "where do I connect". The remote-router test
// reads this exact line back to prove the client's derivation and the
// server's bind agree, so the prefix is part of the contract.
#[cfg(any(unix, windows))]
match crate::host::server::spawn_control_listener_with(
crate::host::local::LocalHost::shared(),
control_services(),
) {
Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()),
Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"),
}
#[cfg(not(any(unix, windows)))]
log::info!("no control listener on this platform; serving panes only");
run()
}
/// What this machine offers over a control connection, beyond its filesystem.
///
/// The machine tree is why a daemon serves control at all: the workspace
/// list, the tab/pane tree and each pane's facts live on **the machine the
/// panes run on**, so that every client of this machine — the GUI on it, a
/// laptop across the world — sees the same thing. Clients keep only their own
/// view state.
///
/// A machine with no home directory to place the file in still serves files
/// and panes — it simply omits `machine-tree` from its capabilities, and
/// clients see the same "does not serve the machine tree" answer a server
/// without one has always given.
pub fn control_services() -> crate::host::server::Services {
use crate::core::machine::MachineStore;
// Reported on stderr as well as the log, like the socket line in
// [`run_daemon`]: on a headless box the log file is off by default, and
// "does this daemon actually serve the tree" is the first question a
// capability mismatch raises.
match MachineStore::shared() {
Ok(machine) => {
eprintln!("machine tree at {}", machine.path().display());
// From here on the pane server's own observations — OSC 7 cwds,
// agent identities, deaths — land on the tree's pane records, so
// what a client revives from is what the machine saw, not what
// some client last remembered to write.
crate::core::machine::publish_observations(&machine);
crate::host::server::Services::with_machine(machine)
}
Err(e) => {
eprintln!("no machine tree ({e}); serving files and panes only");
crate::host::server::Services::none()
}
}
}
/// Run the daemon: bind the socket and serve connections forever. Returns `Err`
/// only on a fatal setup failure (bad socket path, bind error); the accept loop
/// itself runs until the process is killed.
@@ -144,6 +303,29 @@ pub fn run() -> anyhow::Result<()> {
#[cfg(unix)]
serve_sigterm(registry.clone());
// Pane ids must never alias across restarts: the persisted tree still
// names the previous process's panes, and a fresh process minting from 1
// would hand a new shell an id some dead leaf claims — at which point the
// record's `live` flag flips back on for the wrong pane, revival stalls on
// "pane N is already part of this machine's tree", and a window attaching
// by the stale id steals an unrelated workspace's stream. Starting past
// everything the tree knows makes the id a name, not a slot.
if let Some(store) = crate::core::machine::observed_store() {
registry.seed_ids_past(&store.machine());
// And let the store ask *us* whether a seeded pane is still alive at
// registration time — the pane that dies between its spawn and its
// adopting operation would otherwise be filed `live: true` with its
// death observation already dropped, and nothing left to flip it.
let probe = registry.clone();
store.set_liveness_probe(Arc::new(move |id| {
probe.get(id).is_some_and(|pane| pane.info().alive)
}));
}
// Now that the tree has an owner filling it, the daemon can *see* panes
// nothing references any more — but it only reports them, deliberately.
spawn_orphan_sweep(registry.clone());
for stream in listener.incoming() {
match stream {
Ok(stream) => {
@@ -209,14 +391,31 @@ fn serve_sigterm(registry: Arc<Registry>) {
if unsafe { libc::sigwait(&set, &mut sig) } == 0 {
log::info!("daemon shutting down on SIGTERM");
registry.drain_and_kill();
transport::remove_stale_endpoint();
crate::daemon::pidfile::remove();
on_shutdown();
std::process::exit(0);
}
})
.ok();
}
/// What every daemon exit owes the next one.
///
/// The tree's observations first: a pane's cwd and its agent session are
/// deferred by design (`machine::Persist::Soon`) and are exactly what the next
/// launch revives that pane from, so the last couple of seconds of them are
/// worth one write on the way out. Then the endpoint markers — **both**
/// dialects', since on Windows each listener has its own — and the pidfile, so
/// nothing left on disk points at a process that is gone.
fn on_shutdown() {
if let Some(store) = crate::core::machine::observed_store() {
store.flush();
}
transport::remove_stale_endpoint();
#[cfg(windows)]
crate::host::server::remove_control_endpoint();
crate::daemon::pidfile::remove();
}
/// Handle one connection start-to-finish. Reads the opening `ClientMsg` and
/// dispatches; for the streaming variants it then runs [`stream_pane`].
fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
@@ -366,8 +565,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
// place the daemon terminates itself.
log::info!("daemon shutting down on client request");
registry.drain_and_kill();
transport::remove_stale_endpoint();
crate::daemon::pidfile::remove();
on_shutdown();
std::process::exit(0);
}
@@ -774,6 +972,45 @@ mod tests {
assert_eq!(reg.alloc_id(), 3);
}
/// Pane ids are names, not slots: a fresh process must never re-mint an id
/// the persisted tree still references, or a stale leaf aliases a new
/// shell — the tree marks the wrong pane live, revival's re-registration
/// is refused forever, and an attach by the old id steals another
/// workspace's stream.
#[test]
fn pane_ids_never_alias_what_the_persisted_tree_references() {
use crate::core::machine::{MachineStore, PaneSeed};
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join("machine.json"));
let ws = store.workspace_create(None, None, None).unwrap();
store
.tab_create(ws.id, None, PaneSeed::bare(7), None, None)
.unwrap();
let reg = Registry::new();
reg.seed_ids_past(&store.machine());
assert_eq!(reg.alloc_id(), 8, "past the highest id the tree names");
// A seed can only move the counter forward.
reg.seed_ids_past(&store.machine());
assert_eq!(reg.alloc_id(), 9);
}
/// A tree naming `u64::MAX` (a corrupted file, an absurd client seed)
/// must not panic the daemon at startup: `max + 1` overflowed in a debug
/// build, taking every pane on the machine down with a bookkeeping add.
#[test]
fn a_tree_naming_the_maximum_pane_id_does_not_panic_the_seed() {
use crate::core::machine::{Machine, PaneRecord};
let reg = Registry::new();
reg.seed_ids_past(&Machine {
workspaces: Vec::new(),
panes: vec![PaneRecord::new(u64::MAX)],
});
// The counter parks at the ceiling — a bounded absurdity, not a crash.
assert_eq!(reg.alloc_id(), u64::MAX);
}
#[test]
fn empty_registry_get_remove_list_are_empty() {
let reg = Registry::new();
@@ -1006,11 +1243,9 @@ mod tests {
let (client, server) = UnixStream::pair().unwrap();
let writer = spawn_writer(rx, server, Arc::new(crate::daemon::pane::OutputGate::new()));
// Kill the client end first, then hand the writer a message: the
// Kill the client end first, then hand the writer messages: an
// encode hits a broken pipe and the thread must bail on its own.
drop(client);
tx.send(DaemonMsg::Output(b"into the void".to_vec()))
.unwrap();
// Bounded poll rather than a bare `join()`: the sender stays alive
// for the whole wait, so only the write-failure path can finish the
@@ -1020,8 +1255,16 @@ mod tests {
// running the whole suite in parallel can leave this thread
// unscheduled for seconds. A tight bound turns that into a flake
// that says nothing about the behaviour under test.
//
// Kept fed rather than sent one message: the first write into a
// freshly-closed socket can *succeed* (the kernel has not
// processed the peer's close yet, especially under load), and a
// writer that swallowed it would park in `recv()` for the rest of
// the deadline. Only a later write is guaranteed to see the
// broken pipe, so the loop keeps offering them.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while !writer.is_finished() && std::time::Instant::now() < deadline {
let _ = tx.send(DaemonMsg::Output(b"into the void".to_vec()));
thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
-11
View File
@@ -73,17 +73,6 @@ pub fn take_mismatched_daemon() -> Option<MismatchedDaemon> {
/// identity of a daemon that is no longer the one answering.
static LOCAL_DAEMON: std::sync::Mutex<Option<DaemonVersion>> = std::sync::Mutex::new(None);
/// The serving daemon's process identity, when it reports one. `None` means
/// "unknown" (an older daemon, or nothing running) — callers must treat that
/// as "no instance check possible", never as a mismatch.
pub fn local_daemon_instance() -> Option<String> {
let guard = LOCAL_DAEMON.lock().ok()?;
guard
.as_ref()
.map(|v| v.instance.clone())
.filter(|i| !i.is_empty())
}
/// Whether the serving daemon advertises `feature`
/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when
/// nothing is known — the safe answer, because every capability gated on this
+141 -13
View File
@@ -23,6 +23,12 @@
//! `authenticate` rejects any connection that doesn't match — so only a process
//! that could read the user-private file gets in. See [`imp_windows`].
//!
//! One daemon serves two dialects on two listeners — panes and control — which
//! on Unix are two socket files and here are two port files, each with its own
//! ephemeral port and its own token (`bind_endpoint`). The control listener's is
//! `control.port`; [`crate::host::server`] owns it, since that is where the
//! dialect lives.
//!
//! All endpoint state lives under the (config-dir-aware) config directory, so
//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform.
@@ -376,8 +382,16 @@ mod imp_windows {
/// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG:
/// unguessable without reading the (user-private) port file, so possessing it
/// proves the connecting process runs as the same user.
const TOKEN_LEN: usize = 32;
type Token = [u8; TOKEN_LEN];
pub const TOKEN_LEN: usize = 32;
pub type Token = [u8; TOKEN_LEN];
/// The pane dialect's endpoint marker.
///
/// Named, because one daemon serves two dialects on two listeners — the
/// same shape it has on Unix, where they are two socket files — and each
/// records its own port and mints its own token. See
/// [`bind_endpoint`].
const PANE_PORT_FILE: &str = "daemon.port";
/// This daemon's auth token, minted once at [`bind`] and checked by
/// [`authenticate`] on every accepted connection. A process global because the
@@ -446,12 +460,21 @@ mod imp_windows {
/// "endpoint exists" marker, and — being under the user-private config dir —
/// its contents (the token) are readable only by the same user.
fn port_path() -> Option<PathBuf> {
config::config_path("daemon.port")
port_path_named(PANE_PORT_FILE)
}
/// [`port_path`] for any of this daemon's endpoints.
pub fn port_path_named(file: &str) -> Option<PathBuf> {
config::config_path(file)
}
/// Read the recorded loopback port + token, if the port file exists and parses.
fn read_port_file() -> Option<(u16, Token)> {
let path = port_path()?;
read_port_file_named(PANE_PORT_FILE)
}
fn read_port_file_named(file: &str) -> Option<(u16, Token)> {
let path = port_path_named(file)?;
let contents = std::fs::read_to_string(path).ok()?;
parse_port_file(&contents)
}
@@ -491,6 +514,13 @@ mod imp_windows {
authenticate_with(stream, expected)
}
/// [`authenticate`] for a connection on one of this daemon's *other*
/// endpoints, whose token its listener holds rather than reading from the
/// process global.
pub fn check_endpoint_token(stream: &mut Stream, expected: &Token) -> io::Result<()> {
authenticate_with(stream, expected)
}
/// Pure core of [`authenticate`]: read a token off `reader` and compare it to
/// `expected`. Split out so the handshake is testable without a live daemon or
/// the process-global token.
@@ -531,8 +561,27 @@ mod imp_windows {
/// this daemon's freshly-minted auth token — in the port file so the GUI can
/// find *and* authenticate to it. Ensures the config dir exists first.
pub fn bind() -> anyhow::Result<Listener> {
let path = port_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve daemon port path (no config dir)"))?;
// Mint the pane dialect's token once for this daemon's lifetime;
// `authenticate` checks against the same value.
let token = *DAEMON_TOKEN.get_or_init(make_token);
let (listener, _) = bind_named(PANE_PORT_FILE, token)?;
Ok(listener)
}
/// [`bind`] for a second dialect in this same daemon: its own ephemeral
/// port, its own token, its own marker file beside `daemon.port`.
///
/// Answers the token as well as the listener, because a second endpoint has
/// nowhere process-global to keep it — its accept loop holds it and checks
/// each connection with [`check_endpoint_token`]. One token per endpoint, so
/// a client that learned one cannot present it to the other.
pub fn bind_endpoint(file: &str) -> anyhow::Result<(Listener, Token)> {
bind_named(file, make_token())
}
fn bind_named(file: &str, token: Token) -> anyhow::Result<(Listener, Token)> {
let path = port_path_named(file)
.ok_or_else(|| anyhow::anyhow!("could not resolve {file} path (no config dir)"))?;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
@@ -544,19 +593,43 @@ mod imp_windows {
.local_addr()
.map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))?
.port();
// Mint the token once for this daemon's lifetime; `authenticate` checks
// against the same value. Written to the port file so a client that can
// read it (same user) can present it back.
let token = DAEMON_TOKEN.get_or_init(make_token);
let contents = format!("{port}\n{}", encode_token(token));
// Written to the marker file so a client that can read it (same user)
// can present it back.
let contents = format!("{port}\n{}", encode_token(&token));
std::fs::write(&path, contents)
.map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?;
Ok(listener)
Ok((listener, token))
}
/// [`connect`] to one of the daemon's other endpoints, presenting the token
/// its marker file records. `NotFound` means nothing is listening there — the
/// same "nobody home" every caller treats as "not running".
pub fn connect_endpoint(file: &str) -> io::Result<Stream> {
let (port, token) = read_port_file_named(file)
.filter(|(p, _)| *p != 0)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no {file} file")))?;
let mut stream = TcpStream::connect(loopback(port))?;
tune(&stream);
stream.write_all(&token)?;
Ok(stream)
}
/// Remove another endpoint's marker file. Best effort, like
/// [`remove_stale_endpoint`].
pub fn remove_endpoint(file: &str) {
if let Some(path) = port_path_named(file) {
let _ = std::fs::remove_file(path);
}
}
/// A human-readable description of the endpoint, for log messages.
pub fn endpoint_display() -> String {
match read_port_file() {
endpoint_display_named(PANE_PORT_FILE)
}
/// [`endpoint_display`] for another of this daemon's endpoints.
pub fn endpoint_display_named(file: &str) -> String {
match read_port_file_named(file) {
Some((port, _)) => format!("127.0.0.1:{port}"),
None => "127.0.0.1:<unbound>".to_string(),
}
@@ -665,6 +738,61 @@ mod imp_windows {
bad.join().unwrap();
}
/// The daemon's *second* endpoint — the control dialect's, bound by
/// [`crate::host::server`] — is a separate port with a separate token,
/// recorded in a separate file. Two listeners, two boundaries: a client
/// that learned the pane endpoint's token has not thereby been given the
/// one behind which the whole workspace tree lives.
#[test]
fn a_second_endpoint_gets_its_own_port_and_token() {
// The name `host::server` uses; spelled out rather than imported so
// the transport does not depend on the dialect above it.
const CONTROL: &str = "control.port";
let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
config::set_config_dir(dir);
remove_endpoint(CONTROL);
let (listener, token) = bind_endpoint(CONTROL).expect("bind the second endpoint");
let port = listener.local_addr().unwrap().port();
let recorded =
std::fs::read_to_string(port_path_named(CONTROL).unwrap()).expect("marker file");
let (file_port, file_token) = parse_port_file(&recorded).expect("marker file parses");
assert_eq!(file_port, port, "the file records the port actually bound");
assert_eq!(
file_token, token,
"and the token the listener will check for"
);
// A client that could read the file gets in — that read is the whole
// proof of same-user, which is what filesystem permissions give the
// Unix socket for free.
let good = std::thread::spawn(move || connect_endpoint(CONTROL).unwrap());
let (mut server_side, _) = listener.accept().unwrap();
assert!(check_endpoint_token(&mut server_side, &token).is_ok());
let _keep = good.join().unwrap();
// Anything else is refused before a frame is parsed — including the
// other endpoint's token, which is why they are minted separately.
let mut foreign = token;
foreign[0] ^= 0xff;
let bad = std::thread::spawn(move || {
let mut s = TcpStream::connect(loopback(port)).unwrap();
let _ = s.write_all(&foreign);
});
let (mut server_side, _) = listener.accept().unwrap();
assert_eq!(
check_endpoint_token(&mut server_side, &token)
.unwrap_err()
.kind(),
io::ErrorKind::PermissionDenied
);
bad.join().unwrap();
remove_endpoint(CONTROL);
}
/// Full wiring over the real config-dir path: `bind` writes a parseable
/// `<port>\n<token>` file and seeds the process token, and the public
/// `authenticate` (which reads that process token) then accepts a client
+1 -1
View File
@@ -180,7 +180,7 @@ impl RemoteHost {
}
/// The underlying connection, for callers that need to speak control
/// directly (the workspace store, once it exists).
/// directly (the machine-tree verbs).
pub fn client(&self) -> &Arc<ControlClient> {
&self.client
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -26,10 +26,5 @@ tty7-core = { path = "../tty7-core" }
# Sandboxes for the suite: an empty directory per case, removed on drop. The
# server is on this machine, so a local temp dir is a path in its namespace.
tempfile = "3"
# Workspace records cross the control wire as opaque JSON, so
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
# itself still depends on nothing but `tty7-core`.
serde_json.workspace = true
[lints]
workspace = true
+12 -47
View File
@@ -144,21 +144,13 @@ fn main() -> ExitCode {
}
/// Serve panes and control connections until killed.
///
/// The whole of it lives in [`tty7_core::daemon::server::run_daemon`], shared
/// verbatim with `tty7 --daemon`: local and remote machines run the identical
/// daemon, which is what makes "one machine = one daemon = one workspace tree"
/// a fact rather than a convention.
fn run_daemon() -> ExitCode {
// Control first, and on its own thread: a machine that cannot host panes
// (no pty, a locked-down container) should still be able to back a remote
// workspace's files, so a control failure is reported and stepped over
// rather than being fatal.
#[cfg(unix)]
match tty7_core::host::server::spawn_control_listener_with(
tty7_core::host::local::LocalHost::shared(),
control_services(),
) {
Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()),
Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"),
}
if let Err(e) = tty7_core::daemon::server::run() {
if let Err(e) = tty7_core::daemon::server::run_daemon() {
eprintln!("tty7-server: daemon exited with error: {e}");
return ExitCode::FAILURE;
}
@@ -226,7 +218,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
// the same rule `bridge_panes` follows one dialect over,
// and for the same reason. Two `--stdio` sessions both
// falling through to serving in-process would each hold
// their own `WorkspaceStore` over the one file, and
// their own `MachineStore` over the one file, and
// `persist` writes the whole document: the second to save
// silently drops the first's changes. Their attachment
// registries would be separate too, which makes design
@@ -266,7 +258,11 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
// Takes stdin/stdout away from the rest of the process before a
// single frame is written — see `StdioDuplex::take`.
let link = StdioDuplex::take()?;
server::serve_with(link, LocalHost::shared(), control_services())
server::serve_with(
link,
LocalHost::shared(),
tty7_core::daemon::server::control_services(),
)
}
}
}
@@ -377,37 +373,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
Ok(())
}
/// What this machine offers over a control connection, beyond its filesystem.
///
/// The workspace store is the reason this binary exists on a remote box at all:
/// the workspace list, the tab/pane tree and each pane's cwd live on
/// **the machine the panes run on**, so that connecting from a different laptop
/// shows the same thing. The client's `session.json` keeps only its own view
/// state.
///
/// A machine with no home directory to place the file in still serves files and
/// panes — it simply says `workspace-store` is not among its capabilities, and
/// clients see the same "does not serve the workspace store" answer a
/// pre-M5 server gives.
fn control_services() -> tty7_core::host::server::Services {
use tty7_core::core::workspace_store::WorkspaceStore;
match WorkspaceStore::shared() {
Ok(store) => {
log_stderr(format_args!(
"workspace store at {}",
store.path().display()
));
tty7_core::host::server::Services::with_workspaces(store)
}
Err(e) => {
log_stderr(format_args!(
"no workspace store ({e}); serving files and panes only"
));
tty7_core::host::server::Services::none()
}
}
}
/// `--flag <value>` or `--flag=<value>`, first occurrence wins.
/// Whether a failed control probe may start the machine's daemon.
///
+522
View File
@@ -0,0 +1,522 @@
//! The machine-owned workspace tree, end to end against a real `tty7-server`
//! child process.
//!
//! The client is the shipped `ControlClient`, the wire is the control dialect
//! over real pipes, and the server is the shipped binary owning its tree in a
//! file. What the process boundary buys here specifically:
//!
//! | | Why an in-process store would not do |
//! |---|---|
//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership |
//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up |
//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push |
//!
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
//! another's leftovers and nothing here can touch a developer's real tree.
// Unix-only: the server under test is a `--stdio` child, and the two-client
// case stands up a control socket.
#![cfg(unix)]
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tty7_core::core::machine::{Axis, LayoutDelta, MACHINE_FILE, PaneNode, PaneSeed};
use tty7_core::daemon::control::{
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, WorkspaceId,
feature,
};
/// The child, and the only way to end it — a process-backed link is reaped by
/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`.
struct ServerProcess {
child: Mutex<Option<Child>>,
}
impl LinkShutdown for ServerProcess {
fn shutdown_link(&self) -> io::Result<()> {
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
return Ok(());
};
let _ = child.kill();
let _ = child.wait();
Ok(())
}
}
/// One connected client: the RPC channel, plus everything the server pushed.
struct Client {
control: ControlClient,
events: Arc<Mutex<Vec<ControlEvent>>>,
peer_features: Vec<String>,
}
impl Client {
/// Wait for a `Layout` delta about `workspace` matching `want`, or fail
/// saying what did arrive. Polled because a push and the reply that caused
/// it race by construction.
fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) {
let key = workspace.to_string();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let seen = self
.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if seen.iter().any(|e| {
matches!(e, ControlEvent::Layout { workspace: w, delta } if *w == key && want(delta))
}) {
return;
}
assert!(
Instant::now() < deadline,
"no matching Layout delta for {key}; saw {seen:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn delta_count(&self) -> usize {
self.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|e| matches!(e, ControlEvent::Layout { .. }))
.count()
}
}
/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and
/// connect a client to it. `--serve` for the same reason as everywhere else in
/// these tests: a developer's real daemon must never be bridged into.
fn connect(data_dir: &Path, token: &str) -> Client {
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.args(["--stdio", "--serve"])
.env("TTY7_DATA_DIR", data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("could not start tty7-server --stdio");
let stdout = child.stdout.take().expect("piped");
let stdin = child.stdin.take().expect("piped");
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
child: Mutex::new(Some(child)),
});
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let control = ControlClient::connect_with(
stdout,
stdin,
Some(closer),
&ControlHello::host_rpc(token, "test-client"),
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
)
.expect("handshake with tty7-server --stdio");
let peer_features = control.hello().features.clone();
Client {
control,
events,
peer_features,
}
}
fn data_dir() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
fn machine_file(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join(MACHINE_FILE)
}
fn seed(pane: u64, cwd: &str) -> PaneSeed {
PaneSeed {
pane,
cwd: Some(cwd.to_string()),
ssh_spec: None,
agent: None,
}
}
// ---------------------------------------------------------------------------
/// The capability bit is the client's cue that the tree verbs are worth a
/// round trip, and it has to reflect what the shipped binary wired up.
#[test]
fn the_server_advertises_the_machine_tree() {
let dir = data_dir();
let client = connect(dir.path(), "cap");
assert!(
client
.peer_features
.iter()
.any(|f| f == feature::MACHINE_TREE),
"features were {:?}",
client.peer_features
);
}
/// The semantic operations against a real server, and the tree ends up in a
/// file that server owns. This is "the daemon owns the structure" as a
/// syscall someone else made, not as a diagram.
#[test]
fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
let dir = data_dir();
let client = connect(dir.path(), "ops");
// Build: a workspace, a tab, a split.
let ws = match client
.control
.call(ControlRequest::WorkspaceCreate {
name: Some("api".into()),
workspace: None,
})
.expect("create workspace")
{
ReplyOk::WorkspaceTree(ws) => *ws,
other => panic!("expected WorkspaceTree, got {other:?}"),
};
let tab = match client
.control
.call(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
pane: seed(1, "/home/me/proj"),
tab: None,
})
.expect("create tab")
{
ReplyOk::TabTree(tab) => *tab,
other => panic!("expected TabTree, got {other:?}"),
};
client
.control
.call(ControlRequest::PaneSplit {
workspace: ws.id,
pane: 1,
axis: Axis::Vertical,
ratio: 0.3,
new: seed(2, "/home/me/proj/sub"),
first: false,
})
.expect("split");
// Read back through the wire.
let machine = match client.control.call(ControlRequest::MachineGet).unwrap() {
ReplyOk::MachineTree(m) => *m,
other => panic!("expected MachineTree, got {other:?}"),
};
assert_eq!(machine.workspaces.len(), 1);
assert_eq!(machine.workspaces[0].tabs[0].id, tab.id);
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1, 2]);
assert_eq!(machine.panes.len(), 2);
assert!(
machine.panes.iter().all(|p| p.live),
"panes this server was told about in its own lifetime are live"
);
// The file is the server's: the test process never wrote it.
let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree");
assert!(text.contains(&ws.id.to_string()), "{text}");
// A refusal is a client-visible error, not a dropped reply.
let missing = client
.control
.call(ControlRequest::WorkspaceTree {
workspace: WorkspaceId::new(),
})
.unwrap_err();
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
}
/// **The revival contract, across a real restart.** A second server process
/// reads the first one's tree; every pane in it is dead (`live == false`), the
/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor.
#[test]
fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() {
let dir = data_dir();
let ws = {
let first = connect(dir.path(), "first");
let ws = match first
.control
.call(ControlRequest::WorkspaceCreate {
name: None,
workspace: None,
})
.unwrap()
{
ReplyOk::WorkspaceTree(ws) => *ws,
other => panic!("{other:?}"),
};
first
.control
.call(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
pane: seed(7, "/home/me/proj"),
tab: None,
})
.unwrap();
first.control.close();
ws
};
// A brand-new server process over the same file.
let second = connect(dir.path(), "second");
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
ReplyOk::MachineTree(m) => *m,
other => panic!("{other:?}"),
};
let record = machine
.panes
.iter()
.find(|p| p.id == 7)
.expect("the pane record survives the restart");
assert!(!record.live, "a restarted server has no live panes");
assert_eq!(
record.cwd.as_deref(),
Some("/home/me/proj"),
"the facts a successor spawns from survive"
);
assert_eq!(
machine.workspaces[0].tabs[0].root,
PaneNode::Leaf { pane: 7 },
"the leaf still names the dead pane — the revival slot"
);
// Revive: a fresh pane takes the leaf, the spent record goes.
second
.control
.call(ControlRequest::PaneReplace {
workspace: ws.id,
old: 7,
new: seed(1, "/home/me/proj"),
})
.expect("replace");
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
ReplyOk::MachineTree(m) => *m,
other => panic!("{other:?}"),
};
assert_eq!(
machine.workspaces[0].tabs[0].root,
PaneNode::Leaf { pane: 1 }
);
assert!(machine.panes.iter().all(|p| p.id != 7));
}
/// Two clients on one server. An operation by one reaches the other as a
/// `Layout` delta and never comes back to its author — the mechanism that
/// replaces whole-record last-writer-wins with edits that all land.
#[test]
fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
use tty7_core::host::local::LocalHost;
use tty7_core::host::server;
let dir = data_dir();
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let machine = Arc::clone(&machine);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_machine(machine),
)
});
}
let writer = bridged(&sock, "writer");
let watcher = bridged(&sock, "watcher");
assert!(
writer
.peer_features
.iter()
.any(|f| f == feature::MACHINE_TREE)
);
// Make sure the watcher's subscription is up (its server thread subscribes
// before answering its first request).
watcher.control.call(ControlRequest::Ping).unwrap();
let ws = match writer
.control
.call(ControlRequest::WorkspaceCreate {
name: Some("shared".into()),
workspace: None,
})
.unwrap()
{
ReplyOk::WorkspaceTree(ws) => *ws,
other => panic!("{other:?}"),
};
let tab = match writer
.control
.call(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
pane: seed(3, "/srv"),
tab: None,
})
.unwrap()
{
ReplyOk::TabTree(tab) => *tab,
other => panic!("{other:?}"),
};
watcher.expect_delta(
ws.id,
|d| matches!(d, LayoutDelta::WorkspaceCreated { workspace } if workspace.id == ws.id),
);
watcher.expect_delta(
ws.id,
|d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id),
);
// The created tab became active, and the *change of active tab* is its own
// delta — implicit activation must not be something a client re-derives.
watcher.expect_delta(
ws.id,
|d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id),
);
assert_eq!(
writer.delta_count(),
0,
"a client must not be pushed its own operation"
);
// …and the rule holds in the other direction.
watcher
.control
.call(ControlRequest::TabRename {
workspace: ws.id,
tab: tab.id,
name: Some("build".into()),
})
.unwrap();
writer.expect_delta(
ws.id,
|d| matches!(d, LayoutDelta::TabRenamed { name: Some(n), .. } if n == "build"),
);
assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops");
}
/// Takeover semantics on the new tree, with **no record store served at
/// all**: the attach verbs predate the tree, and their contract — newcomer
/// wins, the displaced session is told, a stale detach cannot evict the
/// usurper — must survive the record store's retirement.
#[test]
fn attachment_rides_the_tree_when_no_record_store_is_served() {
use tty7_core::host::local::LocalHost;
use tty7_core::host::server;
let dir = data_dir();
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let machine = Arc::clone(&machine);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_machine(machine),
)
});
}
let ws = machine
.workspace_create(None, Some("shared".into()), None)
.unwrap();
let laptop = bridged(&sock, "laptop");
let desktop = bridged(&sock, "desktop");
let attach = |client: &Client| {
client.control.call(ControlRequest::WorkspaceAttach {
id: ws.id.to_string(),
})
};
match attach(&laptop).expect("first attach") {
ReplyOk::Attached { took_over_from } => assert_eq!(took_over_from, None),
other => panic!("{other:?}"),
}
assert_eq!(
machine.attachment(ws.id).map(|a| a.hostname),
Some("laptop".into()),
"the tree's own record says who holds the workspace"
);
// The newcomer wins, learns whom it displaced, and the displaced session
// is pushed a Preempted notice.
match attach(&desktop).expect("takeover") {
ReplyOk::Attached { took_over_from } => {
assert_eq!(took_over_from.as_deref(), Some("laptop"));
}
other => panic!("{other:?}"),
}
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let seen = laptop.events.lock().unwrap().clone();
if seen.iter().any(|e| {
matches!(e, ControlEvent::Preempted { workspace, by }
if *workspace == ws.id.to_string() && by == "desktop")
}) {
break;
}
assert!(Instant::now() < deadline, "no Preempted push; saw {seen:?}");
std::thread::sleep(Duration::from_millis(20));
}
// The preempted session tidying up must not evict the usurper.
laptop
.control
.call(ControlRequest::WorkspaceDetach {
id: ws.id.to_string(),
})
.expect("a stale detach is success, not eviction");
assert_eq!(
machine.attachment(ws.id).map(|a| a.hostname),
Some("desktop".into())
);
}
/// A `--stdio --bridge` child connected to an already-listening control
/// socket — the two-hop shape a real multi-client machine has.
fn bridged(sock: &Path, token: &str) -> Client {
let hello = ControlHello::host_rpc(token, token);
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.args(["--stdio", "--bridge", "--control-sock"])
.arg(sock)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("could not start the bridging client");
let stdout = child.stdout.take().expect("piped");
let stdin = child.stdin.take().expect("piped");
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
child: Mutex::new(Some(child)),
});
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let control = ControlClient::connect_with(
stdout,
stdin,
Some(closer),
&hello,
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
)
.expect("bridge handshake");
let peer_features = control.hello().features.clone();
Client {
control,
events,
peer_features,
}
}
@@ -93,7 +93,7 @@ fn stdio_host() -> (SharedHost, TempSandbox) {
// bridge to *that* would be testing their machine's state instead of
// this build.
.args(["--stdio", "--serve"])
// The server opens its workspace store at startup. None of these cases
// The server opens its machine tree at startup. None of these cases
// touch it, but pointing it at the sandbox keeps forty-six child
// processes off the developer's real `~/.local/share/tty7`.
.env("TTY7_DATA_DIR", sandbox.path())
-543
View File
@@ -1,543 +0,0 @@
//! The workspace store, end to end against a real `tty7-server` child process.
//!
//! Same shape and the same reasoning as [`stdio_conformance`]: the client is
//! the shipped `ControlClient`, the wire is the control dialect over real
//! pipes, and the server is the shipped binary keeping its records in a file it
//! owns. What this file adds is the half the conformance suite cannot reach —
//! the store is not a `Host` method, so no amount of `read_dir` parity proves
//! that `WorkspacePut` reached a disk or that another client heard about it.
//!
//! The three things worth a process boundary:
//!
//! | | Why an in-process socket pair would not do |
//! |---|---|
//! | The record is on **the server's** disk | The whole storage split is "the machine is the authority". A store in the test's own address space proves nothing about that |
//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` |
//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected |
//!
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
//! another's leftovers and nothing here can touch the developer's real
//! `~/.local/share/tty7/workspaces.json`.
// Unix-only, for the same reason as `stdio_conformance.rs`: the server under
// test is a `--stdio` child, and two of the cases stand up a control socket.
#![cfg(unix)]
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tty7_core::core::workspace_store::{STORE_FILE, WorkspaceStore};
use tty7_core::daemon::control::{
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, feature,
};
use tty7_core::host::local::LocalHost;
use tty7_core::host::server;
/// The child, and the only way to end it — see `stdio_conformance.rs` for why a
/// `LinkShutdown` is what reaps a process-backed link.
struct ServerProcess {
child: Mutex<Option<Child>>,
}
impl LinkShutdown for ServerProcess {
fn shutdown_link(&self) -> io::Result<()> {
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
return Ok(());
};
let _ = child.kill();
let _ = child.wait();
Ok(())
}
}
/// One connected client: the RPC channel, plus everything the server pushed to
/// it.
struct Client {
control: ControlClient,
events: Arc<Mutex<Vec<ControlEvent>>>,
peer_features: Vec<String>,
}
impl Client {
/// Wait for a `WorkspaceChanged` naming `id`, or fail saying what did
/// arrive. Polled rather than blocked on a channel because the event and
/// the reply that caused it race by construction.
fn expect_changed(&self, id: &str) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let seen = self
.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if seen
.iter()
.any(|e| matches!(e, ControlEvent::WorkspaceChanged { id: got } if got == id))
{
return;
}
assert!(
Instant::now() < deadline,
"no WorkspaceChanged for {id}; saw {seen:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// Wait for the takeover notice naming `workspace` and `by`.
fn expect_preempted(&self, workspace: &str, by: &str) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let seen = self
.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if seen.iter().any(|e| {
matches!(e, ControlEvent::Preempted { workspace: w, by: b }
if w == workspace && b == by)
}) {
return;
}
assert!(
Instant::now() < deadline,
"no Preempted for {workspace} by {by}; saw {seen:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn changed_count(&self) -> usize {
self.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|e| matches!(e, ControlEvent::WorkspaceChanged { .. }))
.count()
}
}
/// Start a `tty7-server --stdio --serve` whose store lives in `data_dir`, and
/// connect a client to it.
///
/// `--serve` rather than letting the mode be probed: a developer running these
/// tests may well have a real `tty7-server --daemon` up, and bridging to *that*
/// would be testing their machine's state — and, here, writing to their real
/// workspace file.
fn connect(data_dir: &Path, token: &str) -> Client {
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.args(["--stdio", "--serve"])
.env("TTY7_DATA_DIR", data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("could not start tty7-server --stdio");
let stdout = child.stdout.take().expect("piped");
let stdin = child.stdin.take().expect("piped");
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
child: Mutex::new(Some(child)),
});
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let control = ControlClient::connect_with(
stdout,
stdin,
Some(closer),
&ControlHello::host_rpc(token, "test-client"),
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
)
.expect("handshake with tty7-server --stdio");
let peer_features = control.hello().features.clone();
Client {
control,
events,
peer_features,
}
}
fn data_dir() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
fn store_file(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join(STORE_FILE)
}
fn record(id: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"name": name,
"session": {"active": 0, "tabs": [
{"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 11}},
"sidebar_group": "/home/me/proj"}
]},
"last_active": 1_753_600_000u64,
})
}
fn json(reply: ReplyOk) -> serde_json::Value {
match reply {
ReplyOk::Json(v) => v,
other => panic!("expected a Json reply, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
/// The capability bit is the client's cue that asking is worth a round trip, so
/// it has to reflect what the shipped binary actually wired up.
#[test]
fn the_server_advertises_the_workspace_store() {
let dir = data_dir();
let client = connect(dir.path(), "cap");
assert!(
client
.peer_features
.iter()
.any(|f| f == feature::WORKSPACE_STORE),
"features were {:?}",
client.peer_features
);
}
/// **The milestone's proof for M5.** The four RPCs against a real server, and
/// the record ends up in a file that server owns — the storage split is not a
/// diagram, it is this file on that machine.
#[test]
fn records_survive_in_a_file_the_server_owns() {
let dir = data_dir();
let client = connect(dir.path(), "rpc");
assert_eq!(
json(client.control.call(ControlRequest::WorkspaceList).unwrap()),
serde_json::json!([])
);
for (id, name) in [("w-api", "api"), ("w-web", "web")] {
client
.control
.call(ControlRequest::WorkspacePut {
id: id.to_string(),
json: record(id, name),
})
.expect("put");
}
// The file is on this machine only because the "remote" is this machine;
// the point is that the *test process* never wrote it. Reading it with
// plain `std::fs` is how we know the bytes went out through a pipe and came
// back as a syscall someone else made.
let text = std::fs::read_to_string(store_file(&dir)).expect("the server wrote its store");
assert!(text.contains("w-api"), "{text}");
assert!(text.contains("w-web"), "{text}");
// Get answers exactly what was put.
let got = json(
client
.control
.call(ControlRequest::WorkspaceGet {
id: "w-api".to_string(),
})
.unwrap(),
);
assert_eq!(got, record("w-api", "api"));
// List answers both, in the order they were written.
let listed = json(client.control.call(ControlRequest::WorkspaceList).unwrap());
let ids: Vec<&str> = listed
.as_array()
.unwrap()
.iter()
.map(|v| v["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["w-api", "w-web"]);
// A missing id is an error the client can tell from an empty record.
let missing = client
.control
.call(ControlRequest::WorkspaceGet {
id: "not-a-workspace".to_string(),
})
.unwrap_err();
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
// Delete reaches the disk, and deleting again is still success.
for _ in 0..2 {
client
.control
.call(ControlRequest::WorkspaceDelete {
id: "w-api".to_string(),
})
.expect("delete");
}
let text = std::fs::read_to_string(store_file(&dir)).unwrap();
assert!(!text.contains("w-api"), "{text}");
assert!(text.contains("w-web"), "{text}");
}
/// A second connection to the same server sees the first one's records — that
/// is what "换台电脑连过来要看到同一份" means once the machine is fixed and the
/// client is not.
#[test]
fn a_later_client_sees_what_an_earlier_one_wrote() {
let dir = data_dir();
{
let first = connect(dir.path(), "first");
first
.control
.call(ControlRequest::WorkspacePut {
id: "w".to_string(),
json: record("w", "api"),
})
.expect("put");
first.control.close();
}
// A brand-new server process, reading the file the previous one left.
let second = connect(dir.path(), "second");
let got = json(
second
.control
.call(ControlRequest::WorkspaceGet {
id: "w".to_string(),
})
.unwrap(),
);
assert_eq!(got["name"], "api");
assert_eq!(got["session"]["tabs"][0]["pane"]["Leaf"]["pane_id"], 11);
}
/// Two clients on one machine at once. A change by one has to reach the other,
/// and must not come back to its author.
///
/// The store lives behind a listener, as it does under `--daemon`, and both
/// clients reach it as `--stdio --bridge` children — the same two-hop shape
/// `cli.rs` uses, and the configuration a user has when their laptop and their
/// desktop are both connected. A store per connection would pass every other
/// test in this file and fail this one.
#[test]
fn a_change_from_one_client_reaches_the_other() {
let dir = data_dir();
let store = WorkspaceStore::open(store_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let store = Arc::clone(&store);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_workspaces(store),
)
});
}
let writer = bridged(&sock, "writer");
let watcher = bridged(&sock, "watcher");
assert!(
writer
.peer_features
.iter()
.any(|f| f == feature::WORKSPACE_STORE)
);
writer
.control
.call(ControlRequest::WorkspacePut {
id: "shared".to_string(),
json: record("shared", "api"),
})
.expect("put");
watcher.expect_changed("shared");
assert_eq!(
writer.changed_count(),
0,
"a client must not be pushed its own change"
);
// The watcher is looking at the same store, not at a copy.
let got = json(
watcher
.control
.call(ControlRequest::WorkspaceGet {
id: "shared".to_string(),
})
.unwrap(),
);
assert_eq!(got["name"], "api");
// A delete is a change too — and the watcher's own delete comes back to the
// writer, which is the same rule seen from the other side.
watcher
.control
.call(ControlRequest::WorkspaceDelete {
id: "shared".to_string(),
})
.expect("delete");
writer.expect_changed("shared");
assert_eq!(watcher.changed_count(), 1, "still only the writer's put");
assert_eq!(store.len(), 0);
}
/// **The takeover, across two real processes.**
///
/// The same two-client shape as the change-notification test, and for the same
/// reason: a takeover is by definition something one connection does to
/// *another*, so an in-process registry with two handles into it would prove
/// only that the data structure works. What has to hold is that the notice
/// crosses a pipe into a different program and that the displaced link actually
/// closes.
///
/// D8 is the assertion in the middle: the newcomer holds the workspace
/// afterwards. Rejecting the second client would satisfy "only one at a time"
/// just as well and is the decision this test exists to rule out.
#[test]
fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() {
let dir = data_dir();
let store = WorkspaceStore::open(store_file(&dir));
let sock = dir.path().join("control.sock");
let listener = server::bind_control_socket(&sock).unwrap();
{
let store = Arc::clone(&store);
std::thread::spawn(move || {
server::serve_listener_with(
listener,
LocalHost::new(),
server::Services::with_workspaces(store),
)
});
}
let laptop = bridged_for(&sock, "tok-laptop", "laptop", Some("w"));
// The attach runs on the server thread after the handshake reply, so the
// record is what says it happened — not the fact that we got a `HELLO_OK`.
await_attachment(&store, "w", "laptop");
assert!(laptop.control.call(ControlRequest::Ping).is_ok());
let desktop = bridged_for(&sock, "tok-desktop", "desktop", Some("w"));
// The displaced client is told which workspace it lost and to whom.
laptop.expect_preempted("w", "desktop");
// …and then its link is closed, because this connection existed for that
// workspace: the server closes its stream.
let deadline = Instant::now() + Duration::from_secs(10);
while laptop.control.is_connected() {
assert!(
Instant::now() < deadline,
"the displaced session's link stayed open"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(
laptop
.control
.call(ControlRequest::Ping)
.unwrap_err()
.kind(),
io::ErrorKind::ConnectionReset
);
// D8: the newcomer is the one holding it, and it can still work.
await_attachment(&store, "w", "desktop");
assert!(desktop.control.call(ControlRequest::Ping).is_ok());
assert_eq!(
desktop.changed_count(),
0,
"taking over is not a workspace change"
);
// Taking it back is the same operation in the other direction — that is all
// the [Take Back] button is.
let back = bridged_for(&sock, "tok-laptop-2", "laptop", None);
let reply = back
.control
.call(ControlRequest::WorkspaceAttach { id: "w".into() })
.expect("attach");
assert_eq!(
reply,
ReplyOk::Attached {
took_over_from: Some("desktop".to_string())
}
);
desktop.expect_preempted("w", "laptop");
await_attachment(&store, "w", "laptop");
// The link that just did the taking was not opened *for* the workspace, so
// it is a plain machine connection and keeps working — that is the shape the
// GUI has, one link per machine.
assert!(back.control.is_connected());
assert!(back.control.call(ControlRequest::Ping).is_ok());
}
/// Poll until `hostname` holds `workspace`, or fail saying who does.
fn await_attachment(store: &Arc<WorkspaceStore>, workspace: &str, hostname: &str) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let who = store.attachment(workspace);
if who.as_ref().map(|a| a.hostname.as_str()) == Some(hostname) {
return;
}
assert!(
Instant::now() < deadline,
"{workspace} is held by {who:?}, not {hostname}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// A `--stdio --bridge` child connected to an already-listening control socket.
fn bridged(sock: &Path, token: &str) -> Client {
bridged_for(sock, token, "test-client", None)
}
/// [`bridged`], naming the client machine and, optionally, the workspace this
/// connection is opened *for* — the hello field the takeover keys on.
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
let hello = ControlHello {
control_version: tty7_core::daemon::control::CONTROL_VERSION,
workspace: workspace.map(str::to_string),
client_token: token.to_string(),
client_hostname: hostname.to_string(),
};
bridged_with(sock, hello)
}
fn bridged_with(sock: &Path, hello: ControlHello) -> Client {
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.args(["--stdio", "--bridge", "--control-sock"])
.arg(sock)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("could not start the bridging client");
let stdout = child.stdout.take().expect("piped");
let stdin = child.stdin.take().expect("piped");
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
child: Mutex::new(Some(child)),
});
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let control = ControlClient::connect_with(
stdout,
stdin,
Some(closer),
&hello,
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
)
.expect("bridge handshake");
let peer_features = control.hello().features.clone();
Client {
control,
events,
peer_features,
}
}
+135 -701
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -199,7 +199,7 @@ pub fn open_releases_page() {
}
/// Tiny persisted state for the update checker, stored at `update.json` in the
/// config dir (alongside `config.json` / `session.json`). Currently just the
/// config dir (alongside `config.json` / `views.json`). Currently just the
/// last version we popped the modal for, so we never nag twice for one release.
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct UpdateState {
+4 -5
View File
@@ -1,11 +1,10 @@
//! The gpui-facing half of [`WindowState`].
//!
//! The struct itself, its `window.json` IO, and the "is this geometry sane"
//! guard live in `tty7-core` — `session.json` embeds the geometry in each
//! [`Workspace`](crate::core::session::Workspace), so it has to parse on a
//! machine that never links gpui. What is left here is the only part that
//! genuinely needs gpui: turning the four stored `f32`s into a
//! [`Bounds<Pixels>`] and back.
//! guard live in `tty7-core` — `views.json` embeds the geometry in each
//! [`WindowView`](crate::core::session::WindowView), which is defined there.
//! What is left here is the only part that genuinely needs gpui: turning the
//! four stored `f32`s into a [`Bounds<Pixels>`] and back.
use gpui::{Bounds, Pixels, point, px};
+18 -9
View File
@@ -79,7 +79,7 @@ fn spawn_config_watcher(cx: &mut App) {
let Ok(event) = res else { return };
// React to events that touch our `config.json`, or a theme file dropped
// into the `themes/` subfolder — both feed the same registry reload below.
// Everything else in the dir (`session.json`, `history`, the daemon
// Everything else in the dir (`views.json`, `history`, the daemon
// socket, and our own `.config.json.tmp.<pid>` / `*.yaml.tmp.<pid>` atomic
// scratch files, whose extensions aren't theme extensions) is ignored.
let hit = event
@@ -317,9 +317,13 @@ fn main() {
// Daemon mode: when launched with `--daemon` we run the headless persistent
// terminal server and never open a window. This is the backing process the GUI
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
// the GUI. Run to completion (the accept loop blocks until killed) then return.
// the GUI. It is the *same* daemon `tty7-server --daemon` runs on a remote
// box — panes plus the control dialect — because a local machine and a
// remote one are the same thing seen from different distances, and the
// workspace tree both serve lives behind control. Run to completion (the
// accept loop blocks until killed) then return.
if std::env::args().any(|a| a == "--daemon") {
if let Err(e) = crate::daemon::server::run() {
if let Err(e) = crate::daemon::server::run_daemon() {
log::error!("daemon exited with error: {e}");
}
return;
@@ -378,10 +382,9 @@ fn main() {
// theme from it. It has to be read here, off the appearance-observer
// path — see `ui::theme::SystemAppearance`.
crate::ui::theme::refresh_system_appearance(cx);
// Read `session.json` (migrating a pre-multi-window file) before any
// window is built: windows claim their workspace from this store
// rather than each parsing the file themselves. It also dedupes
// pane claims here, once, instead of per window.
// Read `views.json` before any window is built: windows claim
// their workspace from this store rather than each parsing the
// file themselves.
crate::core::session::WorkspaceStore::init(cx);
// The window registry has to exist before the first window opens —
// `ui::windows::open` registers into it.
@@ -408,20 +411,26 @@ fn main() {
})
.detach();
keymap::init(cx);
// Hold a control link to this machine's own daemon, exactly as a
// remote machine gets one: the daemon owns the workspace tree and
// serves it over control, so the local GUI is a control client
// like any other. Supervised on its own forever loop — see
// `ui::local_link`.
crate::ui::local_link::LocalLink::install(cx);
// Come up on the *one* workspace the user was last in, at its own
// remembered geometry (`ui::windows` owns that logic, since "New
// Workspace" and the workspace picker need the identical path).
//
// Deliberately one window, not one per workspace that was open at
// quit: see `Workspaces::workspace_to_restore` for why, and
// quit: see `WindowViews::workspace_to_restore` for why, and
// `WorkspaceStore::restore_one` for what happens to the others (they
// are detached, not forgotten — panes keep running and the switcher
// lists them). Quitting with every window closed — or a first run —
// opens a single window on a fresh workspace.
let any_saved = {
let store = crate::core::session::WorkspaceStore::all(cx);
!store.workspaces.is_empty()
!store.views.is_empty()
};
let reopen = crate::core::session::WorkspaceStore::restore_one(cx);
// With nothing to reopen, what that one window should hold depends on
+26 -9
View File
@@ -1273,15 +1273,32 @@ impl TerminalElement {
let Some(link) = self.view.read(cx).hovered_link.as_ref() else {
return;
};
let row = link.line + display_offset;
if row < 0 || row as usize >= rows {
return;
}
let row = row as usize;
let mut col = link.start;
while col <= link.end && col < cols {
buf[row * cols + col].link_hover = true;
col += 1;
// The link may span several rows — a soft wrap, or a URL a program
// split with a hard newline. Paint every covered cell: full columns on
// the interior rows, clamped to `start`/`end` on the first and last.
let (start, end) = (link.start, link.end);
let mut line = start.line.0;
while line <= end.line.0 {
let grid_row = line + display_offset;
if grid_row >= 0 && (grid_row as usize) < rows {
let grid_row = grid_row as usize;
let col_start = if line == start.line.0 {
start.column.0
} else {
0
};
let col_end = if line == end.line.0 {
end.column.0
} else {
cols.saturating_sub(1)
};
let mut col = col_start;
while col <= col_end && col < cols {
buf[grid_row * cols + col].link_hover = true;
col += 1;
}
}
line += 1;
}
}
+26 -11
View File
@@ -32,12 +32,20 @@
//! probably fine and the *link* is what broke — and rendering it as "stopped"
//! would tell the user their work is gone every time the network blinks.
//!
//! **`Unknown` is never shown for this machine.** A local `List` travels a unix
//! socket to a daemon whose absence is itself the answer: no daemon, no live
//! panes. So a local host with no cached answer reads `Stopped`, which is what
//! **A local `List` failing is not `Unknown`.** It travels a unix socket to a
//! daemon whose absence is itself the answer: no daemon, no live panes. So a
//! local host with no cached *liveness* answer reads `Stopped`, which is what
//! this page has always drawn — the async cache changes remote behaviour and
//! leaves local pixels alone.
//!
//! Not knowing which panes to ask about is a different thing, and it is
//! `Unknown` on every machine. The ids live in the machine's tree
//! ([`crate::ui::machine_mirror`]), so until that first pull lands there is no
//! question to put to the daemon — and "no ids yet" must not be read as "no
//! sessions", which is a claim about the user's work founded on our own
//! ignorance. Locally the pull lands within a frame or two of launch; where
//! there is no control link at all, a muted dot is exactly the truth.
//!
//! # How it is filled
//!
//! [`sweep`] is called from the render paths that show liveness. It never
@@ -58,7 +66,7 @@ use std::time::{Duration, Instant};
use gpui::{App, AppContext as _, BorrowAppContext as _};
use crate::core::session::{Workspace, WorkspaceId, WorkspaceStore};
use crate::core::session::{WindowView, WorkspaceId, WorkspaceStore};
use crate::terminal::{PaneRoute, RemoteTerminal};
use crate::ui::host_ops::{HostId, InFlight};
@@ -232,10 +240,17 @@ impl PaneLivenessCache {
/// [`PaneLivenessCache::liveness`] for a whole workspace, read-only.
///
/// The one call the render sites make. It cannot ask the wrong machine: the
/// host and the ids both come off the same [`Workspace`].
pub fn liveness_of(cx: &App, workspace: &Workspace) -> Liveness {
/// host and the ids both come off the same [`WindowView`].
pub fn liveness_of(cx: &App, workspace: &WindowView) -> Liveness {
let host = workspace.host_id();
let ids = workspace.pane_ids();
// The ids live in the machine's tree; its mirror is where they are read. A
// machine whose tree has not been pulled leaves us with no question to ask,
// which is `Unknown` on any machine — reading it as `Stopped` would tell the
// user their sessions are gone on the strength of our own ignorance. See the
// module docs for why this is *not* the same as a failed local `List`.
let Some(ids) = crate::ui::machine_mirror::pane_ids(cx, workspace) else {
return Liveness::Unknown;
};
match cx.try_global::<PaneLivenessCache>() {
Some(cache) => cache.liveness(host, &ids),
// Before the app has installed the global. Asked of an empty cache
@@ -264,12 +279,12 @@ pub fn sweep(cx: &mut App) {
// first so the borrow of the store is released before the probes, which
// need `cx` mutably.
let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new();
for w in &WorkspaceStore::all(cx).workspaces {
for w in &WorkspaceStore::all(cx).views {
let host = w.host_id();
if targets.iter().any(|(seen, _)| *seen == host) {
continue;
}
if w.pane_ids().is_empty() {
if crate::ui::machine_mirror::pane_ids(cx, w).is_none_or(|ids| ids.is_empty()) {
continue;
}
targets.push((host, w.id));
@@ -298,10 +313,10 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
//
// Recorded as a landed failure rather than returned from: a bare `return`
// would leave `needs_probe` true, so the next frame would re-decide this,
// and `RemoteConnections::get` reaches its global mutably — which notifies,
// and `HostLinks::get` reaches its global mutably — which notifies,
// which repaints, which sweeps. Storing the answer puts the decision behind
// the same TTL as every other one.
if !host.is_local() && crate::ui::remote_connect::RemoteConnections::get(cx, host).is_none() {
if !host.is_local() && crate::ui::remote_connect::HostLinks::get(cx, host).is_none() {
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, None));
return;
}
+1 -1
View File
@@ -861,7 +861,7 @@ fn truncate_at_unbalanced_close(token: &mut String) {
/// Whether `c` may appear inside a URL per RFC 3986 (unreserved + reserved + `%`).
/// Every such character is ASCII, so any CJK character, full-width bracket, arrow or
/// emoji is rejected — which is what lets a URL be cut off from trailing CJK prose.
fn is_url_char(c: char) -> bool {
pub(super) fn is_url_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
+129 -7
View File
@@ -91,7 +91,7 @@ pub(super) fn grid_smart_range<T: EventListener>(
});
}
let (text, points, click_idx) = logical_line_at(term, click)?;
let (text, points, click_idx) = logical_line_at(term, click, false)?;
let chars: Vec<char> = text.chars().collect();
let separators = term.semantic_escape_chars();
// A span whose flanks are separator chars ends exactly where alacritty's
@@ -305,7 +305,10 @@ mod tokenizer {
/// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the
/// clicked cell, following soft wraps in both directions (a long link wraps
/// across rows; stopping at the row edge would truncate the selection).
fn hyperlink_run<T: EventListener>(term: &Term<T>, click: Point) -> Option<(Point, Point)> {
pub(super) fn hyperlink_run<T: EventListener>(
term: &Term<T>,
click: Point,
) -> Option<(Point, Point)> {
let grid = term.grid();
let cols = term.columns();
if click.column.0 >= cols {
@@ -363,9 +366,17 @@ fn hyperlink_run<T: EventListener>(term: &Term<T>, click: Point) -> Option<(Poin
/// the text with wide-char spacers dropped, a per-char grid point, and the
/// char index the click landed on. `None` when the click maps to no char
/// (out-of-bounds column).
fn logical_line_at<T: EventListener>(
///
/// When `bridge_hard_wrap` is set, rows are also joined across a *producer*
/// hard newline (no `WRAPLINE` flag) when the row is filled to the right edge
/// with a link char that continues into the first column of the next row. This
/// lets link resolution recover a URL a printing program split with a literal
/// `\n`, while double-click smart-select (which passes `false`) keeps its
/// word/semantic boundaries and never glues separate output lines together.
pub(super) fn logical_line_at<T: EventListener>(
term: &Term<T>,
click: Point,
bridge_hard_wrap: bool,
) -> Option<(String, Vec<Point>, usize)> {
let cols = term.columns();
if click.column.0 >= cols {
@@ -373,19 +384,46 @@ fn logical_line_at<T: EventListener>(
}
let grid = term.grid();
let last_col = Column(cols - 1);
let top = term.topmost_line();
let bottom = term.bottommost_line();
let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE);
// A hard bridge joins `line` to `line + 1` when the row is full to the
// right edge with a link char and the next row opens with one too, which
// rules out gluing an ordinary short line onto the following paragraph.
//
// It cannot rule out the converse: a hard newline carries no signal about
// whether the producer split a URL, so a *complete* URL that happens to end
// exactly at the right edge is bridged onto whatever the next row starts
// with (`…/a` + `README.md` resolves as `…/aREADME.md`). There is no
// reliable test for that — the head of a genuinely split URL is itself a
// valid URL — so we accept the false positive: the address bar shows the
// mistake and the user is one glance from spotting it.
//
// What we do not accept is the same accident promoting the *second* row to
// the authority. `https://good.com` + `@evil.com/x` parses as userinfo, so
// the real host becomes `evil.com` while the underline still reads
// `good.com` — a phishing hop wearing a trusted label. Never bridge into
// one.
let is_link_char = |c: char| super::search::is_url_char(c);
let hard = |line: Line| {
// `line < bottom` must stay ahead of the `line + 1` lookup — the last
// grid line has no successor to index.
bridge_hard_wrap && line < bottom && is_link_char(grid[line][last_col].c) && {
let next = grid[Line(line.0 + 1)][Column(0)].c;
is_link_char(next) && next != '@'
}
};
let continues = |line: Line| wraps(line) || hard(line);
let mut start_line = click.line;
let top = term.topmost_line();
let mut guard = 0;
while start_line > top && guard < MAX_WRAP_ROWS && wraps(start_line - 1) {
while start_line > top && guard < MAX_WRAP_ROWS && continues(start_line - 1) {
start_line -= 1;
guard += 1;
}
let mut end_line = click.line;
let bottom = term.bottommost_line();
guard = 0;
while end_line < bottom && guard < MAX_WRAP_ROWS && wraps(end_line) {
while end_line < bottom && guard < MAX_WRAP_ROWS && continues(end_line) {
end_line += 1;
guard += 1;
}
@@ -770,6 +808,90 @@ mod tests {
);
}
#[test]
fn hard_wrapped_url_is_bridged_only_for_links() {
// A printing program emitted a literal `\n` mid-URL: the head fills
// row 0 exactly (20 chars, no WRAPLINE flag) and the tail lands on
// row 1. Soft-wrap stitching can't see across this gap; the hard
// bridge in link mode joins them, while smart-select stays put.
let term = term_with(20, 4, "https://example.com/\r\ndeep/path/seg rest");
// The break carries no WRAPLINE flag — this is a producer hard newline,
// not a terminal soft wrap.
assert!(
!term.grid()[Line(0)][Column(19)]
.flags
.contains(Flags::WRAPLINE),
"fixture must be a hard newline, not a soft wrap"
);
// Link mode (bridge_hard_wrap = true) recovers the whole URL spanning
// both rows.
let click = Point::new(Line(0), Column(3));
let (text, _points, _idx) =
logical_line_at(&term, click, true).expect("logical line under click");
let idx = text.find("https").expect("url in bridged line");
let (_s, _e, url) =
crate::terminal::search::url_span_at(&text, idx + 2).expect("url span in bridged line");
assert_eq!(url, "https://example.com/deep/path/seg");
// Smart-select mode (bridge_hard_wrap = false) must NOT glue the two
// output lines together.
let (text, _points, _idx) =
logical_line_at(&term, click, false).expect("logical line under click");
assert!(
!text.contains("deep"),
"double-click must not bridge a hard newline: {text:?}"
);
}
#[test]
fn a_hard_break_before_userinfo_is_never_bridged() {
// Row 0 ends with a bare host that fills the row exactly, row 1 opens
// with `@`. Bridging would resolve `https://good.com@evil.com/x`, whose
// authority per RFC 3986 is `evil.com` — the underline would read
// `good.com` while the click navigated elsewhere. The hard bridge must
// refuse this one even though the row shape otherwise invites it.
let term = term_with(20, 4, "go1 https://good.com\r\n@evil.com/x rest");
assert!(
!term.grid()[Line(0)][Column(19)]
.flags
.contains(Flags::WRAPLINE),
"fixture must be a hard newline, not a soft wrap"
);
let click = Point::new(Line(0), Column(8));
let (text, _points, idx) =
logical_line_at(&term, click, true).expect("logical line under click");
assert!(
!text.contains("evil"),
"a hard break before `@` must not bridge: {text:?}"
);
let (_s, _e, url) =
crate::terminal::search::url_span_at(&text, idx).expect("url span under click");
assert_eq!(url, "https://good.com");
}
#[test]
fn a_soft_wrap_before_userinfo_still_stitches() {
// The `@` guard is about the *ambiguity* of a hard newline. A soft wrap
// is the terminal folding one logical line, so the continuation is
// certain and a userinfo URL must still resolve whole.
let term = term_with(20, 4, "see https://user1234@ex.com/z rest");
assert!(
term.grid()[Line(0)][Column(19)]
.flags
.contains(Flags::WRAPLINE),
"fixture must be a soft wrap, not a hard newline"
);
let click = Point::new(Line(0), Column(10));
let (text, _points, idx) =
logical_line_at(&term, click, true).expect("logical line under click");
let (_s, _e, url) =
crate::terminal::search::url_span_at(&text, idx).expect("url span under click");
assert_eq!(url, "https://user1234@ex.com/z");
}
#[test]
fn wide_glyph_and_its_spacer_resolve_to_the_same_word() {
// Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER
+84 -100
View File
@@ -98,8 +98,8 @@ impl gpui::EventEmitter<AuthPromptReady> for TerminalView {}
///
/// The id arrives asynchronously, on the agent's own hooks, long after
/// everything that *structurally* changes a window. Nothing else was making the
/// window save in between, so whether the id reached `session.json` came down
/// to whether the user happened to open a tab, split a pane or move focus
/// window save in between, so whether the id reached the persisted layout came
/// down to whether the user happened to open a tab, split a pane or move focus
/// afterwards. That is what made resume-after-End-Sessions work sometimes and
/// not others: the layout on file simply had no agent in it.
pub struct AgentSessionChanged;
@@ -544,14 +544,14 @@ pub struct TerminalView {
}
/// A link under the mouse, remembered so the grid can underline its cells. The
/// `line` is the alacritty grid line (display row minus the scroll offset), which
/// stays fixed as the viewport scrolls; `start..=end` are the inclusive columns
/// the link's text spans on that line.
#[derive(Clone, PartialEq)]
/// endpoints are alacritty grid points (line = display row minus the scroll
/// offset), which stay fixed as the viewport scrolls. A link the terminal
/// wrapped — or a producer split across rows with a hard newline — spans
/// several rows, so `start` and `end` can sit on different lines.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct HoveredLink {
pub line: i32,
pub start: usize,
pub end: usize,
pub start: Point,
pub end: Point,
}
enum LoopbackOpen {
@@ -1599,7 +1599,7 @@ impl TerminalView {
/// machine.** The host id comes off the workspace's own `RemoteTarget`,
/// through the same `connection_key` the connection was opened under — so
/// the id resolves to the very host object
/// [`RemoteConnections::insert`](crate::ui::remote_connect::RemoteConnections::insert)
/// [`HostLinks::insert`](crate::ui::remote_connect::HostLinks::insert)
/// registered, with no second source of truth to drift from it. Setting the
/// route and setting the host is one operation because a pane that ran its
/// shell on one machine and its `git` on another would be worse than
@@ -5410,56 +5410,23 @@ impl TerminalView {
if !cx.global::<Config>().link_url {
return false;
}
let term = self.terminal.term.lock();
let Some(line) = Self::grid_line(&term, row) else {
let include_loopback = self.can_forward_loopback(cx);
let Some((target, _start, _end)) = self.resolve_link_at(col, row, true, include_loopback)
else {
return false;
};
let cols = term.columns();
if col >= cols {
return false;
}
// 1) Explicit OSC 8 hyperlink carried on the cell.
let cell = &term.grid()[line][Column(col)];
if let Some(hl) = cell.hyperlink() {
let uri = hl.uri().to_string();
drop(term);
self.open_url(&uri, window, cx);
return true;
}
// 2) Fall back to detecting a bare URL or file path in the row's text.
let mut text = String::with_capacity(cols);
for c in 0..cols {
text.push(term.grid()[line][Column(c)].c);
}
drop(term);
// A relative path in the output is resolved against the cwd and
// stat-checked, then handed to the local file opener — so a remote
// pane's cwd must not be used. There, only absolute-looking local hits
// and URLs remain clickable.
let cwd = self.local_cwd();
if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) {
match link.target {
LinkTarget::Url(url) => self.open_url(&url, window, cx),
LinkTarget::File { path, line, column } => {
// A configured template (e.g. opening the file in an editor)
// takes precedence; otherwise fall back to the OS opener.
match cx.global::<Config>().link_file_command.as_deref() {
Some(template) => run_file_command(template, &path, line, column),
None => open_file_path(&path),
}
match target {
LinkTarget::Url(url) => self.open_url(&url, window, cx),
LinkTarget::File { path, line, column } => {
// A configured template (e.g. opening the file in an editor)
// takes precedence; otherwise fall back to the OS opener.
match cx.global::<Config>().link_file_command.as_deref() {
Some(template) => run_file_command(template, &path, line, column),
None => open_file_path(&path),
}
}
true
} else if self.can_forward_loopback(cx)
&& let Some((_, _, url)) = super::loopback::loopback_url_span_at(&text, col)
{
self.open_url(&url, window, cx);
true
} else {
false
}
true
}
fn open_url(&self, url: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -5608,63 +5575,60 @@ impl TerminalView {
include_files: bool,
include_loopback: bool,
) -> Option<HoveredLink> {
self.resolve_link_at(col, row, include_files, include_loopback)
.map(|(_, start, end)| HoveredLink { start, end })
}
/// The link under screen cell `(col, row)` and the inclusive grid points it
/// spans, shared by hover-underline and click-to-open so both agree on the
/// extent. Resolution runs over the *logical* line — soft-wrapped rows plus
/// producer hard newlines are stitched back together — so a URL split across
/// rows resolves whole instead of stopping at the first row edge.
fn resolve_link_at(
&self,
col: usize,
row: usize,
include_files: bool,
include_loopback: bool,
) -> Option<(LinkTarget, Point, Point)> {
let term = self.terminal.term.lock();
let line = Self::grid_line(&term, row)?;
let cols = term.columns();
if col >= cols {
return None;
}
let click = Point::new(line, Column(col));
// 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run carrying
// the same URI, which may be wider than the visible link text.
// 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run
// carrying the same URI, following soft wraps across rows.
if let Some(hl) = term.grid()[line][Column(col)].hyperlink() {
let uri = hl.uri().to_string();
let same = |c: usize| {
term.grid()[line][Column(c)]
.hyperlink()
.is_some_and(|h| h.uri() == uri)
};
let mut start = col;
while start > 0 && same(start - 1) {
start -= 1;
if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) {
return Some((LinkTarget::Url(uri), start, end));
}
let mut end = col;
while end + 1 < cols && same(end + 1) {
end += 1;
}
return Some(HoveredLink {
line: line.0,
start,
end,
});
}
// 2) Bare URL or file path detected in the row's text.
let mut text = String::with_capacity(cols);
for c in 0..cols {
text.push(term.grid()[line][Column(c)].c);
}
// 2) Bare URL or file path detected in the logical line. `bridge_hard_wrap`
// is on so a URL a program printed with a literal `\n` mid-way is
// recovered whole, not truncated at the break.
let (text, points, click_idx) = super::smart_select::logical_line_at(&term, click, true)?;
drop(term);
// Same gate as the click path above — hover must not underline a link
// the click cannot open.
// Same gate as the click path — a relative path is resolved against the
// cwd and stat-checked, so a remote pane's cwd must not be used.
let cwd = self.local_cwd();
let link =
super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| {
let link = super::search::link_at(&text, click_idx, cwd.as_deref(), include_files)
.or_else(|| {
include_loopback.then(|| {
super::loopback::loopback_url_span_at(&text, col).map(|(start, end, url)| {
super::search::LinkMatch {
super::loopback::loopback_url_span_at(&text, click_idx).map(
|(start, end, url)| super::search::LinkMatch {
start,
end,
target: LinkTarget::Url(url),
}
})
},
)
})?
})?;
Some(HoveredLink {
line: line.0,
start: link.start,
end: link.end,
})
Some((link.target, points[link.start], points[link.end]))
}
/// The inline command line, anchored right where the shell prompt
@@ -8053,7 +8017,7 @@ mod tests {
/// (which needs a window, a daemon and a pane): the derivation under test is
/// the target → `HostId` one, and pinning it here is what catches a future
/// `set_workspace` that forgets the host half. The ids must agree with what
/// `RemoteConnections::insert` registered — same `connection_key`, checked
/// `HostLinks::insert` registered — same `connection_key`, checked
/// by `connection_keys_match_the_contract_table` in `tty7-core`.
#[test]
fn a_panes_host_is_its_workspaces_machine() {
@@ -8111,6 +8075,27 @@ pub(crate) fn quiet_test_pane(
(view, daemon_side)
}
/// [`quiet_test_pane`], marked as a native-SSH pane — the shape a remote
/// window's local SSH split has. `ssh_spec` is otherwise set only by the real
/// spawn path, which needs an actual SSH handshake.
#[cfg(all(test, unix))]
pub(crate) fn quiet_test_ssh_pane(
pane_id: u64,
window: &mut Window,
cx: &mut gpui::App,
) -> (gpui::Entity<TerminalView>, std::os::unix::net::UnixStream) {
let (view, stream) = quiet_test_pane(pane_id, window, cx);
view.update(cx, |view, _| {
view.ssh_spec = Some(Box::new(
serde_json::from_str(
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
)
.expect("a minimal NativeSshSpec decodes"),
));
});
(view, stream)
}
/// gpui-harness tests: a real (headless) App + Window around a `TerminalView`
/// wired to a socketpair, so `handle_event` and the event pump run exactly as
/// in production. The test plays the daemon on the other end of the socket —
@@ -8279,9 +8264,8 @@ mod gpui_tests {
view.hover_link_at(0, 23, true, cx);
assert_eq!(view.last_hover_cell, Some((0, 23)));
view.hovered_link = Some(HoveredLink {
line: 23,
start: 0,
end: 3,
start: Point::new(Line(23), Column(0)),
end: Point::new(Line(23), Column(3)),
});
// The same geometry again changes nothing...
view.set_grid_size(80, 24, px(8.), px(17.));
@@ -9695,19 +9679,19 @@ mod gpui_tests {
cx: &mut Context<TerminalView>,
) -> crate::core::session::WorkspaceId {
use crate::core::session::{
RemoteRef, RemoteTarget, WorkspaceId, WorkspaceStore, Workspaces,
RemoteRef, RemoteTarget, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::terminal::PaneWorkspace;
let host = RemoteRef::new(
RemoteTarget::direct("me", "build-box", 22),
WorkspaceId::new(),
);
let entry = crate::core::session::Workspace::on_remote(host.clone());
let entry = crate::core::session::WindowView::on_remote(host.clone());
let id = entry.id;
WorkspaceStore::install_for_test(
cx,
Workspaces {
workspaces: vec![entry],
WindowViews {
views: vec![entry],
active: None,
},
);
+301 -90
View File
@@ -449,6 +449,14 @@ pub struct Tab {
/// clicking a file in the tree behind it brings the editor back — the same
/// "click it, it comes forward" rule as window stacking.
pub(crate) overlay_top: OverlayTop,
/// This tab's identity in the daemon's machine tree — the id every
/// semantic operation about it carries. Minted here (the daemon keeps a
/// client-minted id, see `ControlRequest::TabCreate`), so the tab can be
/// addressed before its create has round-tripped. A `Cell` because the
/// sync layer re-points it at an existing daemon tab when it recognizes
/// one by its panes (`tree_sync::adopt_tab_ids`), and that pass runs with
/// the same shared borrow every save runs under.
pub(crate) tree_id: std::cell::Cell<tty7_core::core::machine::TabId>,
}
/// Stacking order for the two overlays that cover the whole column. See
@@ -470,6 +478,25 @@ impl Tab {
code: None,
overlay_top: OverlayTop::default(),
sidebar_group: std::cell::RefCell::new(None),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
}
}
/// A tab mirroring one the daemon's tree already holds — labels and
/// identity from the tree, the pane views from `pane` (built by the delta
/// application, which attaches or reuses them).
pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self {
Self {
pane,
name: tree.name.clone(),
last_focused: None,
diff_overlay: None,
code: None,
overlay_top: OverlayTop::default(),
sidebar_group: std::cell::RefCell::new(
tree.sidebar_group.clone().map(std::path::PathBuf::from),
),
tree_id: std::cell::Cell::new(tree.id),
}
}
@@ -708,7 +735,7 @@ pub struct Tty7App {
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
/// When `Some`, the active tab renders only this one leaf full-window
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
maximized: Option<Entity<TerminalView>>,
pub(crate) maximized: Option<Entity<TerminalView>>,
/// Whether the tab chips currently show their ⌘1…⌘9 switch badges
/// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`).
pub(crate) mod_hint_badges: bool,
@@ -914,30 +941,59 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Claiming marks the workspace open and hands back its saved tabs, so
// the store (not this window) stays the single writer of session.json.
// Claiming marks the workspace open; the store stays the single
// writer of the view file.
let restore = cx.global::<Config>().restore_session;
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
let (workspace, saved) = WorkspaceStore::claim(cx, id);
// A workspace that was already on file restores its tab/split layout and
// each pane's cwd, unless the user turned restore off — then it starts
// fresh. A *brand-new* one has no tabs to restore, so what it comes up
// with is the caller's call: `None` here takes the first-run path in
// `with_session`, spawning a single default terminal, which is what
// `New Workspace` and a first run both want. Handing an empty session
// through instead lands on the home page, for the launch that exists to
// show the workspace picker.
let workspace = WorkspaceStore::claim(cx, id);
// A workspace's layout lives in its machine's tree, so a restore
// *asks* rather than reads: the window opens empty and
// `hydrate_window_from_tree` rebuilds it the moment the pull answers —
// against the local daemon that is milliseconds, so the empty state is
// effectively one frame; against a remote machine it is however long
// the link takes, which is the shape remote windows always had. A
// remote machine still unreachable when the hydration gives up is
// re-hydrated by the supervisor's reconnect.
let is_remote = WorkspaceStore::all(cx)
.get(workspace)
.is_some_and(|w| w.is_remote());
// A remote workspace hydrates even with restore off: its panes are
// running sessions on another machine, not a saved layout.
let hydrate = known && (restore || is_remote);
// What the window opens holding is the caller's call for a *brand-new*
// workspace: `None` takes the first-run path in `with_session`,
// spawning a single default terminal — what `New Workspace` and a
// first run both want — while an empty session lands on the home page,
// for the launch that exists to show the workspace picker. A known
// workspace opens empty (the hydration fills it), or on a fresh shell
// when the user turned restore off.
let session = match (known, fresh) {
(true, _) => restore.then_some(saved),
(true, _) if hydrate => Some(Session::default()),
(true, _) => None,
(false, crate::ui::windows::FreshStart::Shell) => None,
(false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()),
};
let app = Self::with_session(Some(workspace), session, window, cx);
// Persist right away. The leaves just spawned (or reattached) now carry
// daemon pane ids, and nothing else writes them until the next
// *structural* change — so a crash before the user happens to open a
// tab would strand every one of those panes in the daemon.
app.save_session(cx);
if hydrate {
// No immediate save: the window is deliberately empty, and racing
// the pull with a diff that reads as "close everything" is exactly
// what the informed gate exists to prevent.
crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace);
} else {
// A local window that skipped hydration shows what the user chose
// (a fresh shell, restore off): its state is the intended layout,
// and its sync may speak for the whole tree. A remote window that
// lands here has *not* seen its machine's tree yet, so it stays
// additive until a hydration informs it.
if !is_remote {
crate::ui::tree_sync::mark_window_informed(cx, workspace);
}
// Persist right away. The leaves just spawned (or reattached) now
// carry daemon pane ids, and nothing else writes them until the
// next *structural* change — so a crash before the user happens to
// open a tab would strand every one of those panes in the daemon.
app.save_session(cx);
}
// If startup reused a daemon that speaks a different wire protocol
// (an app upgrade while the old service kept running), the sessions
// just restored above are living on that old dialect. Surface the
@@ -1409,12 +1465,12 @@ impl Tty7App {
app
}
/// Snapshot the current tabs/active index into a `Session` and persist it.
/// Called after every structural change; the write is a small synchronous
/// JSON dump and any error is swallowed inside `Session::save`.
/// Push this window's structure to its machine's tree (and its geometry to
/// the view file). Called after every structural change — the name
/// predates the tree migration, and it remains the single funnel.
pub(crate) fn save_session(&self, cx: &mut App) {
// Tripwire for the write this record must never take: a pane created
// for one workspace being persisted under another. Each view remembers
// Tripwire for the write this sync must never make: a pane created
// for one workspace being recorded under another. Each view remembers
// the workspace whose window created it; if that and the id this save
// records under have come apart, the window's tabs and its identity
// are describing two different workspaces — the exact corruption that
@@ -1436,34 +1492,16 @@ impl Tty7App {
);
}
}
let tabs: Vec<SessionTab> = self
.tabs
.iter()
.map(|tab| tab_to_session(tab, cx))
.collect();
// Zero tabs is a real state (the home page) and is persisted as such, so
// the next launch comes back to it instead of a fresh shell.
let active = if tabs.is_empty() {
0
} else {
self.active.min(tabs.len() - 1)
};
let session = Session { active, tabs };
// The store merges this into the other windows' workspaces and owns the
// write; the geometry rides along so reopening lands where we are now.
WorkspaceStore::record(
// The layout goes nowhere near the view file: the machine that owns it
// hears about the change as the semantic operations it amounts to,
// local and remote alike. What this client persists is only the
// geometry, ridden on the same funnel so reopening lands where we are.
WorkspaceStore::record_geometry(
cx,
self.workspace,
session,
Some(WindowState::from_bounds(self.window_bounds)),
WindowState::from_bounds(self.window_bounds),
);
// …and for a remote workspace the machine that owns the layout has to
// hear about it, or `session.json` is the only place it exists and any
// other client (or a fresh install) opens the workspace empty. No-ops
// for a local workspace and for a machine we are not connected to —
// the latter is also what keeps a window that failed to restore from
// pushing its emptiness over a good record.
self.push_remote_layout(self.workspace, cx);
crate::ui::tree_sync::sync_window(self, cx);
}
/// This window is going away: capture its final state (a plain `cd` may
@@ -1480,20 +1518,32 @@ impl Tty7App {
// every `New Workspace` the user closes without using would leave one.
//
// Unless the emptiness is *this client's* ignorance rather than the
// machine's answer. `claimable_session` deliberately opens a remote
// workspace empty when its machine cannot be reached, so a window
// opened while the box was asleep and then closed — there was nothing
// in it to work on — would take the entry with it: its `RemoteRef`, its
// cached layout and its geometry, while its panes are still running
// machine's answer. Every window opens empty and waits for its tree
// pull, so a window opened while the box was asleep and then closed —
// there was nothing in it to work on — would take the entry with it:
// its `RemoteRef` and its geometry, while its panes are still running
// over there. Nothing would reconnect it and nothing would offer it
// again; the only way back is re-adding the machine by hand.
let answered = WorkspaceStore::machine_is_connected(cx, self.workspace);
if self.tabs.is_empty() && answered {
if self.tabs.is_empty()
&& answered
&& crate::ui::tree_sync::window_is_informed(cx, self.workspace)
{
// Same as the picker swap: an empty workspace being dropped takes
// its (empty) tree on the machine with it. Only an informed window
// may say so — one still waiting on its hydration is empty because
// the pull has not answered, not because the workspace is.
crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| {
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
});
WorkspaceStore::remove(cx, self.workspace);
} else {
WorkspaceStore::close_window(cx, self.workspace);
}
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
// The window's tree-sync bookkeeping goes with the window; the
// machine's tree itself keeps the workspace, which is the detach.
crate::ui::tree_sync::forget(cx, self.workspace);
// The workspace just moved from "on screen" to "detached" — the Window
// menu is the only place that says so.
crate::ui::windows::refresh_menu(cx);
@@ -1625,16 +1675,35 @@ impl Tty7App {
if previous == id {
return;
}
if self.tabs.is_empty() {
// Only an *informed* empty window proves the workspace is blank: one
// still waiting on its hydration is empty because the pull has not
// answered, and dropping the workspace then would delete a populated
// tree on the strength of our own ignorance.
if self.tabs.is_empty() && crate::ui::tree_sync::window_is_informed(cx, previous) {
// Dropping the blank workspace here, so the machine's tree drops
// its (equally blank) copy — otherwise every visit to the picker
// would leave an empty workspace behind on the daemon.
crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| {
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
});
WorkspaceStore::remove(cx, previous);
} else if self.tabs.is_empty() {
WorkspaceStore::close_window(cx, previous);
} else {
self.save_session(cx);
WorkspaceStore::close_window(cx, previous);
}
crate::ui::tree_sync::forget(cx, previous);
let (claimed, session) = WorkspaceStore::claim(cx, Some(id));
let claimed = WorkspaceStore::claim(cx, Some(id));
crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed);
self.adopt_workspace(claimed, session, window, cx);
// The machine's tree is the layout's only home now, so an explicit
// pick from the switcher always hydrates — restore-off governs what
// *launch* comes back to, not what a deliberate open shows. The window
// swaps to empty and the pull rebuilds it, for the local daemon within
// milliseconds.
self.adopt_workspace(claimed, Session::default(), window, cx);
crate::ui::tree_sync::hydrate_window_from_tree(cx, claimed);
}
/// Take over an *already claimed* workspace: rebuild this window's tabs
@@ -1718,6 +1787,7 @@ impl Tty7App {
// Keep the group it had when closed — the row reappears where
// it lived instead of flashing through Scratch.
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
},
);
self.active = insert_at;
@@ -1958,27 +2028,17 @@ impl Tty7App {
// layout returns exactly as it was.
let _ = this.update_in(cx, |this, window, cx| {
match &restarted {
// Rebuild from the machine's tree, which survived the
// restart on disk: the fresh daemon force-cleared every
// pane's live flag, so the resync revives each leaf as a
// fresh shell in its recorded cwd (agents resumed) —
// exactly the semantics the old saved-session rebuild
// hand-rolled. The pull waits out the local link coming
// back up to the fresh daemon.
Ok(()) => {
let font_size = this.font_size;
// This window's own workspace only — the other windows
// rebuild themselves from theirs.
let saved = WorkspaceStore::all(cx)
.get(this.workspace)
.map(|w| w.session.clone());
let pane_ws = this.window_workspace(cx);
let (tabs, active) = tabs_from_session(
pane_ws.as_ref(),
this.workspace,
saved,
font_size,
window,
cx,
);
this.tabs = tabs;
this.active = active;
crate::ui::tree_sync::resync_window_from_tree(cx, this.workspace);
}
// The fresh daemon never came up; rebuilding would panic in
// `new_terminal`'s connect `.expect`. Stay on the home page and
// The fresh daemon never came up. Stay on the home page and
// leave a breadcrumb rather than crash — the user can retry.
Err(e) => {
log::error!("restart background service failed, staying on home page: {e}");
@@ -3088,8 +3148,8 @@ impl Tty7App {
pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) {
let title = WorkspaceStore::all(cx)
.get(self.workspace)
.filter(|w| !w.session.tabs.is_empty())
.map(|w| w.display_name())
.filter(|w| crate::ui::machine_mirror::pane_count(cx, w).unwrap_or(0) > 0)
.and_then(|w| crate::ui::machine_mirror::display_name(cx, w))
.unwrap_or_else(|| "tty7".to_string());
if *self.window_title.borrow() == title {
return;
@@ -4383,10 +4443,8 @@ impl Tty7App {
/// Turn the title-bar workspace chip into a text field, seeded with the
/// current name. Committing on Enter or blur mirrors the tab rename.
pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let current = WorkspaceStore::all(cx)
.get(self.workspace)
.map(|w| w.display_name())
.unwrap_or_default();
let current =
crate::ui::machine_mirror::display_name_for(cx, self.workspace).unwrap_or_default();
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
input.update(cx, |state, cx| state.focus(window, cx));
let subs = vec![cx.subscribe_in(
@@ -4412,7 +4470,7 @@ impl Tty7App {
};
let value = rename.input.read(cx).value().trim().to_string();
let id = self.workspace;
WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value));
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
crate::ui::windows::refresh_menu(cx);
self.sync_window_title(window, cx);
self.focus_active(window, cx);
@@ -5867,7 +5925,7 @@ impl Tty7App {
if !host.is_connected() {
return None;
}
let home = crate::ui::remote_connect::RemoteConnections::home(cx, host_id)?;
let home = crate::ui::remote_connect::HostLinks::home(cx, host_id)?;
Some((host, Some(home)))
}
@@ -7154,6 +7212,10 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
sidebar_group: tab.sidebar_group.borrow().clone(),
// Deliberately not the live tab's tree id. This snapshot outlives the
// daemon tab it mirrors (the closed-tab stack, the session file), and
// rebuilding from it is a *new* tab everywhere it matters.
tree_id: None,
}
}
@@ -7291,7 +7353,7 @@ pub(crate) fn alive_panes_on(
/// gate on session restore.
///
/// The failure this closes: two workspace records claiming one pane id (a
/// corrupted `session.json`), or a stale id landing on an unrelated pane after
/// corrupted layout store), or a stale id landing on an unrelated pane after
/// the numbers were reused. Before the daemon knew owners, both cases attached
/// — one workspace's window silently picked up another's shell, which is how
/// `work`'s seven tabs once ended up duplicated into `personal`. A pane with no
@@ -7357,6 +7419,13 @@ fn tabs_from_session(
// renders grouped on the first frame; the first landed probe
// corrects it if the tab's repo changed while we were gone.
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
// A session lowered from the machine's tree names its daemon tabs;
// keeping those ids is what stops the first save from closing and
// recreating every one of them.
tree_id: std::cell::Cell::new(
st.tree_id
.unwrap_or_else(tty7_core::core::machine::TabId::new),
),
});
}
// Clamp the saved active index into the rebuilt range (which can be empty
@@ -7531,7 +7600,7 @@ fn session_to_pane(
/// stamped on the view (so `save_session` can shout if a window's tabs and its
/// identity ever come apart). `None` only for callers that genuinely have no
/// workspace (tests).
fn new_terminal(
pub(crate) fn new_terminal(
workspace: Option<crate::terminal::PaneWorkspace>,
owner: Option<WorkspaceId>,
font_size: f32,
@@ -8502,6 +8571,148 @@ pub(crate) mod test_window {
}
}
/// A native-SSH split inside a *remote* workspace's window runs in this
/// client's daemon and is deliberately absent from the remote machine's tree —
/// so a tree-driven tab rebuild has no leaf for it, and has to keep its view
/// anyway or a running local session is orphaned with nothing on screen.
#[cfg(all(test, unix))]
mod ssh_rebuild_gpui_tests {
use super::test_window::harness_with_pane;
use crate::core::session::{
RemoteRef, RemoteTarget, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::ui::pane::{Pane, PaneSlot};
use gpui::TestAppContext;
use tty7_core::core::machine::{LayoutDelta, PaneNode, Tab as TreeTab};
#[gpui::test]
fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) {
// A window with one tab holding remote pane 1 (as far as the window is
// concerned; the socketpair plays the daemon).
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
// Bind the window to a remote workspace and split a native-SSH pane
// into the tab — the state a remote window with a local SSH split has.
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
));
let remote_id = remote.id;
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![remote],
active: None,
},
);
app.workspace = remote_id;
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
let existing = std::mem::replace(&mut app.tabs[0].pane, Pane::Empty);
app.tabs[0].pane = Pane::split_node(
gpui::Axis::Horizontal,
0.5,
existing,
Pane::leaf(PaneSlot::Ready(ssh_view)),
);
stream
});
// Another client of the remote machine restructured the tab. The
// delta's tree names only the remote pane — the SSH leaf was never in
// that tree to be named.
let applied = app.update_in(&mut vcx, |app, window, cx| {
let tab = TreeTab {
id: app.tabs[0].tree_id.get(),
name: None,
sidebar_group: None,
root: PaneNode::Leaf { pane: 1 },
};
app.apply_layout_delta(
&LayoutDelta::TabRestructured { tab, pane: None },
window,
cx,
)
});
assert!(
applied,
"the delta must apply without falling back to a resync"
);
app.update_in(&mut vcx, |app, _, cx| {
let leaves = app.tabs[0].pane.leaves();
assert_eq!(leaves.len(), 2, "the ssh split must survive the rebuild");
assert!(
leaves.iter().any(|slot| match slot {
PaneSlot::Ready(view) => view.read(cx).ssh_spec().is_some(),
_ => false,
}),
"one leaf is still the native-SSH pane"
);
assert!(
leaves.iter().any(|slot| match slot {
PaneSlot::Ready(view) => {
let view = view.read(cx);
view.ssh_spec().is_none() && view.pane_id == 1
}
_ => false,
}),
"the remote pane's existing view is reused, not re-attached"
);
});
}
/// A remote window's tab that is native-SSH through and through is
/// unrepresentable in the machine's tree **forever** — so it must be
/// invisible to the diff, not *held*. Held means "spawns are landing,
/// wait"; a tab that can never land would make every diff return before
/// the ordering and active-tab passes, freezing tab order and activation
/// sync for the whole window for as long as the tab exists.
#[gpui::test]
fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) {
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
));
let remote_id = remote.id;
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![remote],
active: None,
},
);
app.workspace = remote_id;
// A second tab holding only a native-SSH pane.
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
app.tabs
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view))));
stream
});
let (desired, _active, held) = app.update_in(&mut vcx, |app, _, cx| {
crate::ui::tree_sync::desired_tabs(app, cx)
});
assert_eq!(
desired.len(),
1,
"only the remote-backed tab can be named in the machine's tree"
);
assert!(
held.is_empty(),
"the pure-SSH tab is permanently invisible, not held — holding it \
would freeze ordering and active-tab sync for the whole window"
);
}
}
#[cfg(test)]
mod keybinding_gpui_tests {
use super::test_window::harness;
@@ -8623,7 +8834,7 @@ mod keybinding_gpui_tests {
mod shell_menu_gpui_tests {
use crate::core::config::Config;
use crate::core::session::{
RemoteRef, RemoteTarget, Session, Workspace, WorkspaceId, WorkspaceStore, Workspaces,
RemoteRef, RemoteTarget, Session, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::ui::app::Tty7App;
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
@@ -8714,7 +8925,7 @@ mod shell_menu_gpui_tests {
);
// A workspace on a machine nothing in this process has connected to.
let remote = Workspace::on_remote(RemoteRef::new(
let remote = WindowView::on_remote(RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
@@ -8724,8 +8935,8 @@ mod shell_menu_gpui_tests {
app.update_in(&mut vcx, |app, window, cx| {
WorkspaceStore::install_for_test(
cx,
Workspaces {
workspaces: vec![remote],
WindowViews {
views: vec![remote],
active: None,
},
);
+1 -1
View File
@@ -150,7 +150,7 @@ mod gpui_tests {
});
// Inject the zero-tab session (the persisted home-page state) so the
// app builds without spawning a terminal — and without reading the
// on-disk `session.json`.
// on-disk view store.
let window = cx.add_window(|window, cx| {
Tty7App::with_session(None, Some(Session::default()), window, cx)
});
+7
View File
@@ -318,6 +318,7 @@ mod tests {
fn closed_tab_label_prefers_the_user_set_name() {
let tab = SessionTab {
name: Some("build".into()),
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -328,6 +329,7 @@ mod tests {
fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -336,6 +338,7 @@ mod tests {
// Whitespace-only names don't count as names.
let tab = SessionTab {
name: Some(" ".into()),
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/work/getty")),
};
@@ -346,6 +349,7 @@ mod tests {
fn closed_tab_label_searches_splits_for_the_first_cwd() {
let tab = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: SessionPane::Split {
axis: crate::core::session::SessionAxis::Horizontal,
@@ -362,12 +366,14 @@ mod tests {
// No name, no cwd — and "/" has no file name either.
let unnamed = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(None),
};
assert_eq!(closed_tab_label(&unnamed), None);
let root = SessionTab {
name: None,
tree_id: None,
sidebar_group: None,
pane: leaf(Some("/")),
};
@@ -378,6 +384,7 @@ mod tests {
fn closed_tab_label_clamps_runaway_names() {
let tab = SessionTab {
name: Some("a".repeat(40)),
tree_id: None,
sidebar_group: None,
pane: leaf(None),
};
+216
View File
@@ -0,0 +1,216 @@
//! The GUI's control link to **this machine's own daemon**.
//!
//! Local and remote machines are the same thing seen from different distances:
//! one machine, one daemon, one workspace tree, one control link. The remote
//! machines' links live in [`crate::ui::remote_connect::HostLinks`]; this
//! module is the local machine's — the link over which the GUI receives the
//! local daemon's pushes (`ControlEvent::Layout` deltas, `Preempted`) and
//! sends its semantic tree operations.
//!
//! # Not a `HostLinks` entry
//!
//! `HostLinks` doubles as the [`crate::ui::host_registry::HostRegistry`]
//! feeder: inserting there would register a *wire-backed* `Host` for this
//! machine, while the local file tree and git must keep going through the
//! in-process [`LocalHost`](tty7_core::host::local::LocalHost) — a socket
//! round trip per `stat` on the machine you are sitting at would be absurd. It
//! also keeps the `HostId::LOCAL`-never-holds-a-control-connection invariant
//! untouched: this link lives in its own global, not in any host table.
//!
//! # Not routed
//!
//! A remote control connection dials the local daemon's *pane* socket and asks
//! it to route (the GUI never speaks SSH). This machine needs no routing — the
//! daemon's control endpoint is right here, so the link is a plain connect plus
//! a `ControlHello`.
//!
//! # Its own pump
//!
//! The remote supervisor's pump deliberately stops when the last remote
//! workspace closes; this link must outlive that — a purely local session is
//! the *common* case — so [`LocalLink::install`] runs its own forever loop at
//! the same cadence. Each turn supervises the connection (reconnecting on the
//! same 1/2/4/…/30 s backoff a remote machine gets — the daemon may be
//! restarting or upgrading, and the GUI auto-spawns it, so "down" is always
//! transient) and drains the shared event queue, so local pushes are delivered
//! even when the remote pump is parked. Events land in that queue under
//! [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL): the pump drains one
//! queue and machines differ only by id, which is the same-shape-everywhere
//! the whole design is after.
//!
//! # Both platforms
//!
//! The dial is the one part that differs, and only in its first line: a Unix
//! socket where there are Unix sockets, and the same token-checked loopback
//! endpoint the pane dialect uses on Windows (see
//! [`tty7_core::daemon::transport`]). Everything above `connect_blocking` —
//! supervision, backoff, the event queue, the tree sync that rides this link —
//! is one code path, because a machine's tree is what a window's layout *is*
//! and a platform without it is a platform where tabs do not come back.
use std::sync::Arc;
use gpui::{App, Global};
use tty7_core::daemon::control::ControlClient;
use crate::ui::remote_workspace::Backoff;
/// The link, and the schedule for getting it back.
#[derive(Default)]
pub struct LocalLink {
client: Option<Arc<ControlClient>>,
backoff: Backoff,
/// When the next attempt is due. `None` while the link is up or an
/// attempt is in flight.
next_attempt: Option<std::time::Instant>,
attempting: bool,
/// Whether the forever loop is already running, so `install` is idempotent.
pumping: bool,
}
impl Global for LocalLink {}
impl LocalLink {
/// Start supervising the local link. Called once at startup; safe to call
/// again (the loop is a singleton).
pub fn install(cx: &mut App) {
// Local pushes need the same somewhere-to-go the remote ones have,
// and this loop may be the only one draining it.
crate::ui::remote_workspace::install_event_observer();
let link = cx.default_global::<LocalLink>();
if link.pumping {
return;
}
link.pumping = true;
cx.spawn(async move |cx| {
loop {
cx.update(|cx| {
Self::tick(cx);
crate::ui::remote_workspace::drain_events(cx);
});
cx.background_executor()
.timer(crate::ui::remote_workspace::PUMP_TICK)
.await;
}
})
.detach();
}
/// The live control client for this machine's daemon, if there is one.
///
/// `None` is always transient — the supervisor is already reconnecting —
/// so callers treat it exactly like an unreachable remote: skip the
/// operation, or queue nothing and rely on the full pull that follows a
/// reconnect.
pub fn client(cx: &mut App) -> Option<Arc<ControlClient>> {
let link = cx.default_global::<LocalLink>();
link.client.as_ref().filter(|c| c.is_connected()).cloned()
}
/// One supervision step: notice a dead link, drop it, and schedule or
/// launch the next attempt on the backoff.
fn tick(cx: &mut App) {
let now = std::time::Instant::now();
let link = cx.default_global::<LocalLink>();
if link.attempting {
return;
}
if let Some(client) = &link.client {
if client.is_connected() {
return;
}
log::info!("lost the control link to the local daemon; reconnecting");
link.client = None;
}
match link.next_attempt {
// Never attempted at all: due now. The daemon is normally already
// up (main spawns it before the first window), so the first tick
// should connect, not start a schedule.
None if link.backoff.attempt() == 0 => {}
None => {
link.next_attempt = Some(now + link.backoff.delay());
return;
}
Some(at) if at > now => return,
Some(_) => {}
}
link.next_attempt = None;
link.attempting = true;
let _ = link.backoff.advance();
cx.spawn(async move |cx| {
let connected = cx
.background_executor()
.spawn(async move { connect_blocking() })
.await;
cx.update(|cx| {
let link = cx.default_global::<LocalLink>();
link.attempting = false;
match connected {
Ok(client) => {
log::info!("control link to the local daemon is up");
link.client = Some(client);
link.backoff.reset();
link.next_attempt = None;
// Every fresh link starts with a full pull — deltas
// only advance a mirror that has a base to advance.
crate::ui::machine_mirror::MachineMirrors::refresh(
cx,
tty7_core::host::HostId::LOCAL,
);
// …and re-runs every local window's sync: a window
// built while this link was still dialing is parked
// `Unprimed { dirty }` with nothing else scheduled to
// wake it (see `tree_sync::on_link_up`).
crate::ui::tree_sync::on_link_up(cx, tty7_core::host::HostId::LOCAL);
}
Err(e) => {
// The next tick schedules the following attempt off
// the already-advanced backoff.
log::debug!("local control link attempt failed: {e}");
}
}
});
})
.detach();
}
}
/// Dial the local daemon's control endpoint and shake hands. **Blocking**; runs
/// on the background executor.
///
/// `ensure_running` first, because the daemon is the GUI's own child in the
/// common case: on a cold start this races the daemon binding its listener,
/// and the backoff absorbs the one or two attempts that lose the race.
fn connect_blocking() -> std::io::Result<Arc<ControlClient>> {
use tty7_core::daemon::control::ControlHello;
crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?;
let hello = ControlHello::host_rpc(
uuid::Uuid::new_v4().to_string(),
// Its own label rather than this machine's hostname: if this session
// is ever preempted, "this computer" is the useful thing to show —
// the hostname would name the machine the user is already at.
"this computer",
);
let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink);
// The one platform difference: which kind of stream carries the dialect.
#[cfg(unix)]
let client = ControlClient::over_unix(
std::os::unix::net::UnixStream::connect(tty7_core::host::server::control_socket_path()?)?,
&hello,
sink,
)?;
// Loopback TCP with the daemon's token as a preamble — the access boundary
// Windows has instead of socket permissions; `connect_control` presents it.
#[cfg(windows)]
let client =
ControlClient::over_tcp(tty7_core::host::server::connect_control()?, &hello, sink)?;
Ok(Arc::new(client))
}
/// Local daemon pushes land in the same process-wide observer as every remote
/// machine's, attributed to [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL).
fn local_event_sink(event: tty7_core::daemon::control::ControlEvent) {
tty7_core::daemon::control::observe_event(tty7_core::host::HostId::LOCAL, event);
}
+643
View File
@@ -0,0 +1,643 @@
//! A per-machine mirror of each daemon's workspace tree, for the surfaces that
//! read *about* workspaces without showing them.
//!
//! The machine's tree is the layout authority, so anything the client used to
//! answer from its own saved layout — a picker row's name, the "3 panes"
//! count, which pane ids a workspace claims — has to come from the tree now.
//! The windows that *show* a workspace already hold a per-window mirror
//! ([`crate::ui::tree_sync`]); this global is the read model for everything
//! else: the switcher, the Window menu, the title bar, the liveness sweep.
//!
//! # How it stays current
//!
//! One [`Machine`] per [`HostId`], filled by a `MachineGet` when a machine's
//! control link comes up and advanced from there by the same
//! [`LayoutDelta`] stream the windows consume — plus
//! [`note_synced_workspace`], because origin exclusion means this client never
//! hears its **own** operations back, and the per-window mirror they advanced
//! is the only other record of what they did.
//!
//! A delta that will not apply (a machine the pull has not answered for yet, a
//! tab it never heard of) marks nothing broken: the mirror re-pulls the whole
//! machine, exactly like a drifted window does.
//!
//! # It may be behind, and that is allowed
//!
//! Against the local daemon the first pull lands within milliseconds of
//! launch, so the picker's loading gap is about one frame. A machine that is
//! unreachable keeps its last pulled state for the rest of the process — stale
//! names beat no names — and a machine never reached this session simply has
//! no entry, which readers render as the not-knowing they are in.
use std::collections::HashMap;
use gpui::{App, Global};
use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace};
use tty7_core::daemon::control::{ControlRequest, ReplyOk};
use tty7_core::host::HostId;
use crate::core::session::WorkspaceId;
/// Every machine's last known tree, by the machine.
#[derive(Default)]
pub struct MachineMirrors {
machines: HashMap<HostId, Machine>,
/// Hosts with a `MachineGet` in flight, so a burst of triggers costs one
/// round trip.
pulling: Vec<HostId>,
}
impl Global for MachineMirrors {}
impl MachineMirrors {
/// The last pulled tree for `host`, or `None` when no pull has answered
/// yet this session. Read-only; renders may call it every frame.
pub fn machine(cx: &App, host: HostId) -> Option<&Machine> {
cx.try_global::<Self>()?.machines.get(&host)
}
/// Whether `host`'s tree has been pulled at all — the "loading" /
/// "known but empty" distinction a picker wants to draw.
pub fn ready(cx: &App, host: HostId) -> bool {
Self::machine(cx, host).is_some()
}
/// Pull `host`'s whole tree in the background and install it. Cheap to
/// call whenever a link comes up or a delta refuses to apply; concurrent
/// triggers coalesce into one round trip.
pub fn refresh(cx: &mut App, host: HostId) {
// A peer that does not advertise `machine-tree` (a server with no
// home directory for one) has no tree to pull; asking anyway costs a
// round trip per trigger to hear the same refusal. Reads keep their
// "never pulled" answer, which renders as not knowing.
let client = match crate::ui::tree_sync::tree_control_for(cx, host) {
crate::ui::tree_sync::TreeLink::Ready(client) => client,
crate::ui::tree_sync::TreeLink::Unserved => {
log::debug!("not pulling {host:?}: its server does not serve the machine tree");
return;
}
crate::ui::tree_sync::TreeLink::Down => return,
};
let mirrors = cx.default_global::<Self>();
if mirrors.pulling.contains(&host) {
return;
}
mirrors.pulling.push(host);
cx.spawn(async move |cx| {
let pulled = cx
.background_executor()
.spawn(async move {
match client.call(ControlRequest::MachineGet) {
Ok(ReplyOk::MachineTree(machine)) => Some(machine),
Ok(other) => {
log::warn!("MachineGet answered {other:?}");
None
}
Err(e) => {
log::debug!("could not pull the machine tree: {e}");
None
}
}
})
.await;
cx.update(|cx| {
let mirrors = cx.default_global::<Self>();
mirrors.pulling.retain(|h| *h != host);
if let Some(machine) = pulled {
mirrors.machines.insert(host, *machine);
cx.refresh_windows();
}
});
})
.detach();
}
/// Install a freshly pulled tree — for the paths that already hold one
/// (a window's hydration pulls `MachineGet` anyway).
///
/// Repaints, like [`refresh`](Self::refresh)'s landing does: every workspace
/// name, pane count and liveness dot on screen reads this global, and a
/// pull that lands without a repaint leaves the chrome a frame (or, on a
/// quiet screen, indefinitely) behind the tree it is describing.
pub fn install(cx: &mut App, host: HostId, machine: Machine) {
cx.default_global::<Self>().machines.insert(host, machine);
cx.refresh_windows();
}
/// Advance `host`'s mirror by one delta about the workspace `key` names.
///
/// A delta that names state the mirror does not hold re-pulls the machine
/// whole; a delta arriving before the first pull is dropped, because that
/// pull's answer already includes it.
pub fn apply_delta(cx: &mut App, host: HostId, key: &str, delta: &LayoutDelta) {
let Ok(id) = key.parse::<WorkspaceId>() else {
return;
};
let applied = match cx.default_global::<Self>().machines.get_mut(&host) {
Some(machine) => apply(machine, id, delta),
None => true,
};
if !applied {
log::debug!("machine mirror for {host:?} fell behind; re-pulling");
Self::refresh(cx, host);
}
}
/// Record the post-state of this client's own operations on `machine_ws` —
/// the half of the history origin exclusion keeps out of the delta stream.
/// A workspace the mirror has not seen is created; `None` tabs leave the
/// structure alone (a label-only op).
pub fn note_synced_workspace(
cx: &mut App,
host: HostId,
machine_ws: WorkspaceId,
tabs: Vec<Tab>,
active: Option<TabId>,
) {
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
return;
};
let ws = match machine.workspaces.iter_mut().find(|w| w.id == machine_ws) {
Some(ws) => ws,
None => {
machine.workspaces.push(Workspace {
id: machine_ws,
..Workspace::default()
});
machine.workspaces.last_mut().expect("just pushed")
}
};
ws.tabs = tabs;
ws.active_tab = active;
}
/// Fold in a workspace-level operation this client just fired
/// ([`crate::ui::tree_sync::fire_workspace_op`]) — same reason as
/// [`note_synced_workspace`]: the writer never hears its own echo.
pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) {
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
return;
};
match request {
ControlRequest::WorkspaceRename { workspace, name } => {
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
ws.name = name.clone();
}
}
ControlRequest::WorkspaceTouch { workspace } => {
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
ws.last_active = crate::ui::home::now_secs();
}
}
ControlRequest::WorkspaceRemove { workspace } => {
machine.workspaces.retain(|w| w.id != *workspace);
}
_ => {}
}
}
}
/// Advance one machine's copy by one delta. `false` means the delta names
/// state the mirror does not hold and the caller should re-pull.
fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> bool {
// The two deltas that do not require the workspace to exist yet.
match delta {
LayoutDelta::WorkspaceCreated { workspace: ws } => {
machine.workspaces.retain(|w| w.id != ws.id);
machine.workspaces.push(ws.clone());
return true;
}
LayoutDelta::WorkspaceDeleted => {
machine.workspaces.retain(|w| w.id != workspace);
return true;
}
// Facts about a pane are registry-wide; the workspace key only says
// who referenced it. Upserted rather than matched, because the record
// may have been born from another client's op this mirror never saw.
LayoutDelta::PaneFacts { pane } => {
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
Some(record) => *record = pane.clone(),
None => machine.panes.push(pane.clone()),
}
return true;
}
_ => {}
}
let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == workspace) else {
return false;
};
match delta {
LayoutDelta::WorkspaceCreated { .. }
| LayoutDelta::WorkspaceDeleted
| LayoutDelta::PaneFacts { .. } => unreachable!("handled above"),
LayoutDelta::WorkspaceRenamed { name } => {
ws.name = name.clone();
true
}
LayoutDelta::WorkspaceTouched { last_active } => {
ws.last_active = *last_active;
true
}
LayoutDelta::ActiveTabChanged { tab } => {
ws.active_tab = Some(*tab);
true
}
LayoutDelta::TabCreated { at, tab } => {
// Deltas and full pulls have no ordering barrier: a create that
// straddles a pull arrives *after* the snapshot that already
// carries its tab. Replace-by-id (the `WorkspaceCreated` retain
// above is the precedent) rather than insert twice.
ws.tabs.retain(|t| t.id != tab.id);
let at = (*at).min(ws.tabs.len());
ws.tabs.insert(at, tab.clone());
true
}
LayoutDelta::TabClosed { tab } => {
let before = ws.tabs.len();
ws.tabs.retain(|t| t.id != *tab);
if ws.tabs.is_empty() {
ws.active_tab = None;
}
ws.tabs.len() != before
}
LayoutDelta::TabRenamed { tab, name } => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.name = name.clone();
true
}
LayoutDelta::TabRegrouped { tab, group } => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.sidebar_group = group.clone();
true
}
LayoutDelta::TabMoved { tab, to } => {
let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else {
return false;
};
let moved = ws.tabs.remove(from);
ws.tabs.insert((*to).min(ws.tabs.len()), moved);
true
}
LayoutDelta::TabRestructured { tab, pane } => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == tab.id) else {
return false;
};
*t = tab.clone();
if let Some(pane) = pane {
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
Some(record) => *record = pane.clone(),
None => machine.panes.push(pane.clone()),
}
}
true
}
LayoutDelta::RatioChanged { tab, path, ratio } => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
match t.root.descend_mut(path) {
Some(tty7_core::core::machine::PaneNode::Split { ratio: r, .. }) => {
*r = *ratio;
true
}
_ => false,
}
}
}
}
// ---------------------------------------------------------------------------
// Reading a client entry's display facts off its machine's mirror
// ---------------------------------------------------------------------------
/// The tree workspace a client entry points at, with the pane registry it
/// reads records from. `None` while the machine has not been pulled (or no
/// longer lists the workspace).
fn view_of<'a>(
cx: &'a App,
entry: &crate::core::session::WindowView,
) -> Option<(&'a Workspace, &'a [PaneRecord])> {
let machine = MachineMirrors::machine(cx, entry.host_id())?;
let machine_ws = entry.host.as_ref().map(|r| r.workspace).unwrap_or(entry.id);
let ws = machine.workspaces.iter().find(|w| w.id == machine_ws)?;
Some((ws, &machine.panes))
}
/// What the picker and the window title call `entry`: the user-set name, else
/// derived from the tree's repo groups and cwds.
///
/// Falls back to
/// [`WindowView::label`](crate::core::session::WindowView::label) — what the
/// machine last said, before it
/// stopped answering. The tree wins whenever it answers; the hint is for the
/// rows the picker exists to offer, on machines that are asleep. `None` only
/// when this client has never seen the workspace named at all, which is a
/// brand-new entry and nothing a user is choosing between.
pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
match view_of(cx, entry) {
Some((ws, panes)) => Some(display_name_of(ws, panes)),
None => entry.label.clone(),
}
}
/// A tree workspace's label: the user-set name, else the repository most of
/// its tabs live in, else the first pane's directory, else `"Untitled"`.
pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
return name.to_string();
}
subject_path_of(ws, panes)
.and_then(|path| {
std::path::Path::new(&path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Untitled".to_string())
}
/// The path a workspace is *about*: the repo group most tabs belong to (ties
/// toward the earliest tab), else the first pane's cwd. What the picker's dim
/// subtitle shows, and what [`display_name_of`] takes the basename of.
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
let mut counts: Vec<(&str, usize)> = Vec::new();
for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) {
match counts.iter_mut().find(|(g, _)| *g == group) {
Some((_, n)) => *n += 1,
None => counts.push((group, 1)),
}
}
let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g);
let first_cwd = ws
.tabs
.iter()
.flat_map(|t| t.root.pane_ids())
.find_map(|id| {
panes
.iter()
.find(|p| p.id == id)
.and_then(|p| p.cwd.as_deref())
});
dominant.or(first_cwd).map(str::to_string)
}
/// [`display_name`] looked up by the client's workspace id, with the shared
/// not-knowing fallback — for the sites that hold an id rather than an entry.
pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> {
let entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?;
display_name(cx, entry)
}
/// [`subject_path_of`] for a client entry, falling back to the stamped hint for
/// the same reason [`display_name`] does.
pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
match view_of(cx, entry) {
Some((ws, panes)) => subject_path_of(ws, panes).or_else(|| entry.subject.clone()),
None => entry.subject.clone(),
}
}
/// The pair a client entry should carry on file, read off its machine's mirror —
/// for [`WorkspaceStore::record_geometry`](crate::core::session::WorkspaceStore::record_geometry)
/// to stamp. `None` for a machine that has not answered: a hint is only ever
/// replaced by something better, never blanked by not knowing.
pub fn display_hint(
cx: &App,
entry: &crate::core::session::WindowView,
) -> Option<(String, Option<String>)> {
let (ws, panes) = view_of(cx, entry)?;
Some((display_name_of(ws, panes), subject_path_of(ws, panes)))
}
/// Every pane id `entry`'s tree claims on its machine. `None` when the
/// machine's tree has not been pulled — which a caller about to state a fact
/// ("3 running sessions will be ended") must render as not knowing, not as
/// zero.
pub fn pane_ids(cx: &App, entry: &crate::core::session::WindowView) -> Option<Vec<u64>> {
let (ws, _) = match view_of(cx, entry) {
Some(view) => view,
// A pulled machine that no longer lists the workspace *is* an answer:
// it claims nothing.
None if MachineMirrors::ready(cx, entry.host_id()) => return Some(Vec::new()),
None => return None,
};
Some(ws.tabs.iter().flat_map(|t| t.root.pane_ids()).collect())
}
/// How many terminals `entry` holds across every tab, per its machine's tree.
pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option<usize> {
pane_ids(cx, entry).map(|ids| ids.len())
}
#[cfg(test)]
mod tests {
use tty7_core::core::machine::{Axis, PaneNode, Tab, TabId};
use super::*;
fn machine_with(ws: Workspace) -> Machine {
Machine {
workspaces: vec![ws],
panes: Vec::new(),
}
}
fn leaf_tab(pane: u64) -> Tab {
Tab::leaf(pane)
}
/// A machine that is not answering still has to produce a row a user can
/// choose: the picker's whole job is offering workspaces on machines that
/// are asleep, and "Untitled" with a blank subtitle is not an offer. So the
/// stamped hint stands in until a pull lands, and the tree wins the moment
/// one does.
#[gpui::test]
fn an_unpulled_machine_falls_back_to_the_stamped_label(cx: &mut gpui::TestAppContext) {
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
cx.update(|cx| {
let mut view = WindowView::default();
view.label = Some("api".into());
view.subject = Some("/repo/api".into());
let id = view.id;
let entry = view.clone();
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![view],
active: None,
},
);
// Nothing pulled: the hint is what the row says.
assert_eq!(display_name(cx, &entry).as_deref(), Some("api"));
assert_eq!(subject_path(cx, &entry).as_deref(), Some("/repo/api"));
assert!(
display_hint(cx, &entry).is_none(),
"and a machine that has not answered contributes no new hint"
);
// The tree answers, and outranks it.
let mut tree = Workspace {
id,
name: Some("web".into()),
..Workspace::default()
};
tree.tabs = vec![leaf_tab(1)];
MachineMirrors::install(cx, HostId::LOCAL, machine_with(tree));
assert_eq!(display_name(cx, &entry).as_deref(), Some("web"));
assert_eq!(
display_hint(cx, &entry).map(|(label, _)| label).as_deref(),
Some("web"),
"which is what the next save stamps"
);
});
}
#[test]
fn a_workspace_created_delta_lands_whole_and_a_deleted_one_removes_it() {
let mut machine = Machine::default();
let ws = Workspace::default();
let id = ws.id;
assert!(apply(
&mut machine,
id,
&LayoutDelta::WorkspaceCreated { workspace: ws },
));
assert_eq!(machine.workspaces.len(), 1);
assert!(apply(&mut machine, id, &LayoutDelta::WorkspaceDeleted));
assert!(machine.workspaces.is_empty());
}
#[test]
fn structural_deltas_advance_the_mirrored_tree() {
let ws = Workspace::default();
let id = ws.id;
let mut machine = machine_with(ws);
let tab = leaf_tab(1);
let tab_id = tab.id;
assert!(apply(
&mut machine,
id,
&LayoutDelta::TabCreated { at: 0, tab },
));
let restructured = Tab {
id: tab_id,
name: None,
sidebar_group: None,
root: PaneNode::Split {
axis: Axis::Vertical,
ratio: 0.5,
a: Box::new(PaneNode::Leaf { pane: 1 }),
b: Box::new(PaneNode::Leaf { pane: 2 }),
},
};
assert!(apply(
&mut machine,
id,
&LayoutDelta::TabRestructured {
tab: restructured,
pane: Some(PaneRecord::new(2)),
},
));
let ws = &machine.workspaces[0];
assert_eq!(ws.tabs[0].root.pane_ids(), vec![1, 2]);
assert_eq!(
machine.panes.len(),
1,
"the rider pane record is upserted into the registry"
);
}
/// Deltas and full pulls have no ordering barrier: a `TabCreated` that
/// straddles a `MachineGet` arrives after a snapshot that already carries
/// its tab. Applying it must replace by id, not insert a second copy.
#[test]
fn a_tab_created_delta_that_straddled_a_pull_lands_once() {
let ws = Workspace::default();
let id = ws.id;
let mut machine = machine_with(ws);
let delta = LayoutDelta::TabCreated {
at: 0,
tab: leaf_tab(1),
};
assert!(apply(&mut machine, id, &delta));
assert!(apply(&mut machine, id, &delta));
assert_eq!(
machine.workspaces[0].tabs.len(),
1,
"the second application is the pull/delta overlap, not a second tab"
);
}
#[test]
fn a_delta_about_a_tab_the_mirror_never_saw_asks_for_a_repull() {
let ws = Workspace::default();
let id = ws.id;
let mut machine = machine_with(ws);
assert!(
!apply(
&mut machine,
id,
&LayoutDelta::TabRenamed {
tab: TabId::new(),
name: Some("x".into()),
},
),
"an unappliable delta must say so, so the caller re-pulls"
);
// …and so does one about a workspace the machine does not list.
assert!(!apply(
&mut machine,
WorkspaceId::new(),
&LayoutDelta::WorkspaceRenamed { name: None },
));
}
#[test]
fn pane_facts_upsert_the_registry_even_for_a_pane_born_elsewhere() {
let mut machine = Machine::default();
let mut record = PaneRecord::new(7);
record.cwd = Some("/work".into());
assert!(apply(
&mut machine,
WorkspaceId::new(),
&LayoutDelta::PaneFacts {
pane: record.clone(),
},
));
record.live = true;
assert!(apply(
&mut machine,
WorkspaceId::new(),
&LayoutDelta::PaneFacts { pane: record },
));
assert_eq!(machine.panes.len(), 1, "updated in place, not duplicated");
assert!(machine.panes[0].live);
}
/// The precedence `Workspace::display_name` always had, read off the tree:
/// user name, then the dominant repo group, then the first pane's cwd.
#[test]
fn display_names_derive_from_the_tree_with_the_session_precedence() {
let mut ws = Workspace::default();
let panes = vec![PaneRecord {
cwd: Some("/home/me/scratch".into()),
..PaneRecord::new(1)
}];
ws.tabs = vec![leaf_tab(1)];
assert_eq!(display_name_of(&ws, &panes), "scratch");
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
assert_eq!(display_name_of(&ws, &panes), "tty7");
ws.name = Some(" Release prep ".into());
assert_eq!(display_name_of(&ws, &panes), "Release prep");
assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled");
}
}
+3
View File
@@ -22,6 +22,8 @@ pub mod host_ops;
#[allow(dead_code)]
pub mod host_registry;
pub mod keymap;
pub mod local_link;
pub mod machine_mirror;
pub mod palette;
pub mod pane;
pub mod pending_pane;
@@ -42,5 +44,6 @@ pub mod tab_sidebar;
pub mod tab_strip;
pub mod theme;
pub mod tray;
pub mod tree_sync;
pub mod windows;
pub mod worktree_prompt;
+11 -2
View File
@@ -716,12 +716,21 @@ impl Pane<PaneSlot> {
window.refresh();
}
});
// End the drag on release.
// End the drag on release — and persist the ratio
// it landed on. The drag itself only moves the
// shared cell; without this save the new ratio
// reached disk (and now the machine's tree) only as
// a passenger on some later structural change.
window.on_mouse_event({
let dragging = dragging.clone();
move |_ev: &MouseUpEvent, _phase, window, _cx| {
move |_ev: &MouseUpEvent, _phase, window, cx| {
if dragging.get() {
dragging.set(false);
if let Some(app) =
crate::ui::windows::WindowRegistry::app_in(cx, window)
{
app.update(cx, |app, cx| app.save_session(cx));
}
window.refresh();
}
}
+73 -136
View File
@@ -15,7 +15,7 @@
//! | 2 | Resolve one into a self-contained SSH spec | [`spec_for`] |
//! | 3 | Open a routed control connection through the local daemon | [`connect_blocking`] |
//! | 4 | Read the machine's own workspace list | [`rows_from_list`] |
//! | 5 | Hold the connection for the workspaces bound to it | [`RemoteConnections`] |
//! | 5 | Hold the connection for the workspaces bound to it | [`HostLinks`] |
//!
//! ## Machines are configured once
//!
@@ -42,7 +42,6 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use gpui::{App, Global};
use serde_json::Value;
use crate::core::config::Config;
use crate::core::session::{RemoteTarget, WorkspaceId};
@@ -401,11 +400,11 @@ fn handshake(
/// Ask a connected machine for its workspaces.
pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspaceRow>> {
match host.client().call(ControlRequest::WorkspaceList)? {
ReplyOk::Json(Value::Array(list)) => Ok(rows_from_list(&list)),
match host.client().call(ControlRequest::MachineGet)? {
ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("the server answered a workspace list with {other:?}"),
format!("the server answered a machine tree with {other:?}"),
)),
}
}
@@ -444,38 +443,24 @@ fn client_hostname() -> String {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteWorkspaceRow {
pub id: WorkspaceId,
/// Already resolved through `Workspace::display_name`'s rules, so a record
/// with no user-set name still reads as its repo or directory.
/// The user-set name when there is one, else derived from the tabs' repo
/// groups and cwds — the same precedence `Workspace::display_name` gives a
/// local workspace, computed here from the machine's tree.
pub name: String,
pub panes: usize,
pub last_active: u64,
/// The raw record, kept so opening the row can `apply_remote_json` it
/// without a second round trip.
pub record: Value,
}
/// Turn a `WorkspaceList` payload into picker rows, newest first.
///
/// Records the client cannot decode are **skipped, not fatal**: the list is
/// written by whichever tty7 last touched that machine, and one record from a
/// newer build must not make every other workspace on the box unreachable.
pub fn rows_from_list(list: &[Value]) -> Vec<RemoteWorkspaceRow> {
let mut rows: Vec<RemoteWorkspaceRow> = list
/// Turn a machine's tree into picker rows, newest first.
pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec<RemoteWorkspaceRow> {
let mut rows: Vec<RemoteWorkspaceRow> = machine
.workspaces
.iter()
.filter_map(|record| {
// The remote record is the remote-owned half of a `Workspace`, so it
// decodes by merging onto a blank one — which is also what gives us
// `display_name` and `pane_count` for free rather than reimplemented.
let mut workspace = crate::core::session::Workspace::default();
workspace.apply_remote_json(record).ok()?;
let id: WorkspaceId = serde_json::from_value(record.get("id")?.clone()).ok()?;
Some(RemoteWorkspaceRow {
id,
name: workspace.display_name(),
panes: workspace.pane_count(),
last_active: workspace.last_active,
record: record.clone(),
})
.map(|ws| RemoteWorkspaceRow {
id: ws.id,
name: crate::ui::machine_mirror::display_name_of(ws, &machine.panes),
panes: ws.tabs.iter().map(|t| t.root.pane_ids().len()).sum(),
last_active: ws.last_active,
})
.collect();
rows.sort_by_key(|row| std::cmp::Reverse(row.last_active));
@@ -486,16 +471,21 @@ pub fn rows_from_list(list: &[Value]) -> Vec<RemoteWorkspaceRow> {
// 5. Holding the connections
// ---------------------------------------------------------------------------
/// The live remote machines, by [`HostId`].
/// The live control links, by [`HostId`] — one per machine, one machine per
/// entry.
///
/// One entry per *machine*, not per workspace — the same granularity the SSH
/// connection is pooled at and the same one [`crate::ui::host_registry`] uses,
/// so two windows on one box share a connection, a host object and a git-status
/// cache. This table holds the concrete [`RemoteHost`] because pushing a layout
/// needs its control client; `HostRegistry` holds the same object erased to
/// `dyn Host` for the panels.
/// The name says the model: every machine this client talks to is reached
/// over exactly one control link, and the local machine is a machine like any
/// other — its link simply lives in its own global
/// ([`LocalLink`](crate::ui::local_link::LocalLink)) because it is in-process
/// rather than wire-backed. One entry per *machine*, not per workspace — the
/// same granularity the SSH connection is pooled at and the same one
/// [`crate::ui::host_registry`] uses, so two windows on one box share a
/// connection, a host object and a git-status cache. This table holds the
/// concrete [`RemoteHost`] because pushing a layout needs its control client;
/// `HostRegistry` holds the same object erased to `dyn Host` for the panels.
#[derive(Default)]
pub struct RemoteConnections {
pub struct HostLinks {
hosts: HashMap<HostId, Arc<RemoteHost>>,
/// Each machine's `$HOME`, as its handshake reported it.
///
@@ -511,24 +501,18 @@ pub struct RemoteConnections {
homes: HashMap<HostId, PathBuf>,
}
impl Global for RemoteConnections {}
impl Global for HostLinks {}
impl RemoteConnections {
impl HostLinks {
/// The connection to `id`, if this process has one.
pub fn get(cx: &mut App, id: HostId) -> Option<Arc<RemoteHost>> {
cx.default_global::<RemoteConnections>()
.hosts
.get(&id)
.cloned()
cx.default_global::<HostLinks>().hosts.get(&id).cloned()
}
/// Where a *new* workspace on `id` would start: that machine's own `$HOME`,
/// never this client's.
pub fn home(cx: &mut App, id: HostId) -> Option<PathBuf> {
cx.default_global::<RemoteConnections>()
.homes
.get(&id)
.cloned()
cx.default_global::<HostLinks>().homes.get(&id).cloned()
}
/// Record a connection, and register the same object with the host registry
@@ -540,14 +524,14 @@ impl RemoteConnections {
pub fn insert(cx: &mut App, host: Arc<RemoteHost>, home: PathBuf) {
let id = host.id();
crate::ui::host_registry::HostRegistry::insert(cx, Arc::clone(&host).into_shared());
let table = cx.default_global::<RemoteConnections>();
let table = cx.default_global::<HostLinks>();
table.hosts.insert(id, host);
table.homes.insert(id, home);
}
/// Drop a machine's connection once nothing is using it.
pub fn remove(cx: &mut App, id: HostId) {
let table = cx.default_global::<RemoteConnections>();
let table = cx.default_global::<HostLinks>();
table.hosts.remove(&id);
table.homes.remove(&id);
crate::ui::host_registry::HostRegistry::remove(cx, id);
@@ -555,50 +539,10 @@ impl RemoteConnections {
/// Machines currently connected. Diagnostics and teardown.
pub fn len(cx: &mut App) -> usize {
cx.default_global::<RemoteConnections>().hosts.len()
cx.default_global::<HostLinks>().hosts.len()
}
}
/// Push a workspace's layout to the machine that owns it (the
/// remote's `workspaces.json` is the authority). Blocking.
pub fn put_remote_layout(host: &Arc<RemoteHost>, key: String, record: Value) -> io::Result<()> {
host.client()
.call(ControlRequest::WorkspacePut {
id: key,
json: record,
})
.map(|_| ())
}
/// Pull one workspace's authoritative record from the machine that owns it.
/// Blocking.
///
/// The read side of the split, and what a
/// [`ControlEvent::WorkspaceChanged`](crate::daemon::control::ControlEvent)
/// asks for: the event says only *that* a record moved, so the record itself is
/// fetched rather than carried. `ErrorKind::NotFound` is a real answer — the
/// workspace was deleted on the far side — and is deliberately distinguishable
/// from an empty one.
pub fn get_remote_layout(host: &Arc<RemoteHost>, key: String) -> io::Result<Value> {
match host
.client()
.call(ControlRequest::WorkspaceGet { id: key })?
{
ReplyOk::Json(record) => Ok(record),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("the server answered a workspace record with {other:?}"),
)),
}
}
/// Forget a workspace on the machine that owns it. Blocking.
pub fn delete_remote_workspace(host: &Arc<RemoteHost>, key: String) -> io::Result<()> {
host.client()
.call(ControlRequest::WorkspaceDelete { id: key })
.map(|_| ())
}
// ---------------------------------------------------------------------------
// 6. Install consent
// ---------------------------------------------------------------------------
@@ -776,7 +720,7 @@ pub fn register(cx: &mut App) {
crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth));
// Touch the globals so the first connect isn't also the first allocation of
// the table it writes into, on a thread that is holding a socket open.
let _ = RemoteConnections::len(cx);
let _ = HostLinks::len(cx);
}
/// The oldest install waiting for an answer, if any.
@@ -1271,54 +1215,47 @@ mod tests {
assert_eq!(endpoint_label("", "box.local", 22), "box.local");
}
/// The picker's rows come from records the *remote* wrote, so they have to
/// survive a record this build cannot read: one bad entry may not hide the
/// rest of the machine's workspaces.
/// The picker's rows come from the machine's tree: newest first, with a
/// name derived the way a local workspace's would be when none is set.
#[test]
fn rows_skip_undecodable_records_and_sort_newest_first() {
fn rows_from_the_tree_sort_newest_first_and_derive_names() {
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace};
let older = WorkspaceId::new();
let newer = WorkspaceId::new();
let list = vec![
serde_json::json!({
"id": older.to_string(),
"name": "api",
"session": { "tabs": [] },
"last_active": 100,
}),
// No `id` at all — unreadable, and skipped rather than fatal.
serde_json::json!({ "name": "broken" }),
serde_json::json!({
"id": newer.to_string(),
"name": "web",
"session": { "tabs": [] },
"last_active": 500,
}),
];
let rows = rows_from_list(&list);
assert_eq!(rows.len(), 2, "the undecodable record is skipped");
let machine = Machine {
workspaces: vec![
Workspace {
id: older,
name: Some("api".into()),
last_active: 100,
tabs: vec![Tab::leaf(1)],
..Default::default()
},
Workspace {
id: newer,
name: None,
last_active: 500,
tabs: vec![Tab::leaf(2)],
..Default::default()
},
],
panes: vec![
PaneRecord::new(1),
PaneRecord {
cwd: Some("/srv/checkout".into()),
..PaneRecord::new(2)
},
],
};
let rows = rows_from_machine(&machine);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, newer, "newest first");
assert_eq!(rows[0].name, "web");
assert_eq!(rows[1].id, older);
}
/// A record with no user-set name falls back to the same derived name a
/// local workspace would get, rather than showing a raw uuid.
#[test]
fn rows_derive_a_name_when_the_record_has_none() {
let id = WorkspaceId::new();
let list = vec![serde_json::json!({
"id": id.to_string(),
"session": {
"tabs": [{
"pane": { "Leaf": { "cwd": "/srv/checkout" } }
}]
},
"last_active": 1,
})];
let rows = rows_from_list(&list);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "checkout");
assert_eq!(
rows[0].name, "checkout",
"no user name falls back to the first pane's directory"
);
assert_eq!(rows[0].panes, 1);
assert_eq!(rows[1].name, "api", "a user-set name wins");
}
fn host(label: &str, detail: &str) -> HostChoice {
+205 -427
View File
@@ -1,7 +1,7 @@
//! The window's half of "Connect to Host".
//!
//! [`ui::remote_connect`](crate::ui::remote_connect) is the plumbing — SSH
//! specs, routed control connections, the remote workspace store. This is the
//! specs, routed control connections, the remote machine's tree. This is the
//! part that lives on a window: the state the home page renders, the steps that
//! move between those states, and the guards that keep a window on one machine.
//!
@@ -19,7 +19,7 @@
//! |---|---|
//! | New tab / split | [`Tty7App::spawn_host`] — a remote window refuses to spawn a local shell |
//! | Reopening a closed tab | [`Tty7App::rebind_host`] clears the closed stack when a window changes machine |
//! | Restart / session restore | `WorkspaceStore::record`'s storage split — a remote entry never holds a local layout on disk |
//! | Restart / session restore | the machine's own tree is the only layout source — the client persists no layout at all |
//!
//! The fourth path, dragging a tab between windows, does not exist in tty7:
//! tabs never leave the window they were opened in, so there is nothing to
@@ -661,10 +661,10 @@ impl Tty7App {
rows: rows.clone(),
},
);
remote_connect::RemoteConnections::insert(cx, connected.host, home.clone());
remote_connect::HostLinks::insert(cx, connected.host, home.clone());
self.prompt_remote_daemon_mismatch_later(cx);
// Nothing left to *show* about the attempt: the machine is now
// in `RemoteConnections` and its group in the switcher fills
// in `HostLinks` and its group in the switcher fills
// itself from there and from the snapshot above.
self.connect = None;
}
@@ -688,7 +688,8 @@ impl Tty7App {
) {
let host = RemoteRef::new(target, row.id);
let id = WorkspaceStore::claim_remote(cx, host);
WorkspaceStore::apply_remote(cx, id, &row.record);
// No record to apply: the machine's tree is pulled when the window
// hydrates, which the enter below sets in motion.
self.enter_remote_workspace(id, window, cx);
}
@@ -711,7 +712,8 @@ impl Tty7App {
// it from the tabs' repo/cwd, which is the same rule a local workspace
// follows and the one intended. A workspace that opened in
// `~` and then had a repo opened in it renames itself for free.
self.push_remote_layout(id, cx);
// The machine learns about the workspace when the window's hydration
// finds nothing under this id and creates it (`WorkspaceCreate`).
log::info!(
"new remote workspace on {target} rooted at {}",
home.display()
@@ -746,44 +748,6 @@ impl Tty7App {
cx.notify();
}
/// Push this window's workspace record to the machine that owns it.
///
/// The other half of the storage split: the client keeps `open`, the window
/// geometry and the pointer; everything that is a fact about the machine
/// goes over there. Fire-and-forget on a background task — a failed push is
/// a log line, not a modal, because the record is rewritten on every
/// structural change anyway.
pub(crate) fn push_remote_layout(&self, id: WorkspaceId, cx: &mut gpui::App) {
let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, id) else {
return;
};
let Some(connection) = remote_connect::RemoteConnections::get(cx, host.host_id()) else {
// Not connected: the layout is pushed again when it is, and the
// remote's own copy is still the last good one.
return;
};
// Marked before the task starts and cleared when it lands, so a
// `WorkspaceChanged` that arrives in between does not pull the record
// this push is replacing back over the top of it.
cx.default_global::<RemoteLinks>().pushing.insert(id);
cx.spawn(async move |cx| {
cx.background_executor()
.spawn(async move {
if let Err(e) = remote_connect::put_remote_layout(&connection, key, record) {
log::warn!(
"could not push the workspace layout to {}: {e}",
host.target
);
}
})
.await;
cx.update(|cx| {
cx.default_global::<RemoteLinks>().pushing.remove(&id);
});
})
.detach();
}
/// `open: true` remote workspaces reconnect at launch.
///
/// **M6 owns the behaviour**; this owns the seam. Startup opens a window per
@@ -798,7 +762,7 @@ impl Tty7App {
return;
};
remote_connect::register(cx);
if remote_connect::RemoteConnections::get(cx, host.host_id()).is_some() {
if remote_connect::HostLinks::get(cx, host.host_id()).is_some() {
// Another window on the same machine got there first. One connection
// per machine is the point — D7's "connect immediately" is about the
// *machine*, and a second link to it would be a second SSH session
@@ -1060,15 +1024,6 @@ pub(crate) fn pane_route_for(cx: &gpui::App, workspace: WorkspaceId) -> crate::t
crate::terminal::PaneRoute::for_workspace(pane_workspace_for(cx, workspace).as_ref())
}
/// The connection for a remote workspace, if this process has one.
pub(crate) fn connection_for(
cx: &mut gpui::App,
workspace: WorkspaceId,
) -> Option<Arc<RemoteHost>> {
let host = WorkspaceStore::remote_ref(cx, workspace)?;
remote_connect::RemoteConnections::get(cx, host.host_id())
}
// ---------------------------------------------------------------------------
// The supervisor (the connection state machine, running)
// ---------------------------------------------------------------------------
@@ -1078,12 +1033,12 @@ pub(crate) fn connection_for(
/// Fast enough that a `Preempted` push turns a window read-only while the user
/// is still looking at the machine they typed on, slow enough to be free: a tick
/// is a hash-map walk over the handful of machines a person has open.
const PUMP_TICK: Duration = Duration::from_millis(250);
pub(crate) const PUMP_TICK: Duration = Duration::from_millis(250);
/// One machine's link, as the supervisor sees it.
///
/// Per **machine**, not per workspace, because that is the granularity a
/// connection actually has (`RemoteConnections` is keyed by [`HostId`], and two
/// connection actually has (`HostLinks` is keyed by [`HostId`], and two
/// windows on one box share a link). Preemption is the one thing that is
/// per-workspace, and it is kept separately for exactly that reason.
struct MachineLink {
@@ -1116,6 +1071,13 @@ pub(crate) struct RemoteLinks {
/// Workspaces taken over, and by whom. Per **workspace**: one machine can
/// hold three of them and lose exactly one.
preempted: std::collections::HashMap<WorkspaceId, String>,
/// Workspaces being taken *back*: [`RemoteLinks::retry_now`] cleared their
/// preemption and the reconnect is in flight. Remembered because the
/// window still shows the pre-takeover layout, and [`finish_attempt`]
/// must rebuild it from the tree whole (`Adopt::Replace`) — the IfEmpty
/// hydration it runs for an ordinary reconnect skips any non-empty
/// window, which is precisely what a preempted window is.
reclaiming: std::collections::HashSet<WorkspaceId>,
/// Machines the user has deliberately disconnected from.
///
/// Without this the supervisor would reconnect on the next tick: it keeps a
@@ -1135,12 +1097,6 @@ pub(crate) struct RemoteLinks {
/// absent from this map has never been seen before, which is **not** the
/// same as having restarted — see [`finish_attempt`].
instances: std::collections::HashMap<HostId, String>,
/// Workspaces this client is pushing a layout for right now.
///
/// Read by [`refresh_remote_workspace`], which skips them: a record we are
/// in the middle of replacing is not one to pull back over the top of
/// ourselves.
pushing: std::collections::HashSet<WorkspaceId>,
/// The start-up sheet queue. Lives here because it is part of the
/// same connection state and has to survive individual windows — the sheet
/// belongs to a machine, not to whichever window happened to ask first.
@@ -1162,6 +1118,18 @@ impl gpui::Global for RemoteLinks {}
/// `remote_connect`'s install mailbox uses, for the identical reason.
static EVENTS: Mutex<Vec<(HostId, ControlEvent)>> = Mutex::new(Vec::new());
/// Point the process-wide control-event observer at [`EVENTS`]. Idempotent
/// (installing the same closure again is harmless), and shared with the local
/// link's pump ([`crate::ui::local_link::LocalLink::install`]) — whichever
/// comes up first, reader threads must never find nobody listening.
pub(crate) fn install_event_observer() {
crate::daemon::control::set_event_observer(Arc::new(|host, event| {
if let Ok(mut queue) = EVENTS.lock() {
queue.push((host, event));
}
}));
}
impl RemoteLinks {
/// Start the supervisor, and make sure control events have somewhere to go.
///
@@ -1169,11 +1137,7 @@ impl RemoteLinks {
/// (the connect flow, opening one, start-up), because any of them can be the
/// first.
pub(crate) fn ensure_running(cx: &mut gpui::App) {
crate::daemon::control::set_event_observer(Arc::new(|host, event| {
if let Ok(mut queue) = EVENTS.lock() {
queue.push((host, event));
}
}));
install_event_observer();
if cx.default_global::<RemoteLinks>().pumping {
return;
}
@@ -1233,7 +1197,12 @@ impl RemoteLinks {
return;
};
let links = cx.default_global::<RemoteLinks>();
links.preempted.remove(&workspace);
if links.preempted.remove(&workspace).is_some() {
// Taking back, not merely reconnecting: the window's layout is
// the pre-takeover one, so the attach that lands must rebuild it
// from the tree rather than trust what it shows.
links.reclaiming.insert(workspace);
}
// Asking to reconnect outranks having asked to disconnect.
links.suspended.remove(&host.host_id());
let link = links.machines.entry(host.host_id()).or_insert(MachineLink {
@@ -1273,11 +1242,11 @@ impl RemoteLinks {
// would keep reading from a socket that is about to be dropped under it.
for (workspace, _) in workspaces_on(cx, host) {
release_panes(cx, workspace);
cx.default_global::<RemoteLinks>()
.preempted
.remove(&workspace);
let links = cx.default_global::<RemoteLinks>();
links.preempted.remove(&workspace);
links.reclaiming.remove(&workspace);
}
remote_connect::RemoteConnections::remove(cx, host);
remote_connect::HostLinks::remove(cx, host);
cx.default_global::<RemoteLinks>().machines.remove(&host);
log::info!("disconnected from a machine at the user's request");
cx.refresh_windows();
@@ -1317,6 +1286,7 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
let forgotten = links.machines.len();
links.machines.clear();
links.preempted.clear();
links.reclaiming.clear();
links.suspended.clear();
// Logged because the *state* it leaves behind is indistinguishable from
// never having connected: `status_of` reads a missing link as
@@ -1338,8 +1308,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
if suspended.contains(&host) {
continue;
}
let live = remote_connect::RemoteConnections::get(cx, host)
.is_some_and(|h| h.client().is_connected());
let live =
remote_connect::HostLinks::get(cx, host).is_some_and(|h| h.client().is_connected());
let attempting = cx
.try_global::<RemoteLinks>()
.and_then(|l| l.machines.get(&host))
@@ -1356,6 +1326,9 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
if became {
changed = true;
log::info!("link to {target} is attached");
// A fresh link means whatever the mirror held is history; the
// full pull re-bases it before deltas resume advancing it.
crate::ui::machine_mirror::MachineMirrors::refresh(cx, host);
}
continue;
}
@@ -1366,8 +1339,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
// The link is down. Drop the dead host object so nothing keeps calling
// into it — a control connection that has gone is the whole
// workspace's lifeline, not one failed request.
if remote_connect::RemoteConnections::get(cx, host).is_some() {
remote_connect::RemoteConnections::remove(cx, host);
if remote_connect::HostLinks::get(cx, host).is_some() {
remote_connect::HostLinks::remove(cx, host);
log::info!("lost the control connection to {target}; reconnecting");
}
@@ -1426,7 +1399,7 @@ fn prune_suspended(
/// window that is not there.
fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> {
let mut out: Vec<(HostId, RemoteTarget)> = Vec::new();
for workspace in &WorkspaceStore::all(cx).workspaces {
for workspace in &WorkspaceStore::all(cx).views {
let Some(host) = workspace.host.as_ref() else {
continue;
};
@@ -1445,7 +1418,7 @@ fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> {
/// belong to.
fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> {
WorkspaceStore::all(cx)
.workspaces
.views
.iter()
.filter(|w| w.open)
.filter_map(|w| {
@@ -1456,14 +1429,11 @@ fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> {
}
/// Apply everything the reader threads pushed since the last tick.
fn drain_events(cx: &mut gpui::App) {
pub(crate) fn drain_events(cx: &mut gpui::App) {
let events = match EVENTS.lock() {
Ok(mut queue) => std::mem::take(&mut *queue),
Err(_) => return,
};
// Read out before the loop: the pull is one round trip per workspace no
// matter how many events asked for it.
let stale = stale_workspaces(&events);
for (host, event) in events {
match event {
// The takeover, arriving. The window goes read-only and
@@ -1481,102 +1451,41 @@ fn drain_events(cx: &mut gpui::App) {
.preempted
.insert(id, by.clone());
release_panes(cx, id);
// The window's tree-sync state goes with the streams: its
// mirror and queue describe a session that just lost the
// workspace, and its `informed` licence must not survive into
// the take-back (see `tree_sync::on_preempted`).
crate::ui::tree_sync::on_preempted(cx, id);
cx.refresh_windows();
}
// Handled by `stale_workspaces` above, in one pull per workspace.
ControlEvent::WorkspaceChanged { .. } => {}
// Another writer edited a workspace tree this client shows: apply
// the delta to the mirror and the live window (or re-pull the
// workspace when it will not apply cleanly).
ControlEvent::Layout { workspace, delta } => {
crate::ui::tree_sync::on_layout_delta(cx, host, &workspace, delta);
}
// The machine dropped deltas for this connection: every mirror of
// it is now wrong in a way no later delta repairs. Re-pull the
// machine whole and rebuild the windows on it — the recovery an
// unappliable delta already uses, here announced by the server
// instead of stumbled into.
ControlEvent::LayoutResync => {
log::info!("{host:?} dropped layout deltas for this client; re-pulling");
crate::ui::machine_mirror::MachineMirrors::refresh(cx, host);
for (workspace, _) in crate::ui::windows::WindowRegistry::open_windows(cx) {
if WorkspaceStore::host_of(cx, workspace) != host {
continue;
}
// A preempted window stays passive; its take-back re-pulls.
if workspace_is_preempted(cx, workspace) {
continue;
}
crate::ui::tree_sync::resync_window_from_tree(cx, workspace);
}
}
other => log::debug!("unhandled control event from {host:?}: {other:?}"),
}
}
for (host, key) in stale {
refresh_remote_workspace(cx, host, key);
}
}
/// The workspaces a batch of events says to re-read, each named once.
///
/// The machine's own record changed — another client of ours moved a tab,
/// renamed a workspace, closed one. The event carries **no record**, only "go
/// and read it again", and B3 is explicit that losing one of these is safe and
/// getting two is safe. That is exactly the licence to collapse a burst into one
/// round trip, and the reason nothing here tries to be incremental: there is no
/// state to keep, so there is none to get wrong.
fn stale_workspaces(events: &[(HostId, ControlEvent)]) -> Vec<(HostId, String)> {
let mut out: Vec<(HostId, String)> = Vec::new();
for (host, event) in events {
if let ControlEvent::WorkspaceChanged { id } = event
&& !out.iter().any(|(h, key)| h == host && key == id)
{
out.push((*host, id.clone()));
}
}
out
}
/// Re-read one workspace's record from the machine that owns it and apply it.
///
/// # Why this cannot interrupt what the user is doing
///
/// It lands in the **store**, not in the window. `apply_remote` writes the three
/// remote-owned fields of the client's `Workspace` entry (`name`, `session`,
/// `last_active`) and nothing rebuilds a live window from that entry while the
/// window is open — a workspace's tabs are built when the window opens or swaps
/// workspaces, and `claimable_session` scrubs a remote entry's layout even then.
/// So there is no path from here to a closed tab, a re-spawned pane or a moved
/// focus; what a user sees change is the workspace's *name*.
///
/// That is deliberate rather than incidental. The remote is the
/// authority for the layout, but the client that has the window open is the one
/// *living* in it, and rearranging somebody's panes underneath them because
/// another machine moved a tab is not a refresh, it is a fight. The remote's
/// layout is what a window opens *from* — on the next connect, reconnect or
/// reopen — and this keeps the copy it will open from current.
///
/// # The one race, and how it is settled
///
/// A push of ours can be in flight when an event arrives (the server excludes
/// the writer, so the event is another client's, but it may describe a moment
/// before our write). Applying it would briefly show that client's name for a
/// workspace we are mid-rename of. So a workspace with a push in flight is
/// skipped: our push is about to become the machine's truth, and B3's "dropping
/// one is safe" is what makes skipping the right move rather than a queue.
fn refresh_remote_workspace(cx: &mut gpui::App, host: HostId, store_key: String) {
let Some(id) = client_id_for(cx, host, &store_key) else {
// A workspace on that machine this client has no window on. Nothing to
// refresh; the record is pulled when it is opened.
log::debug!("remote workspace {store_key} changed on a machine with no window here");
return;
};
if cx.default_global::<RemoteLinks>().pushing.contains(&id) {
log::debug!("skipping the refresh of {id}: this client is mid-push for it");
return;
}
let Some(connection) = remote_connect::RemoteConnections::get(cx, host) else {
// Not connected: the next connect pulls the whole list anyway.
return;
};
cx.spawn(async move |cx| {
let pulled = cx
.background_executor()
.spawn(async move { remote_connect::get_remote_layout(&connection, store_key) })
.await;
match pulled {
Ok(record) => {
cx.update(|cx| {
WorkspaceStore::apply_remote(cx, id, &record);
cx.refresh_windows();
});
}
// The workspace was deleted on the far side. The window stays open
// with what it had — a window is never closed, and least of all
// because another machine decided this one was done with it.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::info!("remote workspace {id} is gone from its machine; keeping the window");
}
Err(e) => log::warn!("could not re-read remote workspace {id}: {e}"),
}
})
.detach();
}
/// Come back to a machine whose server was just replaced.
@@ -1601,7 +1510,7 @@ fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) {
let Some(host) = remote_connect::origin_host(origin) else {
return;
};
remote_connect::RemoteConnections::remove(cx, host);
remote_connect::HostLinks::remove(cx, host);
for (workspace, _) in workspaces_on(cx, host) {
RemoteLinks::retry_now(cx, workspace);
}
@@ -1669,7 +1578,7 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) {
log::info!("took workspace {key} back from {who}");
}
Ok(_) => {}
// A machine that has no workspace store (an older
// A machine that has no machine tree (an older
// server) still serves files; the workspace is usable,
// it simply cannot be claimed exclusively.
Err(e) => log::warn!("could not attach to workspace {key}: {e}"),
@@ -1692,43 +1601,39 @@ fn finish_attempt(
) {
match outcome {
Ok(connected) => {
// The remote's record is the authority for the layout,
// so what came back with the connect replaces what this client had.
let rows = connected.rows.clone();
let instance = connected.host.peer().instance.clone();
let restarted = server_restarted(cx, host, &connected.host);
// The home too, not just the connection: this is the path a machine
// comes back on after a restart or a dropped link, and dropping it
// here is what left "New Workspace" missing on a machine the panel
// was quite happily calling connected.
remote_connect::RemoteConnections::insert(cx, connected.host, connected.home);
for (id, key) in workspaces_on(cx, host) {
if let Some(row) = rows.iter().find(|r| r.id.to_string() == key) {
WorkspaceStore::apply_remote(cx, id, &row.record);
}
cx.default_global::<RemoteLinks>().preempted.remove(&id);
// The same question `restarted` answers, asked of the *record*
// rather than of this process's memory — and it is the only one
// that can answer across a client restart. `instances` is an
// in-memory map, so on a cold launch every machine is a first
// sighting and `restarted` is false; a server replaced while
// this client was closed would sail through, and its recycled
// ids would attach to whatever unrelated shells now hold the
// numbers. `daemon_instance` is on disk and remembers.
let stale = WorkspaceStore::forget_stale_pane_ids(cx, id, &instance);
if restarted || stale {
// Every pane id this workspace holds was minted by a process
// that is gone. Re-attaching them would cost one doomed round
// trip each and leave the window exactly as disconnected as
// it is now, so the window is rebuilt from the layout instead
// — the same thing a local daemon restart does.
rebuild_after_server_restart(cx, id);
remote_connect::HostLinks::insert(cx, connected.host, connected.home);
for (id, _key) in workspaces_on(cx, host) {
let reclaimed = {
let links = cx.default_global::<RemoteLinks>();
// The attach that just landed preempts whoever held the
// workspace, so a still-recorded preemption is one this
// reconnect ends — same situation as an explicit Take
// Back, and rebuilt the same way below.
links.preempted.remove(&id).is_some() | links.reclaiming.remove(&id)
};
if restarted || reclaimed {
// Rebuild from the tree whole. After a server restart
// every pane this window shows lived in a process that is
// gone (a fresh server holds no live panes), so the tree
// lowers each leaf to a revival — fresh shells in the
// recorded cwds, agents resumed. After a take-back the
// panes may well be alive, but the *layout* on screen is
// the pre-takeover one: the IfEmpty hydration below would
// skip this non-empty window and leave it stale — the
// "take back re-pulls whole" the preemption paths promise
// happens here, as a Replace.
crate::ui::tree_sync::resync_window_from_tree(cx, id);
} else {
relink_panes(cx, id);
// A window that came up before its machine did has no panes to
// relink — it opened empty because there was nothing to route
// to. Now there is.
hydrate_window(cx, id);
// A window that came up before its machine did has no panes
// to relink — it opened empty because there was nothing to
// route to. Now there is: fill it from the tree.
crate::ui::tree_sync::hydrate_window_from_tree(cx, id);
}
// Same reason the window had no panes: with the machine
// unreachable there was nothing to ask for its shells, so the
@@ -1824,55 +1729,6 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) {
}
}
/// Build the tabs of a window that opened before its machine was reachable.
///
/// This is the other end of [`crate::core::session::WorkspaceStore::claim`]'s
/// reachability rule. A remote workspace reopened at launch has nowhere to route
/// to yet — the link is still being built — so it opens empty rather than
/// spawning a second set of shells beside the ones still running over there.
/// The layout it *would* have opened from is the entry's cached session, which
/// [`finish_attempt`] has just refreshed from the machine itself, so by the time
/// this runs the window is rebuilding from the authority.
///
/// # What it will not do
///
/// **Only an empty window is touched.** A window with tabs is one the user is
/// working in; rearranging it because a link came back is the same fight
/// [`refresh_remote_workspace`] refuses to pick. That also makes this safe to
/// call on every reconnect — the second one through finds tabs and leaves.
fn hydrate_window(cx: &mut gpui::App, workspace: WorkspaceId) {
let session = match WorkspaceStore::all(cx).get(workspace) {
Some(entry) if entry.is_remote() => entry.session.clone(),
// Local, or an entry that went away while the connect was in flight.
_ => return,
};
if session.tabs.is_empty() {
// Nothing to restore: a workspace that was quit from the home page, or
// a brand-new one. Its window is right as it is.
return;
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade())
else {
return;
};
if !app.read(cx).tabs.is_empty() {
return;
}
log::info!(
"rebuilding {} tab(s) of workspace {workspace} now its machine is reachable",
session.tabs.len()
);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
app.adopt_workspace(workspace, session, window, cx)
});
});
}
/// Whether the machine we just reconnected to is being served by a *different*
/// `tty7-server` process than the one we last spoke to.
///
@@ -1922,55 +1778,6 @@ fn note_instance(
}
}
/// Rebuild a workspace's window after its machine's server was replaced.
///
/// The local analogue is [`Tty7App::restart_daemon_confirmed`], and this is
/// deliberately the same shape: the layout is the thing that survives, and every
/// leaf in it comes back as a fresh shell in its saved cwd. What makes it safe
/// here is only that [`server_restarted`] *knew* — the same rebuild triggered by
/// a guess would be a way to lose running work.
///
/// **The saved pane ids are dropped first**, and that is what makes the resume
/// work rather than being a tidiness measure. `session_to_pane` keeps a remote
/// leaf's id unconditionally (its liveness cannot be probed without a round
/// trip) and lets the attach fail into a spawn *inside* the terminal — by which
/// point the code that would have sent `claude --resume <id>` has already
/// decided it wasn't needed. Clearing the ids up here makes the leaf take the
/// same path a dead local pane takes, so the agent conversation continues.
///
/// Unlike [`hydrate_window`] this does **not** skip a window with tabs. Those
/// tabs are precisely what has to go: every one of them is a pane bound to a
/// process that no longer exists.
fn rebuild_after_server_restart(cx: &mut gpui::App, workspace: WorkspaceId) {
let mut session = match WorkspaceStore::all(cx).get(workspace) {
Some(entry) if entry.is_remote() => entry.session.clone(),
_ => return,
};
if session.tabs.is_empty() {
return;
}
for tab in &mut session.tabs {
tty7_core::core::session::blank_pane_ids(&mut tab.pane);
}
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else {
return;
};
let Some(app) =
crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade())
else {
return;
};
log::info!(
"rebuilding {} tab(s) of workspace {workspace}: its machine is serving a new process",
session.tabs.len()
);
let _ = handle.update(cx, move |_, window, cx| {
app.update(cx, |app, cx| {
app.adopt_workspace(workspace, session, window, cx)
});
});
}
/// Ask the window showing `workspace` to refill its "+" dropdown, now that its
/// machine is answering. No-op for a workspace with no window on screen.
fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) {
@@ -2146,10 +1953,101 @@ pub(crate) fn workspace_accepts_input(cx: &gpui::App, workspace: WorkspaceId) ->
RemoteLinks::status_of(cx, workspace).is_none_or(|s| s.accepts_input())
}
/// Whether another client's session currently holds `workspace`. Read by the
/// delta application, which must leave a preempted window passive — attaching
/// to the usurper's panes would steal the streams they are typing into.
pub(crate) fn workspace_is_preempted(cx: &gpui::App, workspace: WorkspaceId) -> bool {
cx.try_global::<RemoteLinks>()
.is_some_and(|links| links.preempted.contains_key(&workspace))
}
#[cfg(test)]
mod tests {
use super::*;
/// Take Back is `retry_now` on a preempted workspace, and the window it
/// recovers still shows the pre-takeover layout — so clearing the
/// preemption must leave a `reclaiming` mark behind for `finish_attempt`
/// to read, or the landed attach runs its ordinary IfEmpty hydration,
/// skips the non-empty window, and the stale layout survives to roll the
/// other client's edits back on the next save.
#[gpui::test]
fn taking_back_marks_the_workspace_for_a_whole_rebuild(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
// `retry_now` wakes the supervisor, whose first tick resolves the
// machine's route off the config global.
cx.set_global(crate::core::config::Config::default());
let host = RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
);
let view = crate::core::session::WindowView {
host: Some(host),
..Default::default()
};
let id = view.id;
crate::core::session::WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view],
active: None,
},
);
cx.default_global::<RemoteLinks>()
.preempted
.insert(id, "laptop".into());
RemoteLinks::retry_now(cx, id);
let links = cx.default_global::<RemoteLinks>();
assert!(
!links.preempted.contains_key(&id),
"the takeover is being reversed; the read-only state ends now"
);
assert!(
links.reclaiming.contains(&id),
"the attach that lands must know to rebuild this window from the tree"
);
});
}
/// A plain reconnect (never preempted) must not be marked for a rebuild —
/// its panes are alive and re-attachable, and a Replace would tear down
/// views the relink was about to reuse.
#[gpui::test]
fn a_plain_reconnect_is_not_marked_for_a_rebuild(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
cx.set_global(crate::core::config::Config::default());
let host = RemoteRef::new(
RemoteTarget::Alias {
alias: "build-box".into(),
},
WorkspaceId::new(),
);
let view = crate::core::session::WindowView {
host: Some(host),
..Default::default()
};
let id = view.id;
crate::core::session::WorkspaceStore::install_for_test(
cx,
crate::core::session::WindowViews {
views: vec![view],
active: None,
},
);
RemoteLinks::retry_now(cx, id);
assert!(
!cx.default_global::<RemoteLinks>().reclaiming.contains(&id),
"nothing was taken over, so nothing needs the Replace path"
);
});
}
#[test]
fn the_status_strip_speaks_unless_everything_is_working() {
assert_eq!(RemoteStatus::Attached.strip_message("build-box"), None);
@@ -2263,52 +2161,6 @@ mod tests {
);
}
/// Every leaf loses its id, at every depth. A `Split` branch that kept its
/// ids would leave those panes attaching to a dead process — and, worse,
/// skipping the agent resume, because that only fires for a leaf with no id.
#[test]
fn forgetting_pane_ids_reaches_every_leaf() {
use crate::core::session::{SessionAxis, SessionPane};
fn leaf(id: u64) -> SessionPane {
SessionPane::Leaf {
cwd: None,
pane_id: Some(id),
ssh_spec: None,
agent: None,
agent_session_id: None,
agent_launch_argv: None,
}
}
fn ids(pane: &SessionPane, out: &mut Vec<Option<u64>>) {
match pane {
SessionPane::Leaf { pane_id, .. } => out.push(*pane_id),
SessionPane::Split { a, b, .. } => {
ids(a, out);
ids(b, out);
}
}
}
let mut pane = SessionPane::Split {
axis: SessionAxis::Horizontal,
ratio: 0.5,
a: Box::new(leaf(1)),
b: Box::new(SessionPane::Split {
axis: SessionAxis::Vertical,
ratio: 0.5,
a: Box::new(leaf(2)),
b: Box::new(leaf(3)),
}),
};
let forgotten = tty7_core::core::session::blank_pane_ids(&mut pane);
let mut found = Vec::new();
ids(&pane, &mut found);
assert_eq!(found, vec![None, None, None]);
assert_eq!(forgotten, 3, "every dropped claim is counted");
}
// ── The reconnect schedule (no network) ─────────────────────────────────
/// The schedule is fixed: **1/2/4/…/30s capped, retried for ever**.
@@ -2429,82 +2281,8 @@ mod tests {
assert_eq!(q.waiting(), 1);
}
// ── `WorkspaceChanged` → re-read (B3's push, arriving) ───────────────────
// ── The input gate ───────────────────────────────────────────────────────
/// **A burst of changes costs one round trip per workspace.**
///
/// B3's contract for this event is that it means only "read it again", so
/// losing one is safe and getting ten is safe. Collapsing them is the whole
/// of the logic that rule buys — and the thing that keeps a client with a
/// chatty peer from opening a `WorkspaceGet` per keystroke of theirs.
#[test]
fn a_burst_of_changes_asks_for_each_workspace_once() {
let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b"));
let changed = |id: &str| ControlEvent::WorkspaceChanged { id: id.into() };
let events = vec![
(a, changed("w1")),
(a, changed("w1")),
(b, changed("w1")),
(a, changed("w2")),
(a, changed("w1")),
];
assert_eq!(
stale_workspaces(&events),
vec![
(a, "w1".to_string()),
(b, "w1".to_string()),
(a, "w2".to_string()),
],
"one pull per (machine, workspace), in the order they were heard"
);
}
/// The same workspace id on two machines is two workspaces — pane ids and
/// store keys are per machine, and merging them would refresh one window
/// from another box's record.
#[test]
fn a_change_is_scoped_to_the_machine_that_reported_it() {
let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b"));
let events = vec![
(a, ControlEvent::WorkspaceChanged { id: "same".into() }),
(b, ControlEvent::WorkspaceChanged { id: "same".into() }),
];
assert_eq!(stale_workspaces(&events).len(), 2);
}
/// Every other event is somebody else's business. A takeover in particular
/// must not also trigger a pull: it is handled on its own path, and the
/// record has not changed.
#[test]
fn only_a_workspace_change_asks_for_a_re_read() {
let a = host("ssh-alias:a");
let events = vec![
(
a,
ControlEvent::Preempted {
workspace: "w1".into(),
by: "desktop".into(),
},
),
(
a,
ControlEvent::PaneExited {
pane_id: 3,
code: None,
},
),
];
assert!(stale_workspaces(&events).is_empty());
}
// ── The read-only degrade, state by state ───────────────────
/// The degrade in one table: which states are read-only, what the
/// bottom line says, and what the strip offers to do about it.
///
/// `Preempted` reads differently on purpose — "not connected" would be a
/// lie, because the link is usually fine and the workspace is simply
/// somebody else's now.
#[test]
fn every_state_says_what_it_means_for_the_keyboard() {
let cases = [
@@ -2625,7 +2403,7 @@ mod tests {
crate::ui::windows::WindowRegistry::init(cx);
let (host, target) = machine("build-box");
let mut entry = crate::core::session::Workspace::on_remote(RemoteRef::new(
let mut entry = crate::core::session::WindowView::on_remote(RemoteRef::new(
target,
WorkspaceId::new(),
));
@@ -2633,8 +2411,8 @@ mod tests {
let id = entry.id;
WorkspaceStore::install_for_test(
cx,
crate::core::session::Workspaces {
workspaces: vec![entry],
crate::core::session::WindowViews {
views: vec![entry],
active: None,
},
);
+22 -21
View File
@@ -25,13 +25,14 @@
//!
//! # Where the rows come from
//!
//! `session.json` already records remote workspaces (`Workspace::host`), so a
//! The view store records remote workspaces (`WindowView::host`), so a
//! machine's workspaces are listed **without connecting to it** — the client
//! remembers what it saw last time. Connecting only ever *adds*: the remote's
//! own store is the authority, so its rows are merged in when a link exists and
//! anything this client had not heard of shows up then (see [`Group::merge`]).
//! That is what makes "expand a machine" a lazy, cheap gesture rather than a
//! wizard.
//! remembers which ones it saw, and their display facts come from the
//! machine's mirror (`ui::machine_mirror`). Connecting only ever *adds*: the
//! remote's own tree is the authority, so its rows are merged in when a link
//! exists and anything this client had not heard of shows up then (see
//! [`Group::merge`]). That is what makes "expand a machine" a lazy, cheap
//! gesture rather than a wizard.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
@@ -194,7 +195,7 @@ struct Row {
current: bool,
/// Set for a workspace that exists on the remote but has no local record
/// yet: opening it has to claim it first. `None` once it is in
/// `session.json` like any other.
/// the view store like any other.
adopt: Option<Box<RemoteWorkspaceRow>>,
/// This row's id **on its own machine**, for a remote workspace. It is what
/// the remote's list is matched against — the local [`WorkspaceId`] above is
@@ -217,7 +218,7 @@ pub(crate) struct HostSnapshot {
/// it could never be given a group to appear in.
pub target: RemoteTarget,
/// What the remote said it had. The machine's `$HOME` is deliberately *not*
/// here — it lives in `RemoteConnections`, app-wide, because every window
/// here — it lives in `HostLinks`, app-wide, because every window
/// needs it and only one of them ever did the connecting.
pub rows: Vec<RemoteWorkspaceRow>,
}
@@ -317,7 +318,7 @@ impl Tty7App {
{
let app: &App = cx;
let store = WorkspaceStore::all(app);
for w in &store.workspaces {
for w in &store.views {
let (key, label, target) = match w.host.as_ref() {
None => (String::new(), "This Computer".to_string(), None),
Some(r) => {
@@ -341,11 +342,14 @@ impl Tty7App {
});
groups[slot].rows.push(Row {
id: w.id,
name: w.display_name(),
path: w
.dominant_repo()
.or_else(|| w.first_cwd())
.map(|p| crate::ui::home::display_path(&p))
// Both read the machine's mirror — the tree owns the
// layout these used to be derived from. A machine not
// pulled yet (launch's first frames; an unreached remote)
// renders the not-knowing rather than a stale guess.
name: crate::ui::machine_mirror::display_name(app, w)
.unwrap_or_else(|| "Untitled".to_string()),
path: crate::ui::machine_mirror::subject_path(app, w)
.map(|p| crate::ui::home::display_path(std::path::Path::new(&p)))
.unwrap_or_default(),
when: crate::ui::home::relative_time(now, w.last_active),
live: crate::terminal::pane_liveness::liveness_of(app, w),
@@ -450,7 +454,7 @@ impl Tty7App {
// connect, and every reconnect, records the machine's `$HOME` — and
// that row is the only way to make a workspace on a machine, so it
// has no business depending on which window did the connecting.
group.home = remote_connect::RemoteConnections::home(cx, id);
group.home = remote_connect::HostLinks::home(cx, id);
if let Some(snapshot) = self.host_snapshots.get(&id) {
group.merge(&snapshot.rows, now);
}
@@ -485,7 +489,7 @@ impl Tty7App {
}
_ => {}
}
match remote_connect::RemoteConnections::get(cx, target.host_id()) {
match remote_connect::HostLinks::get(cx, target.host_id()) {
Some(_) => Link::Connected,
None => Link::Offline,
}
@@ -558,10 +562,7 @@ impl Tty7App {
/// window's *current* workspace, because the field it opens is the chip. A
/// list needs to rename the row that was aimed at, so it gets its own.
fn switcher_rename(&mut self, id: WorkspaceId, window: &mut Window, cx: &mut Context<Self>) {
let current = WorkspaceStore::all(cx)
.get(id)
.map(|w| w.display_name())
.unwrap_or_default();
let current = crate::ui::machine_mirror::display_name_for(cx, id).unwrap_or_default();
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
input.update(cx, |state, cx| state.focus(window, cx));
let sub = cx.subscribe_in(
@@ -586,7 +587,7 @@ impl Tty7App {
return;
};
let value = input.read(cx).value().trim().to_string();
WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value));
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
crate::ui::windows::refresh_menu(cx);
if id == self.workspace {
self.sync_window_title(window, cx);
+1 -3
View File
@@ -442,9 +442,7 @@ impl Tty7App {
// sweep is rate-limited, and past that gate each machine is only asked
// once its own TTL has run out.
crate::terminal::pane_liveness::sweep(cx);
let current = crate::core::session::WorkspaceStore::all(cx)
.get(self.workspace)
.map(|w| w.display_name())
let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace)
.unwrap_or_else(|| "tty7".to_string());
// First character, uppercased — the whole point is a glyph that is
// recognisably *this* workspace at a glance across windows.
+7 -2
View File
@@ -198,14 +198,19 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
items.push(MenuItem::Separator);
}
}
// From the machine's mirror — the tree owns the layout the name is
// derived from. Before the first pull lands the entry reads as the
// shared fallback; the menu is rebuilt on every roster change anyway.
let name = crate::ui::machine_mirror::display_name(cx, workspace)
.unwrap_or_else(|| "Untitled".to_string());
let label = if *open {
workspace.display_name()
name
} else {
// The age is the useful discriminator among detached ones — several
// may share a repo name.
format!(
"{} — {}",
workspace.display_name(),
name,
crate::ui::home::relative_time(now, workspace.last_active)
)
};
+2967
View File
File diff suppressed because it is too large Load Diff
+115 -107
View File
@@ -97,6 +97,22 @@ impl WindowRegistry {
.or_else(|| registry.windows.first().map(|w| w.workspace))
}
/// The `Tty7App` rendered in `window`, if it is one of ours.
///
/// For code that runs *inside* a window (an element's event handler) but
/// has no line to the app entity — the inverse lookup of
/// [`window_for`](Self::window_for), keyed by the handle instead of the
/// workspace.
pub fn app_in(cx: &mut App, window: &Window) -> Option<gpui::Entity<Tty7App>> {
Self::sweep(cx);
let handle = window.window_handle();
cx.global::<Self>()
.windows
.iter()
.find(|w| w.handle == handle)
.and_then(|w| w.app.upgrade())
}
/// The `Tty7App` showing `workspace`, if one is open.
pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option<WeakEntity<Tty7App>> {
Self::sweep(cx);
@@ -163,8 +179,8 @@ impl WindowRegistry {
}
/// What a *brand-new* workspace's window starts with. Only consulted when the
/// window is opening on a freshly minted workspace — one restored from
/// `session.json` always rebuilds its saved tabs.
/// window is opening on a freshly minted workspace — a known one opens empty
/// and is filled from its machine's tree.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FreshStart {
/// A single default terminal, the way every previous launch of tty7 came
@@ -255,8 +271,8 @@ pub const MENU_SLOTS: usize = 9;
/// visible *somewhere* or it may as well have been deleted.
pub fn menu_order(cx: &App) -> Vec<(WorkspaceId, bool)> {
let all = WorkspaceStore::all(cx);
let mut open: Vec<_> = all.workspaces.iter().filter(|w| w.open).collect();
let mut closed: Vec<_> = all.workspaces.iter().filter(|w| !w.open).collect();
let mut open: Vec<_> = all.views.iter().filter(|w| w.open).collect();
let mut closed: Vec<_> = all.views.iter().filter(|w| !w.open).collect();
open.sort_by(|a, b| b.last_active.cmp(&a.last_active));
closed.sort_by(|a, b| b.last_active.cmp(&a.last_active));
open.into_iter()
@@ -288,6 +304,11 @@ pub struct PaneCountQuery {
}
/// Read the inputs for [`live_pane_count`]. Cheap; UI thread only.
///
/// `None` when the workspace's machine has never been pulled this session —
/// the ids to count live only in its tree, and a prompt about to state "N
/// running sessions will be ended" must say it could not ask rather than
/// count against a guess.
pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQuery> {
let ws = WorkspaceStore::all(cx).get(workspace)?;
Some(PaneCountQuery {
@@ -296,7 +317,7 @@ pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQue
// whichever *local* panes happen to hold those numbers and put a "3
// running sessions will be ended" warning on a workspace that has none.
route: crate::ui::remote_workspace::pane_route_for(cx, workspace),
claimed: ws.pane_ids(),
claimed: crate::ui::machine_mirror::pane_ids(cx, ws)?,
})
}
@@ -390,9 +411,7 @@ fn confirm_destructive(
verb: &'static str,
act: fn(&mut App, WorkspaceId),
) {
let name = WorkspaceStore::all(cx)
.get(workspace)
.map(|w| w.display_name())
let name = crate::ui::machine_mirror::display_name_for(cx, workspace)
.unwrap_or_else(|| "this workspace".to_string());
let query = pane_count_query(cx, workspace);
let handle = window.window_handle();
@@ -457,21 +476,23 @@ fn confirm_destructive(
/// Callers confirm first unless [`live_pane_count`] answered zero; with nothing
/// running there is nothing to lose.
pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) {
stop_workspace_keeping(cx, workspace, ClearedLayout::Push);
let doomed = doomed_pane_ids(cx, workspace);
stop_workspace_keeping(cx, workspace, doomed);
}
/// What to do with the record once its panes are dead.
#[derive(Clone, Copy, PartialEq)]
enum ClearedLayout {
/// Send it to the machine that owns it — the workspace is going to be
/// reopened, and it must not reopen claiming panes that no longer exist.
Push,
/// Leave it alone: the caller is about to delete the record outright, and a
/// push racing that delete could put the workspace back on the machine.
Discard,
/// The pane ids stopping or deleting `workspace` must kill, per its machine's
/// mirror. Read this **before** any operation that removes the workspace from
/// the mirror — `fire_workspace_op(WorkspaceRemove)` folds the removal in
/// synchronously ([`crate::ui::machine_mirror::MachineMirrors::note_workspace_op`]),
/// and a list read after it is always empty.
fn doomed_pane_ids(cx: &App, workspace: WorkspaceId) -> Vec<u64> {
WorkspaceStore::all(cx)
.get(workspace)
.and_then(|ws| crate::ui::machine_mirror::pane_ids(cx, ws))
.unwrap_or_default()
}
fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) {
fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) {
// A remote workspace's panes live on the remote server, and its pane ids are
// *that* daemon's. Sending them here would not fail — it would succeed
// against whatever local panes happen to hold those numbers, killing a
@@ -482,10 +503,6 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared
.get(workspace)
.map(|w| w.host_id())
.unwrap_or(crate::ui::host_ops::HostId::LOCAL);
let ids = WorkspaceStore::all(cx)
.get(workspace)
.map(|ws| ws.pane_ids())
.unwrap_or_default();
if !ids.is_empty() {
// Off the UI thread: each of these dials `route`, and on a remote
// workspace that is an SSH channel per pane. Stopping a four-pane
@@ -524,98 +541,39 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared
// half-finished action.
close_window_for(cx, workspace);
WorkspaceStore::close_window(cx, workspace);
// Last, and after the window is gone so nothing records the old layout back
// over it: the ids we just killed are dead by our own hand, and a record
// that still claims them reopens into panes that cannot be attached to.
// Locally that is invisible (`alive_panes_on` asks the daemon and gets the
// same answer); on a remote workspace nobody asks, so the stale id is the
// whole difference between reopening onto fresh shells with the agent
// conversation resumed and reopening onto `tty7 — disconnected`.
forget_killed_panes(cx, workspace, cleared);
// No client-side bookkeeping about the panes remains to correct: the kills
// above end the PTYs, the machine's own pane server observes each death,
// and the tree's records flip to `live: false` — exactly the state the
// next open reads as "revive with a fresh shell".
refresh_menu(cx);
}
/// Drop `workspace`'s pane ids, and tell the machine that owns the record.
///
/// The push is not optional for a remote workspace that is being kept: design
/// The remote's `workspaces.json` is the authority, so reopening pulls
/// its copy over the client's ([`WorkspaceStore::apply_remote`]) and a
/// local-only edit would be undone by the next open — which is the open this
/// exists for.
fn forget_killed_panes(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) {
if !WorkspaceStore::forget_pane_ids(cx, workspace) {
return;
}
if cleared == ClearedLayout::Discard {
return;
}
let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, workspace) else {
return;
};
let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else {
// Not connected, so the panes were not killed either — `kill_pane_on`
// needs the same route. The client's copy is still worth clearing: it
// is what a reconnect pushes back up.
log::info!(
"ended sessions on {} without reaching it; the cleared layout goes up on reconnect",
host.target
);
return;
};
cx.background_executor()
.spawn(async move {
if let Err(e) = crate::ui::remote_connect::put_remote_layout(&connection, key, record) {
log::warn!(
"could not tell {} its workspace's panes are gone: {e}",
host.target
);
}
})
.detach();
}
/// Delete a workspace outright: stop it, then forget it entirely. Irreversible
/// — nothing about the layout survives.
pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) {
// Delete it on the machine that owns it first, while the pointer to it is
// still on file. Doing this after `WorkspaceStore::remove` would leave the
// record stranded on the remote with no way left to name it.
delete_on_remote(cx, workspace);
// …and the stop that follows must not push the emptied layout back up: the
// delete above is in flight on a background task, and a push landing after
// it would recreate the record it just removed.
stop_workspace_keeping(cx, workspace, ClearedLayout::Discard);
let doomed = delete_from_tree(cx, workspace);
stop_workspace_keeping(cx, workspace, doomed);
WorkspaceStore::remove(cx, workspace);
release_unused_hosts(cx);
refresh_menu(cx);
}
/// Forget a remote workspace on the machine that owns it (the
/// remote's `workspaces.json` is the authority, so deleting only the client's
/// pointer would leave the workspace there and reappear on the next connect).
/// The tree half of a delete, in the one order that works: read the kill list
/// off the machine mirror **before** firing `WorkspaceRemove`, because firing
/// folds the removal into that mirror on the way out and the list read
/// afterwards is empty — which is how "N running sessions will be ended" once
/// ended zero. Answers the panes the caller must kill.
///
/// A no-op for a local workspace, and for a remote one this client is not
/// currently connected to — there is no way to reach the record, and the delete
/// is a user action rather than something to queue and replay later.
fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) {
let Some(host) = WorkspaceStore::remote_ref(cx, workspace) else {
return;
};
let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else {
log::info!(
"deleting the local pointer to a workspace on {} without reaching it",
host.target
);
return;
};
let key = host.store_key();
cx.background_executor()
.spawn(async move {
if let Err(e) = crate::ui::remote_connect::delete_remote_workspace(&connection, key) {
log::warn!("could not delete the workspace on {}: {e}", host.target);
}
})
.detach();
/// The op itself still goes before `WorkspaceStore::remove`: the tree is where
/// every other client (and the next launch) lists workspaces from, and firing
/// after the entry is gone would leave it stranded with no way to name it.
fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec<u64> {
let doomed = doomed_pane_ids(cx, workspace);
crate::ui::tree_sync::fire_workspace_op(cx, workspace, |ws| {
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
});
crate::ui::tree_sync::forget(cx, workspace);
doomed
}
/// Drop the connection to any machine no workspace points at any more.
@@ -625,14 +583,14 @@ fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) {
/// careful would tear down a live sibling window's host mid-call.
fn release_unused_hosts(cx: &mut App) {
let live: Vec<_> = WorkspaceStore::all(cx)
.workspaces
.views
.iter()
.filter(|w| w.is_remote())
.map(|w| w.host_id())
.collect();
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
if !id.is_local() && !live.contains(&id) {
crate::ui::remote_connect::RemoteConnections::remove(cx, id);
crate::ui::remote_connect::HostLinks::remove(cx, id);
}
}
}
@@ -657,11 +615,11 @@ fn close_window_for(cx: &mut App, workspace: WorkspaceId) {
return;
}
let (fresh, session) = WorkspaceStore::claim(cx, None);
let fresh = WorkspaceStore::claim(cx, None);
WindowRegistry::rebind(cx, workspace, fresh);
let _ = handle.update(cx, |_, window, cx| {
app.update(cx, |app, cx| {
app.adopt_workspace(fresh, session, window, cx)
app.adopt_workspace(fresh, crate::core::session::Session::default(), window, cx)
});
});
}
@@ -845,4 +803,54 @@ mod tests {
);
}
}
/// The regression the delete order guards against: `WorkspaceRemove` is
/// folded into the machine mirror synchronously on its way out, so a kill
/// list read *after* firing it is always empty — the confirm prompt said
/// "3 running sessions will be ended" and the delete then ended none.
/// `delete_from_tree` must hand back the panes the mirror listed before
/// the removal blanked it.
#[gpui::test]
fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror(
cx: &mut gpui::TestAppContext,
) {
use crate::core::session::{WindowView, WindowViews};
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace};
cx.update(|cx| {
let view = WindowView::default();
let id = view.id;
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![view],
active: None,
},
);
crate::ui::machine_mirror::MachineMirrors::install(
cx,
crate::ui::host_ops::HostId::LOCAL,
Machine {
workspaces: vec![TreeWorkspace {
id,
tabs: vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)],
..TreeWorkspace::default()
}],
panes: vec![PaneRecord::new(1), PaneRecord::new(2), PaneRecord::new(3)],
},
);
let doomed = delete_from_tree(cx, id);
assert_eq!(
doomed,
vec![1, 2, 3],
"every session the confirm prompt counted must be on the kill list"
);
assert!(
doomed_pane_ids(cx, id).is_empty(),
"the removal has been folded into the mirror — which is exactly why \
the list must be read first"
);
});
}
}