Files
tty7/crates/tty7-server/tests/scrollback_restore.rs
T
l0ng-ai cf0e8f02e6 feat(sidebar): pinned groups above auto groups (#962)
* feat(sidebar): keep pinned groups on the workspace and derive the rest

Replace the sidebar's hand-made groups with the model from #955: the sidebar
groups tabs by repo automatically, and you pin what you want to keep.

- Pinned groups (`PinnedGroup`: id, optional name, optional folder, fold) are
  stored on the workspace in the machine tree, in display order, and a tab
  points at one by `GroupId`. Everything else is an auto group worked out
  every frame and never stored: by repo home, or by `user@host` for an SSH
  pane — native or a shell that ssh'd onward — so `/home/ubuntu` on two
  machines no longer lands under one header.
- A tab whose cwd *enters* a pinned folder joins it (deepest folder wins; a
  repo home equal to the folder counts, which keeps worktrees with their
  repo). It is edge-triggered through `EntryWatch`, so a tab dragged out
  while still inside the folder stays out until it leaves and comes back, and
  a tab restored at launch is not pulled in by where it already sits.
- Groups sync as one `WorkspaceSetGroups` / `GroupsChanged`, pushed only from
  an edit and adopted from every pull, so a fresh window can never push an
  empty set over the workspace's. The machine hands tabs naming a dropped
  group back to auto grouping in the same mutation. Control dialect → v11.
- Config: `sidebar_grouping` (three modes) and `sidebar_collapsed_groups` give
  way to one `sidebar_auto_grouping` toggle; folds live with the workspace.
- `tty7 tab ls` reports the pinned group a tab is in (name or folder leaf;
  JSON carries id, name and folder).

* feat(sidebar): draw pinned groups above a divider, with their own gestures

The sidebar now reads as two halves: the groups you keep, in the order you
put them, then a divider, then the groups it works out (Arc-style).

- Pinned headers drag-reorder among themselves (their own reorder surface, so
  a pinned header cannot be dropped among the derived ones); the order lands
  on the workspace's group list, not on the tabs.
- An auto header carried above the divider is pinned when let go. With
  nothing pinned yet the divider appears during that drag as a "Drop here to
  pin" zone, since a hairline at the top of the list is nothing to aim at.
- A tab kept in a pinned group and dropped anywhere below the divider goes
  back to auto grouping; the divider lights to say so.
- An empty pinned group stays, with a "+ New Tab" row that opens a tab in its
  folder (or where ⌘T would, for a label group) and files it there.
- Folder groups carry a pin mark that unpins on click and a tooltip with the
  folder; auto headers show pin and "+" on hover.
- Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current
  Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto —
  Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly.

* feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette

Every way into a pinned group the design calls for:

- Drop a folder from Finder or Explorer onto the sidebar to pin it (a local
  workspace only — a dropped path is this machine's, and a folder group keeps
  a directory on the workspace's host). Files are let fall.
- "Pin as Group" on a folder in the file tree, on local and remote workspaces
  alike, since the tree and the group are both on the workspace's host.
- Palette "New Group" makes an empty label group and opens its name for
  typing; "Open Folder as Group…" picks a folder with the system picker, pins
  it and opens a tab in it. The picker browses this computer, so that one is
  not offered on a remote workspace.
- Tab right-click "Move to Group" lists the pinned groups plus "New Group…",
  which files the tab in a fresh label group with its name open for typing.

Pinning a folder already pinned hands back the group that keeps it rather
than making a second one to split its tabs with.

* fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder

A window draws its first frames before its copy of the workspace's groups
lands, so every tab's entry watch recorded "in no folder" — and the groups
landing then read as each tab walking into its folder. A restored tab, or one
dragged out of its folder group, was pulled back in on every launch.

Groups adopted from a pull or from another window's `GroupsChanged` now start
every tab's watch over from where it is; only this window's own pin gathers
the tabs inside the folder, and says so tab by tab. A tab also goes up with
the group it names even when the window does not know that group yet, so a
sync in that same gap cannot send every kept tab back to auto grouping.

* docs(sidebar): describe pinned and auto groups, and log the change

Rewrite the sidebar page's grouping section around "grouped by repo
automatically; pin what you want to keep": the divider, folder and label
groups, the edge-triggered join, every way to pin, and the header menus. The
configuration reference swaps `sidebar_grouping` for `sidebar_auto_grouping`,
the CLI reference describes the GROUP column as the pinned group, and the
changelog gains an Unreleased entry (#955).

* fix(sidebar): file a tab opened by the CLI in a pinned folder into it

A tab that reaches a window as TabCreated — from `tty7 tab new` or another
window — started its entry watch as a restored tab, so opening one inside a
pinned folder left it in the auto group below. It is as new as a tab opened
here, and now joins the folder like one; every window that hears of it
reaches the same answer.

* test(machine): build the group sets in their initializers

Clippy's field_reassign_with_default on the two WorkspaceGroups the
set-groups test assembles.

* fix(sidebar): draw restored tabs in their auto group, and title by repo again

Auto groups are not stored, so after a restart every tab sat in Ungrouped
until its own repo probe came back, then jumped; before pinned groups the
stored repo key put it in place on the first frame. Each tab now carries
`last_auto`, the auto group it last resolved to, as a hint: stored with the
tab, sent up alongside its group in `TabSetGroup` whenever the live answer
moves, and used to draw the tab until the probe answers. The probe always wins
and rewrites the hint, and the hint never outranks a pinned group or the
folder-entry rule. Another window's hint only fills a gap, so two windows can
never bounce a disagreement between them.

The workspace's fallback title regained the repo majority it lost: the most
common pinned folder first, then the repo most unpinned tabs were last filed
under (a worktree counting toward its repo home), then a pane's cwd.

* refactor: drop what the new sidebar left unused, and two clippy findings

- `TerminalView::native_ssh_cwd` and its helper existed for the sidebar's old
  folder grouping of native SSH panes; an SSH tab now groups by host, and
  nothing else read it.
- The file tree's context menu takes `cx` instead of `danger` and the new
  groups flag, back to the argument count it had on main.
- A title test builds its workspace in the initializer.
2026-09-25 16:53:26 +08:00

405 lines
14 KiB
Rust

//! A pane's screen survives the daemon being stopped and started.
//!
//! The unit tests under `daemon::scrollback` cover the file: it round-trips, it
//! is trimmed from the front, the sweep keeps what it is told to keep. None of
//! that answers the question the feature exists for, which is whether a window
//! that comes back to a restarted daemon is shown what its panes had on them.
//! That answer involves a real daemon writing a real snapshot, dying, and a
//! second daemon handing the bytes to the client that asks — so this runs all
//! of it and reads the wire.
//!
//! The ordering is the whole difficulty. A client cannot ask for a restore
//! until the daemon is listening, so anything the daemon throws away *while
//! starting up* is thrown away before the only party who knows what is still
//! wanted has been able to say so.
use std::io::Write as _;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use tty7_core::client::{ControlClient, PaneClient};
use tty7_core::core::machine::PaneSeed;
use tty7_core::daemon::control::{ControlHello, ControlRequest, ReplyOk};
use tty7_core::daemon::protocol::{ClientMsg, DaemonMsg, RestoreFrom, ShellSpec, WinSize};
use tty7_core::daemon::transport;
const READY_WITHIN: Duration = Duration::from_secs(30);
const STREAM_WITHIN: Duration = Duration::from_secs(30);
const STOP_WITHIN: Duration = Duration::from_secs(30);
const MARKER: &[u8] = b"tty7_screen_kept";
/// One instance's directories, outliving the daemons that serve them — the
/// point of the test is what is on disk between two of them.
struct Instance {
dir: tempfile::TempDir,
}
impl Instance {
fn new() -> Instance {
// No config: keeping each pane's screen is what the daemon does, not
// something it is asked to do.
Instance {
dir: tempfile::TempDir::new().unwrap(),
}
}
fn path(&self) -> &std::path::Path {
self.dir.path()
}
/// What the daemon writes down for clients to find it by: a socket on unix,
/// a port-and-token file on Windows.
fn endpoint(&self) -> PathBuf {
#[cfg(unix)]
let name = "daemon.sock";
#[cfg(windows)]
let name = "daemon.port";
self.dir.path().join(name)
}
fn panes(&self) -> PaneClient {
PaneClient::at(self.endpoint())
}
fn control_endpoint(&self) -> PathBuf {
#[cfg(unix)]
let name = "control.sock";
#[cfg(windows)]
let name = "control.port";
self.dir.path().join(name)
}
fn snapshot_of(&self, pane_id: u64) -> Option<Vec<u8>> {
std::fs::read(
self.dir
.path()
.join("scrollback")
.join(format!("{pane_id}.bin")),
)
.ok()
}
/// The tree a window would have left behind: one workspace, one tab, and
/// that tab standing on the pane whose screen we want back.
fn record_tab_on(&self, pane_id: u64) {
let tree = format!(
r#"{{
"workspaces": [
{{
"id": "11111111-2222-3333-4444-555555555555",
"name": null,
"last_active": 1786343761,
"tabs": [
{{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"name": null,
"group": null,
"root": {{ "Leaf": {{ "pane": {pane_id} }} }}
}}
],
"active_tab": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
}}
],
"panes": [
{{ "id": {pane_id}, "cwd": null, "title": "", "ssh_spec": null, "agent": null, "live": false }}
]
}}"#
);
std::fs::write(self.dir.path().join("machine.json"), tree).unwrap();
}
fn start(&self) -> Running {
let child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
.arg("--daemon")
.arg("--config-dir")
.arg(self.path())
.env("TTY7_DATA_DIR", self.path())
.env("TTY7_CONTROL_SOCK", self.path().join("control.sock"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start tty7-server --daemon");
let running = Running {
child,
stopped: false,
};
let deadline = Instant::now() + READY_WITHIN;
loop {
if self.panes().version().is_ok() {
return running;
}
assert!(
Instant::now() < deadline,
"the daemon did not open its pane endpoint within {READY_WITHIN:?}"
);
std::thread::sleep(Duration::from_millis(50));
}
}
/// Ask the daemon to go, the way the restart in Settings asks. This is the
/// path that takes each pane's last snapshot on the way out, so a test that
/// killed the process instead would be testing the periodic writer.
fn stop(&self, mut running: Running) {
let mut stream =
transport::connect_endpoint_at(&self.endpoint()).expect("connect to ask for shutdown");
ClientMsg::Shutdown
.encode(&mut stream)
.expect("send Shutdown");
stream.flush().ok();
drop(stream);
let deadline = Instant::now() + STOP_WITHIN;
loop {
match running.child.try_wait() {
Ok(Some(_)) => {
running.stopped = true;
return;
}
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(50));
}
Ok(None) => panic!("the daemon did not exit within {STOP_WITHIN:?}"),
Err(e) => panic!("waiting for the daemon failed: {e}"),
}
}
}
/// The `Spawn` a window sends for a pane whose `Attach` found nothing: a
/// new shell, asked to open showing what the dead one had.
fn spawn_restoring(&self, dead: u64) -> (u64, Vec<u8>) {
let mut stream =
transport::connect_endpoint_at(&self.endpoint()).expect("connect to spawn");
ClientMsg::Spawn {
cwd: None,
size: size(),
shell: Some(interactive_shell()),
owner: None,
workspace: None,
restore: Some(RestoreFrom {
pane_id: dead,
banner: Some("the shell below is new".to_string()),
}),
allow_remote_clipboard_write: false,
}
.encode(&mut stream)
.expect("send Spawn");
let pane_id = match DaemonMsg::read(&mut stream) {
Ok(DaemonMsg::Spawned { pane_id }) => pane_id,
other => panic!("expected Spawned, got {other:?}"),
};
// Everything the daemon replays sits in the socket ahead of whatever
// the new shell writes, so a bounded drain is enough: the replay is
// already queued by the time `Spawned` is read.
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("bound the drain");
let mut replayed = Vec::new();
while let Ok(msg) = DaemonMsg::read(&mut stream) {
match msg {
DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => {
replayed.extend_from_slice(&bytes)
}
_ => {}
}
}
(pane_id, replayed)
}
}
struct Running {
child: Child,
stopped: bool,
}
impl Drop for Running {
fn drop(&mut self) {
if !self.stopped {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn size() -> WinSize {
WinSize {
cols: 100,
rows: 30,
cell_w: 8,
cell_h: 16,
}
}
fn interactive_shell() -> ShellSpec {
#[cfg(unix)]
let program = "/bin/sh";
#[cfg(windows)]
let program = "cmd.exe";
ShellSpec {
program: program.into(),
args: Vec::new(),
args_are_tty7_defaults: false,
}
}
fn windows_contain(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn collect_until(session: &mut tty7_core::client::PaneSession, marker: &[u8]) -> Vec<u8> {
let mut seen: Vec<u8> = Vec::new();
loop {
match session.recv() {
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
seen.extend_from_slice(&bytes);
if windows_contain(&seen, marker) {
return seen;
}
}
Ok(DaemonMsg::Exited { code }) => panic!(
"the pane exited ({code:?}) before {:?} appeared; saw {:?}",
String::from_utf8_lossy(marker),
String::from_utf8_lossy(&seen)
),
Ok(_) => {}
Err(e) => panic!(
"the pane stream ended early: {e}; saw {:?}",
String::from_utf8_lossy(&seen)
),
}
}
}
/// Put a marker on a pane's screen and give the daemon back the pane's id.
fn pane_showing_the_marker(instance: &Instance) -> u64 {
let mut session = instance
.panes()
.spawn(None, size(), Some(interactive_shell()), None, None)
.expect("spawn a pane");
session
.set_recv_timeout(Some(STREAM_WITHIN))
.expect("bound the stream reads");
let pane_id = session.pane_id();
session
.input(format!("echo {}\r", String::from_utf8_lossy(MARKER)).as_bytes())
.expect("the shell takes input");
collect_until(&mut session, MARKER);
// Detach rather than kill: the pane outlives this connection, which is the
// state a daemon restart finds its panes in.
session.detach().expect("detach");
pane_id
}
/// The case the feature is for: nothing has told the new daemon anything yet,
/// because the window that would tell it is still waiting for it to listen.
#[test]
fn a_restarted_daemon_still_has_the_screen_when_the_window_asks() {
let instance = Instance::new();
let running = instance.start();
let dead = pane_showing_the_marker(&instance);
instance.stop(running);
let stored = instance
.snapshot_of(dead)
.expect("the shutdown wrote the pane's screen");
assert!(
windows_contain(&stored, MARKER),
"the snapshot on disk does not hold the pane's screen"
);
let _restarted = instance.start();
let (_new_pane, replayed) = instance.spawn_restoring(dead);
assert!(
windows_contain(&replayed, MARKER),
"the restarted daemon did not give the pane back its screen; \
the client received {:?}",
String::from_utf8_lossy(&replayed)
);
}
/// What a pane is running has to reach the tree, because that is the only
/// place a window rebuilding the pane can read it from. Without it the rebuild
/// falls back to the default shell, which is how a restart turns a bash pane
/// into a PowerShell one.
#[test]
fn the_tree_records_the_shell_a_pane_is_running() {
let instance = Instance::new();
let _running = instance.start();
let pane = pane_showing_the_marker(&instance);
// A pane reaches the tree by being put in a tab, which is what the window
// does right after it spawns one — carrying the same seed it spawned with.
// Until then the daemon has nothing to record its facts against, so this is
// the pane's first chance to say what it is running, and for a pane that
// then sits at a prompt it is the only one: the daemon's own observation
// rides on a fact *changing*, and a pane's shell never does.
let control = ControlClient::connect_at(
&instance.control_endpoint(),
&ControlHello::host_rpc("probe", "probe"),
)
.expect("control handshake");
let ws = match control
.request(ControlRequest::WorkspaceCreate {
name: Some("probe".into()),
workspace: None,
})
.expect("create a workspace")
{
ReplyOk::WorkspaceTree(ws) => *ws,
other => panic!("expected WorkspaceTree, got {other:?}"),
};
control
.request(ControlRequest::TabCreate {
workspace: ws.id,
at: None,
pane: PaneSeed {
shell: Some(interactive_shell()),
..PaneSeed::bare(pane)
},
tab: None,
})
.expect("put the pane in a tab");
let wanted = interactive_shell().program;
let deadline = Instant::now() + STREAM_WITHIN;
loop {
let tree =
std::fs::read_to_string(instance.path().join("machine.json")).unwrap_or_default();
// The record is keyed by pane id and the program is a plain string in
// it; matching on both together is enough to say this pane's shell —
// not some other pane's — reached the tree.
if tree.contains(&format!("\"id\": {pane}")) && tree.contains(&wanted) {
return;
}
assert!(
Instant::now() < deadline,
"the tree never recorded pane {pane}'s shell ({wanted}); it holds: {tree}"
);
std::thread::sleep(Duration::from_millis(100));
}
}
/// The same thing for an instance whose tree still names the pane. This one
/// passes on its own merits today; it is here so a fix for the case above
/// cannot be one that quietly stops honouring the tree.
#[test]
fn a_screen_comes_back_when_the_tree_still_names_its_pane() {
let instance = Instance::new();
let running = instance.start();
let dead = pane_showing_the_marker(&instance);
instance.stop(running);
instance.record_tab_on(dead);
let _restarted = instance.start();
let (_new_pane, replayed) = instance.spawn_restoring(dead);
assert!(
windows_contain(&replayed, MARKER),
"a pane the tree still names came back blank; the client received {:?}",
String::from_utf8_lossy(&replayed)
);
}