mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-04 08:01:59 +00:00
* 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.
405 lines
14 KiB
Rust
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)
|
|
);
|
|
}
|