mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
@@ -9606,7 +9606,6 @@ dependencies = [
|
||||
name = "tty7-server"
|
||||
version = "26.7.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tty7-core",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+182
-1328
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+649
-461
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+115
-107
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user