From 0d98ff85ad26df93f093bf39206b255de66dbcd6 Mon Sep 17 00:00:00 2001 From: Cedric ANTHONY Date: Sat, 5 Sep 2026 20:42:05 +0200 Subject: [PATCH] `HDC-35 | [feat]: restore docks after Herdr server restarts` --- README.md | 9 + herdr-plugin.toml | 7 + src/config.rs | 17 +- src/host/client.rs | 10 +- src/host/dock_state.rs | 605 +++++++++++++++++++++++++++++++++++++++++ src/host/launch.rs | 292 +++++++++++++++++++- src/host/mod.rs | 27 ++ src/main.rs | 84 +++++- tests/config.rs | 4 + tests/dock_restore.rs | 411 ++++++++++++++++++++++++++++ tests/host_client.rs | 39 +++ tests/host_launch.rs | 74 +++++ tests/launch_binary.rs | 5 +- 13 files changed, 1571 insertions(+), 13 deletions(-) create mode 100644 src/host/dock_state.rs create mode 100644 tests/dock_restore.rs diff --git a/README.md b/README.md index 78ee946..11993f3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ The dock is a narrow Herdr pane rather than an extension of the native sidebar. That keeps the plugin on Herdr's public plugin surface, compatible with upstream Herdr, and independently installable. +Docks survive Herdr server restarts: toggling a dock records where it was +open, and after `herdr server stop` and a fresh start a startup hook re-opens +those docks — without stealing focus and at their saved width. The plain shell +pane Herdr leaves in the dock's old slot is not plugin-owned after the +restart, so close it once manually. Opt out with `[dock] +restore_on_startup = false` in the plugin config +(`herdr plugin config-dir herdr-context`). + The goal is simple: **know where the project stands — files, changes, conversations — without leaving the tab.** @@ -204,6 +212,7 @@ warning in the dock. ```toml [dock] initial_width = 40 # 24..60 +restore_on_startup = true # re-open docks after a Herdr server restart [ui] display_mode = "ascii" # ascii, unicode, or nerd diff --git a/herdr-plugin.toml b/herdr-plugin.toml index ba680c0..78079ab 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -19,3 +19,10 @@ id = "toggle" title = "herdr-context: toggle dock" contexts = ["workspace", "tab", "pane"] command = ["./target/release/herdr-context", "toggle"] + +[[startup]] +command = ["./target/release/herdr-context", "restore"] + +[[events]] +on = "pane.exited" +command = ["./target/release/herdr-context", "on-event"] diff --git a/src/config.rs b/src/config.rs index a333eff..aac74f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -177,6 +177,7 @@ impl ConfigLoad { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DockConfig { initial_width: u16, + restore_on_startup: bool, } impl DockConfig { @@ -184,12 +185,19 @@ impl DockConfig { pub const fn initial_width(self) -> u16 { self.initial_width } + + /// Whether the restore startup hook re-opens docks after a Herdr restart. + #[must_use] + pub const fn restore_on_startup(self) -> bool { + self.restore_on_startup + } } impl Default for DockConfig { fn default() -> Self { Self { initial_width: DEFAULT_DOCK_WIDTH, + restore_on_startup: true, } } } @@ -684,7 +692,7 @@ fn parse_config(value: &toml::Value) -> ConfigLoad { if let Some(table) = optional_table(root, "dock", &mut warnings) { warn_unknown_fields( table, - &["initial_width"], + &["initial_width", "restore_on_startup"], "dock.unknown_field", &mut warnings, ); @@ -696,6 +704,13 @@ fn parse_config(value: &toml::Value) -> ConfigLoad { &mut warnings, ) .unwrap_or(DEFAULT_DOCK_WIDTH); + + config.dock.restore_on_startup = parse_bool( + table.get("restore_on_startup"), + "dock.restore_on_startup", + &mut warnings, + ) + .unwrap_or(true); } if let Some(table) = optional_table(root, "ui", &mut warnings) { diff --git a/src/host/client.rs b/src/host/client.rs index 258d7cf..e313604 100644 --- a/src/host/client.rs +++ b/src/host/client.rs @@ -498,7 +498,7 @@ impl HostClient for CommandHostClient { let mut origin_pane_id = OsString::from("HERDR_CONTEXT_ORIGIN_PANE_ID="); origin_pane_id.push(request.origin_pane_id().as_str()); let pane_cwd = self.plugin_root.as_deref().unwrap_or_else(|| request.cwd()); - let args = vec![ + let mut args = vec![ OsString::from("plugin"), OsString::from("pane"), OsString::from("open"), @@ -518,8 +518,14 @@ impl HostClient for CommandHostClient { origin_cwd, OsString::from("--env"), origin_pane_id, - OsString::from("--focus"), ]; + if request.focus() { + args.push(OsString::from("--focus")); + } else { + // Herdr focuses a newly opened pane by default; the explicit + // negation is what keeps restore from stealing focus. + args.push(OsString::from("--no-focus")); + } let result = self.invoke(args)?; expect_type(&result, "plugin_pane_opened")?; let pane_id = required_string(&result, "/plugin_pane/pane/pane_id", "opened pane id")?; diff --git a/src/host/dock_state.rs b/src/host/dock_state.rs new file mode 100644 index 0000000..b68f1db --- /dev/null +++ b/src/host/dock_state.rs @@ -0,0 +1,605 @@ +//! Persisted "dock open in (workspace, tab)" records for one plugin state dir. +//! +//! Toggle records where a dock is open, a `restore` startup hook re-opens those +//! docks after a Herdr server restart, and a `pane.exited` event hook prunes +//! records whose dock pane died out-of-band. State is keyed by the server's +//! socket path because named sessions run separate servers against one global +//! state dir; without the key, one server's restore could steal another +//! server's records. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::fs::{File, OpenOptions, TryLockError}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +use serde::{Deserialize, Serialize}; + +use super::launch::{LockError, ensure_private_directory, open_private_lock_file}; + +const STATE_FILE_VERSION: u8 = 1; +const STATE_FILE_NAME: &str = "docks.json"; +const LOCK_FILE_NAME: &str = "docks.lock"; +const TMP_FILE_NAME: &str = "docks.json.tmp"; +const LOCK_TIMEOUT: Duration = Duration::from_secs(2); +/// Above the worst legitimate content (8 servers x 256 short records) so a +/// full file still loads while hostile or corrupted files stay bounded. +const MAX_STATE_BYTES: u64 = 512 * 1024; +const MAX_SERVERS: usize = 8; +const MAX_RECORDS_PER_SERVER: usize = 256; + +/// One persisted dock: the workspace tab it docks into, its pane, and width. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DockRecord { + pub workspace_id: String, + pub tab_id: String, + pub dock_pane_id: String, + pub width: u16, +} + +/// Docks open per Herdr server, keyed by the server's socket path. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DockStateFile { + version: u8, + #[serde(default)] + servers: BTreeMap>, +} + +impl Default for DockStateFile { + fn default() -> Self { + Self { + version: STATE_FILE_VERSION, + servers: BTreeMap::new(), + } + } +} + +impl DockStateFile { + /// Records persisted for one server socket; empty when unknown. + #[must_use] + pub fn records_for(&self, socket: &str) -> &[DockRecord] { + self.servers.get(socket).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Inserts or replaces the record for the same (workspace, tab) pair. + /// + /// Replacement always succeeds; new inserts are refused beyond + /// [`MAX_SERVERS`] sockets or [`MAX_RECORDS_PER_SERVER`] records per socket. + pub fn upsert(&mut self, socket: &str, record: DockRecord) -> Result<(), DockStateError> { + let Some(records) = self.servers.get_mut(socket) else { + if self.servers.len() >= MAX_SERVERS { + return Err(DockStateError::ServerCapReached { cap: MAX_SERVERS }); + } + self.servers.insert(socket.to_owned(), vec![record]); + return Ok(()); + }; + if let Some(existing) = records.iter_mut().find(|existing| { + existing.workspace_id == record.workspace_id && existing.tab_id == record.tab_id + }) { + *existing = record; + return Ok(()); + } + if records.len() >= MAX_RECORDS_PER_SERVER { + return Err(DockStateError::RecordCapReached { + socket: socket.to_owned(), + cap: MAX_RECORDS_PER_SERVER, + }); + } + records.push(record); + Ok(()) + } + + /// Removes the record for a (workspace, tab) pair; reports whether anything changed. + pub fn remove(&mut self, socket: &str, workspace_id: &str, tab_id: &str) -> bool { + let Some(records) = self.servers.get_mut(socket) else { + return false; + }; + let before = records.len(); + records.retain(|record| !(record.workspace_id == workspace_id && record.tab_id == tab_id)); + let removed = records.len() != before; + if records.is_empty() { + self.servers.remove(socket); + } + removed + } + + /// Removes records whose dock pane exited; reports whether anything changed. + pub fn remove_by_pane(&mut self, socket: &str, pane_id: &str) -> bool { + let Some(records) = self.servers.get_mut(socket) else { + return false; + }; + let before = records.len(); + records.retain(|record| record.dock_pane_id != pane_id); + let removed = records.len() != before; + if records.is_empty() { + self.servers.remove(socket); + } + removed + } + + /// Replaces one socket's records; an empty vector removes the section. + fn set_records(&mut self, socket: &str, records: Vec) { + if records.is_empty() { + self.servers.remove(socket); + } else { + self.servers.insert(socket.to_owned(), records); + } + } + + /// Bounds a file read back from disk; over-cap content only appears through + /// external tampering, so it is truncated instead of trusted. + fn clamped(mut self) -> Self { + for records in self.servers.values_mut() { + records.truncate(MAX_RECORDS_PER_SERVER); + } + while self.servers.len() > MAX_SERVERS { + self.servers.pop_last(); + } + self + } +} + +/// Loads the dock state file, falling back to an empty state on any problem. +/// +/// Missing files, corrupt JSON, unknown versions, and over-size files all +/// degrade to an empty state; loading never fails. +#[must_use] +pub fn load(state_dir: &Path) -> DockStateFile { + let path = state_dir.join(STATE_FILE_NAME); + let bytes = match read_bounded_regular_file(&path) { + Ok(Some(bytes)) => bytes, + Ok(None) | Err(_) => return DockStateFile::default(), + }; + let Ok(state) = serde_json::from_slice::(&bytes) else { + return DockStateFile::default(); + }; + if state.version != STATE_FILE_VERSION { + return DockStateFile::default(); + } + state.clamped() +} + +/// Persists one record under the state lock, re-reading the file before writing +/// so a concurrent toggle in another process is never clobbered. +pub fn upsert_record( + state_dir: &Path, + socket: &str, + record: DockRecord, +) -> Result<(), DockStateError> { + with_locked_state(state_dir, |state| { + state.upsert(socket, record).map(|()| ((), true)) + }) +} + +/// Drops the record for a (workspace, tab) pair under the state lock; reports +/// whether anything changed. Nothing is written when nothing changed. +pub fn remove_record( + state_dir: &Path, + socket: &str, + workspace_id: &str, + tab_id: &str, +) -> Result { + with_locked_state(state_dir, |state| { + let removed = state.remove(socket, workspace_id, tab_id); + Ok((removed, removed)) + }) +} + +/// Drops records whose dock pane exited under the state lock; reports whether +/// anything changed. Nothing is written when nothing changed. +pub fn remove_by_pane( + state_dir: &Path, + socket: &str, + pane_id: &str, +) -> Result { + with_locked_state(state_dir, |state| { + let removed = state.remove_by_pane(socket, pane_id); + Ok((removed, removed)) + }) +} + +/// Overlays one socket's processed records under the state lock, leaving other +/// sockets untouched; an empty vector prunes the socket's section. +pub fn replace_socket_records( + state_dir: &Path, + socket: &str, + records: Vec, +) -> Result<(), DockStateError> { + with_locked_state(state_dir, |state| { + state.set_records(socket, records); + Ok(((), true)) + }) +} + +/// Mutates the state file under its lock: the file is re-read after the lock is +/// held, so the closure always sees the latest content, and the write is +/// skipped when the closure leaves the state unchanged. +/// +/// Callers must already hold any narrower lock (a tab lock) before calling: +/// the lock order is always tab lock first, then this state lock. +fn with_locked_state( + state_dir: &Path, + apply: impl FnOnce(&mut DockStateFile) -> Result<(T, bool), DockStateError>, +) -> Result { + ensure_private_directory(state_dir).map_err(DockStateError::Lock)?; + let _lock = acquire_lock(&state_dir.join(LOCK_FILE_NAME))?; + let mut state = load(state_dir); + let (result, dirty) = apply(&mut state)?; + if dirty { + write_state_file(state_dir, &state)?; + } + Ok(result) +} + +fn acquire_lock(path: &Path) -> Result { + let file = open_private_lock_file(path).map_err(DockStateError::Lock)?; + let deadline = Instant::now() + .checked_add(LOCK_TIMEOUT) + .ok_or(DockStateError::Lock(LockError::InvalidTimeout( + LOCK_TIMEOUT, + )))?; + loop { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(TryLockError::WouldBlock) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + Err(TryLockError::WouldBlock) => { + return Err(DockStateError::Lock(LockError::Timeout { + path: path.to_path_buf(), + timeout: LOCK_TIMEOUT, + })); + } + Err(TryLockError::Error(error)) => { + return Err(DockStateError::Lock(LockError::Io { + operation: "lock", + path: path.to_path_buf(), + source: error, + })); + } + } + } +} + +fn write_state_file(state_dir: &Path, state: &DockStateFile) -> Result<(), DockStateError> { + let bytes = serde_json::to_vec(state).map_err(DockStateError::Encoding)?; + let tmp_path = state_dir.join(TMP_FILE_NAME); + let mut tmp = open_private_lock_file(&tmp_path).map_err(DockStateError::Lock)?; + tmp.set_len(0).map_err(|source| DockStateError::Io { + operation: "truncate state file", + path: tmp_path.clone(), + source, + })?; + tmp.write_all(&bytes).map_err(|source| DockStateError::Io { + operation: "write state file", + path: tmp_path.clone(), + source, + })?; + std::fs::rename(&tmp_path, state_dir.join(STATE_FILE_NAME)).map_err(|source| { + DockStateError::Io { + operation: "publish state file", + path: tmp_path, + source, + } + })?; + Ok(()) +} + +fn read_bounded_regular_file(path: &Path) -> io::Result>> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.file_type().is_file() || metadata.len() > MAX_STATE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "dock state file is not a bounded regular file", + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + let mut file: File = options.open(path)?; + let opened = file.metadata()?; + if !opened.is_file() || opened.len() > MAX_STATE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "dock state file changed during open", + )); + } + let capacity = usize::try_from(opened.len()).unwrap_or(0); + let mut bytes = Vec::with_capacity(capacity); + (&mut file) + .take(MAX_STATE_BYTES.saturating_add(1)) + .read_to_end(&mut bytes)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_STATE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "dock state file exceeds the byte limit", + )); + } + Ok(Some(bytes)) +} + +#[derive(Debug)] +pub enum DockStateError { + Lock(LockError), + Io { + operation: &'static str, + path: PathBuf, + source: io::Error, + }, + Encoding(serde_json::Error), + ServerCapReached { + cap: usize, + }, + RecordCapReached { + socket: String, + cap: usize, + }, +} + +impl fmt::Display for DockStateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Lock(error) => write!(formatter, "dock state lock failed: {error}"), + Self::Io { + operation, + path, + source, + } => write!( + formatter, + "could not {operation} {}: {source}", + path.display() + ), + Self::Encoding(error) => write!(formatter, "could not encode dock state: {error}"), + Self::ServerCapReached { cap } => write!( + formatter, + "dock state already tracks {cap} servers; refusing to track another" + ), + Self::RecordCapReached { socket, cap } => write!( + formatter, + "dock state already tracks {cap} docks for {socket}; refusing to track another" + ), + } + } +} + +impl Error for DockStateError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Lock(error) => Some(error), + Self::Io { source, .. } => Some(source), + Self::Encoding(error) => Some(error), + Self::ServerCapReached { .. } | Self::RecordCapReached { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(workspace: &str, tab: &str, pane: &str) -> DockRecord { + DockRecord { + workspace_id: workspace.to_owned(), + tab_id: tab.to_owned(), + dock_pane_id: pane.to_owned(), + width: 40, + } + } + + #[test] + fn upsert_replaces_by_workspace_and_tab_pair() { + let mut state = DockStateFile::default(); + state + .upsert("sock", record("ws", "tab", "pane-a")) + .expect("insert"); + state + .upsert("sock", record("ws", "tab", "pane-b")) + .expect("replace"); + + assert_eq!(state.records_for("sock"), &[record("ws", "tab", "pane-b")]); + } + + #[test] + fn records_are_isolated_per_socket() { + let mut state = DockStateFile::default(); + state + .upsert("sock-a", record("ws", "tab", "pane-a")) + .expect("insert"); + state + .upsert("sock-b", record("ws", "tab", "pane-b")) + .expect("insert"); + + assert_eq!( + state.records_for("sock-a"), + &[record("ws", "tab", "pane-a")] + ); + assert_eq!( + state.records_for("sock-b"), + &[record("ws", "tab", "pane-b")] + ); + assert!(state.records_for("sock-c").is_empty()); + } + + #[test] + fn remove_targets_the_pair_and_prunes_empty_sections() { + let mut state = DockStateFile::default(); + state + .upsert("sock", record("ws", "tab", "pane-a")) + .expect("insert"); + state + .upsert("sock", record("ws", "other", "pane-b")) + .expect("insert"); + + assert!(state.remove("sock", "ws", "tab")); + assert_eq!( + state.records_for("sock"), + &[record("ws", "other", "pane-b")] + ); + assert!(state.remove("sock", "ws", "other")); + assert!(!state.servers.contains_key("sock")); + assert!(!state.remove("sock", "ws", "tab")); + } + + #[test] + fn remove_by_pane_matches_only_the_pane_id() { + let mut state = DockStateFile::default(); + state + .upsert("sock", record("ws", "tab", "pane-a")) + .expect("insert"); + + assert!(!state.remove_by_pane("sock", "ws")); + assert!(!state.remove_by_pane("other", "pane-a")); + assert!(state.remove_by_pane("sock", "pane-a")); + assert!(state.records_for("sock").is_empty()); + } + + #[test] + fn caps_refuse_new_inserts_but_allow_replacement() { + let mut state = DockStateFile::default(); + for index in 0..MAX_SERVERS { + state + .upsert(&format!("sock-{index}"), record("ws", "tab", "pane")) + .expect("server insert"); + } + assert!(matches!( + state.upsert("sock-extra", record("ws", "tab", "pane")), + Err(DockStateError::ServerCapReached { .. }) + )); + + // "sock-0" already holds one record from the server loop above. + for index in 1..MAX_RECORDS_PER_SERVER { + state + .upsert("sock-0", record("ws", &format!("tab-{index}"), "pane")) + .expect("record insert"); + } + assert!(matches!( + state.upsert("sock-0", record("ws", "tab-new", "pane")), + Err(DockStateError::RecordCapReached { .. }) + )); + state + .upsert("sock-0", record("ws", "tab-1", "replaced")) + .expect("replacement passes the cap"); + } + + #[test] + fn store_and_load_roundtrip_per_socket() { + let state_dir = tempfile::tempdir().expect("tempdir"); + upsert_record(state_dir.path(), "sock-a", record("ws", "tab", "pane-a")).expect("store"); + upsert_record(state_dir.path(), "sock-b", record("ws2", "tab2", "pane-b")).expect("store"); + + let state = load(state_dir.path()); + assert_eq!( + state.records_for("sock-a"), + &[record("ws", "tab", "pane-a")] + ); + assert_eq!( + state.records_for("sock-b"), + &[record("ws2", "tab2", "pane-b")] + ); + } + + #[test] + fn remove_record_writes_only_when_something_changed() { + let state_dir = tempfile::tempdir().expect("tempdir"); + upsert_record(state_dir.path(), "sock", record("ws", "tab", "pane")).expect("store"); + + assert!(remove_record(state_dir.path(), "sock", "ws", "tab").expect("remove")); + assert!(!remove_record(state_dir.path(), "sock", "ws", "tab").expect("remove")); + assert_eq!(load(state_dir.path()), DockStateFile::default()); + + let untouched = tempfile::tempdir().expect("tempdir"); + assert!(!remove_record(untouched.path(), "sock", "ws", "tab").expect("remove")); + assert!(!untouched.path().join(STATE_FILE_NAME).exists()); + } + + #[test] + fn replace_socket_records_overlays_only_one_socket() { + let state_dir = tempfile::tempdir().expect("tempdir"); + upsert_record(state_dir.path(), "sock-a", record("ws", "tab", "pane-a")).expect("store"); + + replace_socket_records( + state_dir.path(), + "sock-b", + vec![record("ws2", "tab2", "pane-b")], + ) + .expect("overlay"); + let state = load(state_dir.path()); + assert_eq!( + state.records_for("sock-a"), + &[record("ws", "tab", "pane-a")] + ); + assert_eq!( + state.records_for("sock-b"), + &[record("ws2", "tab2", "pane-b")] + ); + + replace_socket_records(state_dir.path(), "sock-a", Vec::new()).expect("prune"); + let state = load(state_dir.path()); + assert!(state.records_for("sock-a").is_empty()); + assert_eq!( + state.records_for("sock-b"), + &[record("ws2", "tab2", "pane-b")] + ); + } + + #[test] + fn load_falls_back_to_empty_on_corrupt_wrong_version_or_oversize_files() { + let state_dir = tempfile::tempdir().expect("tempdir"); + let path = state_dir.path().join(STATE_FILE_NAME); + + std::fs::write(&path, b"{not json").expect("corrupt file"); + assert_eq!(load(state_dir.path()), DockStateFile::default()); + + std::fs::write(&path, br#"{"version":9,"servers":{}}"#).expect("future version"); + assert_eq!(load(state_dir.path()), DockStateFile::default()); + + std::fs::write(&path, br#"{"servers":{}}"#).expect("missing version"); + assert_eq!(load(state_dir.path()), DockStateFile::default()); + + std::fs::write(&path, vec![b'x'; (MAX_STATE_BYTES + 1) as usize]).expect("oversize file"); + assert_eq!(load(state_dir.path()), DockStateFile::default()); + + let missing = tempfile::tempdir().expect("tempdir"); + assert_eq!(load(missing.path()), DockStateFile::default()); + } + + #[test] + fn load_clamps_oversized_sections_from_tampered_files() { + let state_dir = tempfile::tempdir().expect("tempdir"); + let mut servers = BTreeMap::new(); + for server in 0..(MAX_SERVERS + 1) { + servers.insert( + format!("sock-{server}"), + (0..(MAX_RECORDS_PER_SERVER + 1)) + .map(|index| record("ws", &format!("tab-{index}"), "pane")) + .collect::>(), + ); + } + let tampered = serde_json::to_vec(&serde_json::json!({ + "version": STATE_FILE_VERSION, + "servers": servers, + })) + .expect("encode tampered state"); + std::fs::write(state_dir.path().join(STATE_FILE_NAME), tampered).expect("write"); + + let state = load(state_dir.path()); + assert_eq!(state.servers.len(), MAX_SERVERS); + assert!( + state + .servers + .values() + .all(|records| records.len() == MAX_RECORDS_PER_SERVER) + ); + } +} diff --git a/src/host/launch.rs b/src/host/launch.rs index 8188070..734359c 100644 --- a/src/host/launch.rs +++ b/src/host/launch.rs @@ -1,3 +1,4 @@ +use std::env; use std::error::Error; use std::fmt; use std::fs::{File, OpenOptions, TryLockError}; @@ -9,9 +10,13 @@ use std::time::{Duration, Instant}; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use super::dock_state::{ + DockRecord, DockStateError, load as load_dock_state, remove_record, replace_socket_records, + upsert_record, +}; use super::{ - DEFAULT_DOCK_WIDTH, DockIdentity, DockWidth, HostClient, HostError, HostPane, LaunchContext, - OpenDockRequest, PaneId, TabId, WorkspaceId, + DEFAULT_DOCK_WIDTH, DockIdentity, DockWidth, HostClient, HostError, HostErrorKind, HostPane, + LaunchContext, OpenDockRequest, PaneId, TabId, WorkspaceId, }; /// Current dock visibility relative to focused pane. @@ -64,6 +69,7 @@ pub struct DockLauncher { state_dir: PathBuf, lock_timeout: Duration, width: DockWidth, + socket: Option, } impl DockLauncher { @@ -73,6 +79,7 @@ impl DockLauncher { state_dir, lock_timeout: Duration::from_secs(2), width: DockWidth::clamped(DEFAULT_DOCK_WIDTH), + socket: None, } } @@ -87,6 +94,16 @@ impl DockLauncher { self } + /// Configures best-effort dock persistence for the server behind `socket`. + /// + /// Without a socket the toggle still opens, focuses, and closes docks; it + /// just leaves nothing for a restart restore to re-open. + #[must_use] + pub fn with_socket(mut self, socket: Option) -> Self { + self.socket = socket; + self + } + pub fn toggle( &self, context: &LaunchContext, @@ -145,6 +162,11 @@ impl DockLauncher { )) })?; let pane_id = dock.pane_id(); + + // Persist before placement: a dock that exists but fails + // placement must still restore later. Lock order stays + // tab lock first, then docks.lock. + self.persist_open(context, pane_id, request.width()); host.move_to_right_edge(pane_id)?; host.resize_pane(pane_id, request.width())?; host.focus_pane(pane_id)?; @@ -156,10 +178,263 @@ impl DockLauncher { } ToggleDecision::Close { pane_id } => { host.close_pane(&pane_id)?; + self.persist_close(context); Ok(ToggleOutcome::Closed) } } } + + /// Re-opens docks persisted for `socket` after a Herdr server restart. + /// + /// Best-effort per record: vanished tabs prune their record, transient + /// failures keep it for the next restart, and the pane holding focus when + /// the hook ran keeps it — docks open with an explicit no-focus request + /// and the captured focused pane is re-focused after identity probes. + pub fn restore(&self, socket: &str, host: &mut impl HostClient) -> Result<(), LauncherError> { + let records = load_dock_state(&self.state_dir) + .records_for(socket) + .to_vec(); + if records.is_empty() { + return Ok(()); + } + let mut kept = Vec::with_capacity(records.len()); + for record in records { + match self.restore_record(host, &record) { + RecordOutcome::Kept(record) => kept.push(record), + RecordOutcome::Dropped => {} + } + } + replace_socket_records(&self.state_dir, socket, kept)?; + Ok(()) + } + + fn restore_record(&self, host: &mut impl HostClient, record: &DockRecord) -> RecordOutcome { + let Some((workspace_id, tab_id)) = record_ids(record) else { + return RecordOutcome::Dropped; + }; + let _lock = + match TabLock::acquire(&self.state_dir, &workspace_id, &tab_id, self.lock_timeout) { + Ok(lock) => lock, + Err(error) => { + note_restore_deferred(&workspace_id, &tab_id, &error); + return RecordOutcome::Kept(record.clone()); + } + }; + let panes = match host.panes_in_tab(&workspace_id, &tab_id) { + Ok(panes) => panes, + Err(error) if error.kind() == HostErrorKind::NotFound => { + return RecordOutcome::Dropped; + } + Err(error) => { + note_restore_deferred(&workspace_id, &tab_id, &error); + return RecordOutcome::Kept(record.clone()); + } + }; + if panes.is_empty() { + return RecordOutcome::Dropped; + } + + // Capture focus before reconcile: the identity probes below focus + // candidate panes as a side effect of verifying them. + let focused_pane_id = panes + .iter() + .find(|pane| pane.is_focused()) + .map(|pane| pane.pane_id().clone()); + // The identity probes focus candidate panes as a side effect of + // verifying them, and a partially opened dock leaves them focused, so + // every post-capture path pins focus back to the pane the server had. + let focused = focused_pane_id.as_ref(); + let keeper = match reconcile_docks(host, &panes) { + Ok(Some(dock)) => dock.pane_id().clone(), + Ok(None) => { + match self.open_restored_dock(host, &workspace_id, &tab_id, &panes, record) { + Ok(keeper) => keeper, + Err(error) => { + return self.defer_record( + host, + &workspace_id, + &tab_id, + focused, + &error, + record, + ); + } + } + } + Err(error) => { + return self.defer_record(host, &workspace_id, &tab_id, focused, &error, record); + } + }; + refocus_focused_pane(host, &workspace_id, &tab_id, focused); + RecordOutcome::Kept(DockRecord { + workspace_id: record.workspace_id.clone(), + tab_id: record.tab_id.clone(), + dock_pane_id: keeper.as_str().to_owned(), + width: record.width, + }) + } + + /// Keeps a record for the next restart after a transient failure: pins + /// focus back to the pane the server had, notes the deferral. + fn defer_record( + &self, + host: &impl HostClient, + workspace_id: &WorkspaceId, + tab_id: &TabId, + focused: Option<&PaneId>, + error: impl fmt::Display, + record: &DockRecord, + ) -> RecordOutcome { + refocus_focused_pane(host, workspace_id, tab_id, focused); + note_restore_deferred(workspace_id, tab_id, &error); + RecordOutcome::Kept(record.clone()) + } + + /// Opens a restored dock with an explicit no-focus request, mirroring + /// `toggle`'s open invariants minus the focus handoff. Placement (edge + /// move and resize) is best-effort and never fails the open: a dock that + /// exists but sits at an imperfect width must still own the record, or + /// every restart would open another dock beside the leftovers. + fn open_restored_dock( + &self, + host: &mut impl HostClient, + workspace_id: &WorkspaceId, + tab_id: &TabId, + panes: &[HostPane], + record: &DockRecord, + ) -> Result { + let target = panes + .iter() + .find(|pane| pane.is_focused()) + .or_else(|| panes.first()) + .ok_or_else(|| { + LauncherError::Invariant("target tab has no pane to split".to_owned()) + })?; + let cwd = target + .cwd() + .map(Path::to_path_buf) + .unwrap_or_else(|| env::current_dir().unwrap_or_default()); + let request = OpenDockRequest::new_unfocused( + target.pane_id().clone(), + tab_id.clone(), + cwd, + DockWidth::clamped(record.width), + ); + let opened_pane_id = host.open_dock(&request)?; + let panes = host.panes_in_tab(workspace_id, tab_id)?; + let dock = reconcile_docks(host, &panes)?.ok_or_else(|| { + LauncherError::Invariant(format!( + "opened dock {} was absent from the post-open pane query", + opened_pane_id.as_str() + )) + })?; + let pane_id = dock.pane_id(); + if let Err(error) = host.move_to_right_edge(pane_id) { + eprintln!( + "herdr-context: restore could not move {}: {error}", + pane_id.as_str() + ); + } + if let Err(error) = host.resize_pane(pane_id, request.width()) { + eprintln!( + "herdr-context: restore could not resize {} to {} columns: {error}", + pane_id.as_str(), + request.width().columns() + ); + } + Ok(pane_id.clone()) + } + + /// Records an opened dock for `restore`. Persistence is best-effort: + /// failures degrade to "not restored" and never fail the finished toggle. + fn persist_open(&self, context: &LaunchContext, dock_pane_id: &PaneId, width: DockWidth) { + let Some(socket) = self.socket.as_deref() else { + return; + }; + let record = DockRecord { + workspace_id: context.workspace_id().as_str().to_owned(), + tab_id: context.tab_id().as_str().to_owned(), + dock_pane_id: dock_pane_id.as_str().to_owned(), + width: width.columns(), + }; + if let Err(error) = upsert_record(&self.state_dir, socket, record) { + eprintln!("herdr-context: could not record the open dock: {error}"); + } + } + + /// Drops the record for a closed dock. Best-effort like `persist_open`. + fn persist_close(&self, context: &LaunchContext) { + let Some(socket) = self.socket.as_deref() else { + return; + }; + if let Err(error) = remove_record( + &self.state_dir, + socket, + context.workspace_id().as_str(), + context.tab_id().as_str(), + ) { + eprintln!("herdr-context: could not drop the closed dock record: {error}"); + } + } +} + +/// Fate of one persisted record after a restore pass. +enum RecordOutcome { + /// Keep the (possibly refreshed) record for the next restart. + Kept(DockRecord), + /// The workspace or tab is gone; prune the record. + Dropped, +} + +fn record_ids(record: &DockRecord) -> Option<(WorkspaceId, TabId)> { + Some(( + WorkspaceId::new(record.workspace_id.as_str()).ok()?, + TabId::new(record.tab_id.as_str()).ok()?, + )) +} + +fn note_restore_deferred(workspace_id: &WorkspaceId, tab_id: &TabId, error: impl fmt::Display) { + eprintln!( + "herdr-context: restore deferred {}/{}: {error}", + workspace_id.as_str(), + tab_id.as_str() + ); +} + +/// Re-focuses the pane that held focus when the restore hook ran, undoing the +/// focus side effects of the identity probes and any partially opened dock. +fn refocus_focused_pane( + host: &impl HostClient, + workspace_id: &WorkspaceId, + tab_id: &TabId, + focused_pane_id: Option<&PaneId>, +) { + let Some(target) = focused_pane_id else { + return; + }; + // The saved focus holder is usually not a plugin pane, so `plugin pane + // focus` cannot reach it; walk from the currently focused pane instead. + let current = host + .panes_in_tab(workspace_id, tab_id) + .ok() + .and_then(|panes| { + panes + .iter() + .find(|pane| pane.is_focused()) + .map(|pane| pane.pane_id().clone()) + }); + let Some(current) = current else { + return; + }; + if current == *target { + return; + } + if let Err(error) = host.focus_origin_pane(¤t, target) { + eprintln!( + "herdr-context: restore could not refocus {}: {error}", + target.as_str() + ); + } } fn reconcile_docks( @@ -276,7 +551,7 @@ fn lock_hash(seed: u64, workspace_id: &WorkspaceId, tab_id: &TabId) -> u64 { hash } -fn ensure_private_directory(path: &Path) -> Result<(), LockError> { +pub(super) fn ensure_private_directory(path: &Path) -> Result<(), LockError> { std::fs::create_dir_all(path).map_err(|source| LockError::Io { operation: "create directory", path: path.to_path_buf(), @@ -301,7 +576,7 @@ fn ensure_private_directory(path: &Path) -> Result<(), LockError> { Ok(()) } -fn open_private_lock_file(path: &Path) -> Result { +pub(super) fn open_private_lock_file(path: &Path) -> Result { let mut options = OpenOptions::new(); options.read(true).write(true).create(true); #[cfg(unix)] @@ -414,6 +689,7 @@ impl Error for LockError { pub enum LauncherError { Lock(LockError), Host(HostError), + State(DockStateError), Invariant(String), } @@ -423,6 +699,7 @@ impl fmt::Display for LauncherError { Self::Lock(error) => write!(formatter, "dock lock failed: {error}"), Self::Host(error) => write!(formatter, "Herdr operation failed: {error}"), Self::Invariant(message) => write!(formatter, "dock invariant failed: {message}"), + Self::State(error) => write!(formatter, "dock state failed: {error}"), } } } @@ -433,6 +710,7 @@ impl Error for LauncherError { Self::Lock(error) => Some(error), Self::Host(error) => Some(error), Self::Invariant(_) => None, + Self::State(error) => Some(error), } } } @@ -449,6 +727,12 @@ impl From for LauncherError { } } +impl From for LauncherError { + fn from(error: DockStateError) -> Self { + Self::State(error) + } +} + #[cfg(test)] mod tests { use super::{DockState, ToggleDecision, decide_toggle}; diff --git a/src/host/mod.rs b/src/host/mod.rs index 1d87758..9f54f11 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,6 +1,7 @@ //! Herdr process boundary and normalized launch context. pub mod client; +pub mod dock_state; pub mod launch; use std::collections::BTreeMap; @@ -587,6 +588,7 @@ pub struct OpenDockRequest { tab_id: TabId, cwd: PathBuf, width: DockWidth, + focus: bool, } impl OpenDockRequest { @@ -602,6 +604,25 @@ impl OpenDockRequest { tab_id, cwd, width, + focus: true, + } + } + + /// Creates an open request that leaves pane focus untouched — used by the + /// restart restore, which must not steal the saved focused pane. + #[must_use] + pub const fn new_unfocused( + origin_pane_id: PaneId, + tab_id: TabId, + cwd: PathBuf, + width: DockWidth, + ) -> Self { + Self { + origin_pane_id, + tab_id, + cwd, + width, + focus: false, } } @@ -624,6 +645,12 @@ impl OpenDockRequest { pub const fn width(&self) -> DockWidth { self.width } + + /// Whether opening this dock should focus it. + #[must_use] + pub const fn focus(&self) -> bool { + self.focus + } } /// Herdr boundary required by launcher work. Implementations own CLI/socket details. diff --git a/src/main.rs b/src/main.rs index 519c40f..638fa2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::process::ExitCode; use herdr_context::app::App; use herdr_context::config::PluginConfig; use herdr_context::host::client::{CommandHostClient, DOCK_TITLE}; +use herdr_context::host::dock_state; use herdr_context::host::launch::DockLauncher; use herdr_context::host::{DockWidth, LaunchContext}; @@ -25,26 +26,99 @@ fn run(mode: Option) -> Result<(), Box> { match mode.as_deref().and_then(|value| value.to_str()) { Some("toggle") => toggle(), Some("dock") => run_dock(), + Some("restore") => restore_command(), + Some("on-event") => on_event_command(), None => run_default(), - Some(mode) => Err(format!("unknown mode {mode:?}; expected toggle or dock").into()), + Some(mode) => Err(format!( + "unknown mode {mode:?}; expected toggle, dock, restore, or on-event" + ) + .into()), } } fn toggle() -> Result<(), Box> { // Capture the invoking terminal before any Herdr operation can change focus. let context = LaunchContext::from_env()?; - let state_dir = env::var_os("HERDR_PLUGIN_STATE_DIR") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .ok_or("missing required variable HERDR_PLUGIN_STATE_DIR")?; + let state_dir = state_dir_env().ok_or("missing required variable HERDR_PLUGIN_STATE_DIR")?; let mut host = CommandHostClient::from_env()?; let config = PluginConfig::load_from_env().into_config(); DockLauncher::new(state_dir) .with_width(DockWidth::clamped(config.dock().initial_width())) + .with_socket(socket_path_env()) .toggle(&context, &mut host)?; Ok(()) } +/// Startup hook: re-opens docks persisted before the server stopped. +/// +/// Best-effort by design: missing hook context and per-record failures are +/// noted on stderr (herdr logs plugin hook output) and the exit code stays 0, +/// so restore can never break session startup. +fn restore_command() -> Result<(), Box> { + let Some(state_dir) = state_dir_env() else { + return Ok(()); + }; + let Some(socket) = socket_path_env() else { + return Ok(()); + }; + let config = PluginConfig::load_from_env().into_config(); + if !config.dock().restore_on_startup() { + return Ok(()); + } + let mut host = match CommandHostClient::from_env() { + Ok(host) => host, + Err(error) => { + eprintln!("herdr-context: restore skipped: {error}"); + return Ok(()); + } + }; + if let Err(error) = DockLauncher::new(state_dir).restore(&socket, &mut host) { + eprintln!("herdr-context: restore incomplete: {error}"); + } + Ok(()) +} + +/// Event hook: prunes persisted docks whose pane exited out-of-band so a later +/// restart does not resurrect them. Toggle-close already removed the record, +/// which makes the event a no-op for that path. +fn on_event_command() -> Result<(), Box> { + let (Some(state_dir), Some(socket)) = (state_dir_env(), socket_path_env()) else { + return Ok(()); + }; + let Ok(event) = env::var("HERDR_PLUGIN_EVENT_JSON") else { + return Ok(()); + }; + let Ok(event) = serde_json::from_str::(&event) else { + return Ok(()); + }; + if event.get("event").and_then(serde_json::Value::as_str) != Some("pane.exited") { + return Ok(()); + } + let Some(pane_id) = event + .pointer("/data/pane_id") + .and_then(serde_json::Value::as_str) + .filter(|pane_id| !pane_id.is_empty()) + else { + return Ok(()); + }; + if let Err(error) = dock_state::remove_by_pane(&state_dir, &socket, pane_id) { + eprintln!("herdr-context: prune failed: {error}"); + } + Ok(()) +} + +fn state_dir_env() -> Option { + env::var_os("HERDR_PLUGIN_STATE_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn socket_path_env() -> Option { + env::var_os("HERDR_SOCKET_PATH") + .filter(|value| !value.is_empty()) + .and_then(|value| value.into_string().ok()) +} + fn run_default() -> Result<(), Box> { let context = LaunchContext::from_env()?; if io::stdout().is_terminal() { diff --git a/tests/config.rs b/tests/config.rs index a3abf52..3968be9 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -180,6 +180,7 @@ fn valid_fields_survive_invalid_neighbors_and_limits_are_bounded() { r#" [dock] initial_width = 52 +restore_on_startup = false [ui] display_mode = "nerd" @@ -210,6 +211,7 @@ passive_jujutsu_interval_ms = 2000 let config = loaded.config(); assert_eq!(config.dock().initial_width(), 52); + assert!(!config.dock().restore_on_startup()); assert!(config.files().show_hidden()); assert_eq!( config.files().exclusions(), @@ -271,6 +273,7 @@ fn invalid_fields_fall_back_independently_and_warnings_never_echo_values() { r#" [dock] initial_width = "bad\u001b[2J" +restore_on_startup = "nope" [ui] display_mode = "emoji\u001b" @@ -295,6 +298,7 @@ jujutsu_mode = "fresh" let config = loaded.config(); assert_eq!(config.dock().initial_width(), 40); + assert!(config.dock().restore_on_startup()); assert_eq!(config.files().exclusions(), [PathBuf::from("valid")]); assert_eq!(config.ui().display_mode(), DisplayMode::Ascii); assert_eq!( diff --git a/tests/dock_restore.rs b/tests/dock_restore.rs new file mode 100644 index 0000000..ae8ef11 --- /dev/null +++ b/tests/dock_restore.rs @@ -0,0 +1,411 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use herdr_context::host::dock_state::{self, DockRecord}; +use herdr_context::host::launch::DockLauncher; +use herdr_context::host::{ + DockIdentity, DockWidth, HostClient, HostError, HostErrorKind, HostPane, OpenDockRequest, + PaneId, TabId, WorkspaceId, +}; +use tempfile::TempDir; + +const SOCKET: &str = "sock-main"; +const OTHER_SOCKET: &str = "sock-other"; +const WORKSPACE: &str = "workspace"; +const TAB: &str = "tab"; + +struct FakeHost { + panes: Vec, + opened_panes: Vec, + operations: RefCell>, + gone_tabs: Vec, + failing_tabs: Vec, + fail_open: bool, + fail_resize: bool, +} + +impl FakeHost { + fn new(panes: Vec) -> Self { + Self { + panes, + opened_panes: vec![dock_pane("dock", false)], + operations: RefCell::new(Vec::new()), + gone_tabs: Vec::new(), + failing_tabs: Vec::new(), + fail_open: false, + fail_resize: false, + } + } + + fn with_opened_panes(mut self, panes: Vec) -> Self { + self.opened_panes = panes; + self + } + + /// Rebuilds the pane list with exactly one focused pane, mirroring herdr. + fn focus_model(&mut self, pane_id: &str) { + for pane in &mut self.panes { + let focused = pane.pane_id().as_str() == pane_id; + let updated = HostPane::new( + pane.pane_id().clone(), + pane.tab_id().clone(), + pane.cwd().map(Path::to_path_buf), + pane.foreground_cwd().map(Path::to_path_buf), + focused, + ); + *pane = match pane.dock_identity() { + Some(identity) => updated.with_dock_identity(identity), + None => updated, + }; + } + } +} + +impl HostClient for FakeHost { + fn pane(&self, pane_id: &PaneId) -> Result, HostError> { + Ok(self + .panes + .iter() + .find(|pane| pane.pane_id() == pane_id) + .cloned()) + } + + fn panes_in_tab( + &self, + _workspace_id: &WorkspaceId, + tab_id: &TabId, + ) -> Result, HostError> { + let tab = tab_id.as_str(); + if self.gone_tabs.iter().any(|gone| gone == tab) { + return Err(HostError::new(HostErrorKind::NotFound, "tab is gone")); + } + if self.failing_tabs.iter().any(|failing| failing == tab) { + return Err(HostError::new( + HostErrorKind::Unavailable, + "herdr is unavailable", + )); + } + Ok(self + .panes + .iter() + .filter(|pane| pane.tab_id().as_str() == tab) + .cloned() + .collect()) + } + + fn live_sessions(&self) -> Result, HostError> { + Ok(Vec::new()) + } + + fn send_text(&self, _pane_id: &PaneId, _text: &str) -> Result<(), HostError> { + Ok(()) + } + + fn focus_origin_pane( + &self, + dock_pane_id: &PaneId, + origin_pane_id: &PaneId, + ) -> Result<(), HostError> { + if dock_pane_id == origin_pane_id { + return Ok(()); + } + self.operations.borrow_mut().push(format!( + "refocus:{}:{}", + dock_pane_id.as_str(), + origin_pane_id.as_str() + )); + Ok(()) + } + + fn verified_dock_identity( + &mut self, + pane: &HostPane, + ) -> Result, HostError> { + self.operations + .borrow_mut() + .push(format!("verify:{}", pane.pane_id().as_str())); + // Mirrors herdr: the ownership probe focuses the candidate pane. + if pane.dock_identity().is_some() { + self.focus_model(pane.pane_id().as_str()); + } + Ok(pane.dock_identity()) + } + + fn open_dock(&mut self, request: &OpenDockRequest) -> Result { + self.operations.borrow_mut().push(format!( + "open:{}:{}:{}", + request.origin_pane_id().as_str(), + request.cwd().display(), + request.width().columns() + )); + if self.fail_open { + return Err(HostError::new( + HostErrorKind::OperationFailed, + "open failed", + )); + } + let opened_id = self.opened_panes[0].pane_id().clone(); + self.panes.extend(self.opened_panes.clone()); + Ok(opened_id) + } + + fn focus_pane(&mut self, pane_id: &PaneId) -> Result<(), HostError> { + self.operations + .borrow_mut() + .push(format!("focus:{}", pane_id.as_str())); + self.focus_model(pane_id.as_str()); + Ok(()) + } + + fn close_pane(&mut self, pane_id: &PaneId) -> Result<(), HostError> { + self.operations + .borrow_mut() + .push(format!("close:{}", pane_id.as_str())); + self.panes.retain(|pane| pane.pane_id() != pane_id); + Ok(()) + } + + fn move_to_right_edge(&mut self, pane_id: &PaneId) -> Result<(), HostError> { + self.operations + .borrow_mut() + .push(format!("move:{}", pane_id.as_str())); + Ok(()) + } + + fn resize_pane(&mut self, pane_id: &PaneId, width: DockWidth) -> Result<(), HostError> { + self.operations.borrow_mut().push(format!( + "resize:{}:{}", + pane_id.as_str(), + width.columns() + )); + if self.fail_resize { + return Err(HostError::new( + HostErrorKind::OperationFailed, + "could not resize pane", + )); + } + Ok(()) + } +} + +fn record(pane: &str, width: u16) -> DockRecord { + record_in(WORKSPACE, TAB, pane, width) +} + +fn record_in(workspace: &str, tab: &str, pane: &str, width: u16) -> DockRecord { + DockRecord { + workspace_id: workspace.to_owned(), + tab_id: tab.to_owned(), + dock_pane_id: pane.to_owned(), + width, + } +} + +fn pane(id: &str, focused: bool) -> HostPane { + HostPane::new( + PaneId::new(id).expect("valid test pane id"), + TabId::new(TAB).expect("valid test tab id"), + Some(PathBuf::from("/project")), + None, + focused, + ) +} + +fn pane_without_cwd(id: &str, focused: bool) -> HostPane { + HostPane::new( + PaneId::new(id).expect("valid test pane id"), + TabId::new(TAB).expect("valid test tab id"), + None, + None, + focused, + ) +} + +fn dock_pane(id: &str, focused: bool) -> HostPane { + pane(id, focused).with_dock_identity(DockIdentity::PluginMetadata) +} + +fn launcher(state: &Path) -> DockLauncher { + DockLauncher::new(state.to_path_buf()) +} + +fn seed(state: &Path, socket: &str, record: DockRecord) { + dock_state::upsert_record(state, socket, record).expect("seed dock record"); +} + +fn stored(state: &Path, socket: &str) -> Vec { + dock_state::load(state).records_for(socket).to_vec() +} + +#[test] +fn restore_opens_the_saved_dock_without_stealing_focus_and_refreshes_the_record() +-> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 48)); + let mut host = + FakeHost::new(vec![pane("origin", true)]).with_opened_panes(vec![dock_pane("dock", false)]); + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert_eq!( + *host.operations.borrow(), + [ + "open:origin:/project:48", + "verify:dock", + "move:dock", + "resize:dock:48", + "refocus:dock:origin", + ] + ); + assert_eq!(stored(state.path(), SOCKET), [record("dock", 48)]); + Ok(()) +} + +#[test] +fn restore_falls_back_to_the_process_cwd_when_the_pane_has_none() +-> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(vec![pane_without_cwd("origin", true)]); + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert_eq!( + host.operations.borrow()[0], + format!("open:origin:{}:40", std::env::current_dir()?.display()) + ); + Ok(()) +} + +#[test] +fn restore_prunes_the_record_when_the_tab_is_gone() -> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(vec![pane("origin", true)]); + host.gone_tabs = vec![TAB.to_owned()]; + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert!(host.operations.borrow().is_empty()); + assert!(stored(state.path(), SOCKET).is_empty()); + Ok(()) +} + +#[test] +fn restore_prunes_the_record_when_the_tab_has_no_panes() -> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(Vec::new()); + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert!(host.operations.borrow().is_empty()); + assert!(stored(state.path(), SOCKET).is_empty()); + Ok(()) +} + +#[test] +fn restore_refreshes_the_record_when_a_dock_is_already_open() +-> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(vec![pane("origin", true), dock_pane("dock-live", false)]); + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert_eq!( + *host.operations.borrow(), + ["verify:dock-live", "refocus:dock-live:origin"] + ); + assert_eq!(stored(state.path(), SOCKET), [record("dock-live", 40)]); + Ok(()) +} + +#[test] +fn restore_touches_only_the_restored_socket() -> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + seed( + state.path(), + OTHER_SOCKET, + record_in("ws2", "tab2", "kept", 52), + ); + let mut host = FakeHost::new(vec![pane("origin", true)]); + host.gone_tabs = vec![TAB.to_owned()]; + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert!(stored(state.path(), SOCKET).is_empty()); + assert_eq!( + stored(state.path(), OTHER_SOCKET), + [record_in("ws2", "tab2", "kept", 52)] + ); + Ok(()) +} + +#[test] +fn restore_keeps_the_record_when_the_host_is_unavailable() -> Result<(), Box> +{ + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(vec![pane("origin", true)]); + host.failing_tabs = vec![TAB.to_owned()]; + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert!(host.operations.borrow().is_empty()); + assert_eq!(stored(state.path(), SOCKET), [record("dock-old", 40)]); + Ok(()) +} + +#[test] +fn restore_keeps_the_record_when_the_open_fails() -> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 40)); + let mut host = FakeHost::new(vec![pane("origin", true)]); + host.fail_open = true; + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert_eq!(*host.operations.borrow(), ["open:origin:/project:40"]); + assert_eq!(stored(state.path(), SOCKET), [record("dock-old", 40)]); + Ok(()) +} + +#[test] +fn restore_gives_the_opened_dock_the_record_when_placement_is_imperfect() +-> Result<(), Box> { + let state = TempDir::new()?; + seed(state.path(), SOCKET, record("dock-old", 24)); + let mut host = FakeHost::new(vec![pane("origin", true)]); + host.fail_resize = true; + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert_eq!( + *host.operations.borrow(), + [ + "open:origin:/project:24", + "verify:dock", + "move:dock", + "resize:dock:24", + "refocus:dock:origin", + ] + ); + // The opened dock owns the record even at an imperfect width: a stale + // record would open yet another dock on every restart. + assert_eq!(stored(state.path(), SOCKET), [record("dock", 24)]); + Ok(()) +} + +#[test] +fn restore_without_records_is_a_no_op() -> Result<(), Box> { + let state = TempDir::new()?; + let mut host = FakeHost::new(vec![pane("origin", true)]); + + launcher(state.path()).restore(SOCKET, &mut host)?; + + assert!(host.operations.borrow().is_empty()); + assert!(!state.path().join("docks.json").exists()); + Ok(()) +} diff --git a/tests/host_client.rs b/tests/host_client.rs index 8961d86..5724ad1 100644 --- a/tests/host_client.rs +++ b/tests/host_client.rs @@ -419,3 +419,42 @@ fn stalled_command_returns_a_bounded_structured_error() -> Result<(), Box Result<(), Box> { + let temp = TempDir::new()?; + let log = temp.path().join("argv.log"); + let script = temp.path().join("fake-herdr"); + fs::write( + &script, + format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' +case "$*" in + plugin\ pane\ open*) + printf '%s\n' '{{"id":"test","result":{{"type":"plugin_pane_opened","plugin_pane":{{"plugin_id":"herdr-context","entrypoint":"dock","pane":{{"pane_id":"opened","tab_id":"tab","cwd":"/project","focused":false}}}}}}}}' + ;; + *) + printf '%s\n' '{{"error":{{"code":"operation_failed","message":"unexpected argv"}},"id":"test"}}' + exit 1 + ;; +esac +"#, + log.display(), + ), + )?; + fs::set_permissions(&script, fs::Permissions::from_mode(0o700))?; + + let mut client = CommandHostClient::new(script).with_plugin_root(PathBuf::from("/plugin root")); + let request = OpenDockRequest::new_unfocused( + PaneId::new("origin")?, + TabId::new("tab")?, + PathBuf::from("/project"), + DockWidth::clamped(40), + ); + client.open_dock(&request)?; + let argv = fs::read_to_string(log)?; + assert!(argv.contains("plugin pane open --plugin herdr-context --entrypoint dock --placement split --target-pane origin --direction right --cwd /plugin root --env HERDR_CONTEXT_ORIGIN_CWD=/project --env HERDR_CONTEXT_ORIGIN_PANE_ID=origin --no-focus\n")); + assert!(!argv.contains(" --focus\n")); + Ok(()) +} diff --git a/tests/host_launch.rs b/tests/host_launch.rs index 1d31a49..d846a85 100644 --- a/tests/host_launch.rs +++ b/tests/host_launch.rs @@ -1,6 +1,7 @@ use std::error::Error; use std::path::{Path, PathBuf}; +use herdr_context::host::dock_state::{self, DockRecord}; use herdr_context::host::launch::{DockLauncher, ToggleOutcome}; use herdr_context::host::{ DockIdentity, DockWidth, HostClient, HostError, HostPane, LaunchContext, OpenDockRequest, @@ -295,3 +296,76 @@ fn dock_width_is_clamped_to_supported_bounds() { assert_eq!(DockWidth::clamped(40).columns(), 40); assert_eq!(DockWidth::clamped(u16::MAX).columns(), 60); } + +#[test] +fn open_toggle_persists_a_record_and_focus_toggle_leaves_it() +-> Result<(), Box> { + let state = TempDir::new()?; + let mut host = FakeHost::new(vec![pane_with_foreground("origin", true, "/live/project")]); + let socketed = || launcher(state.path()).with_socket(Some("sock".to_owned())); + + assert_eq!( + socketed().toggle(&context()?, &mut host)?, + ToggleOutcome::Opened + ); + assert_eq!( + dock_state::load(state.path()).records_for("sock"), + [DockRecord { + workspace_id: "workspace".to_owned(), + tab_id: "tab".to_owned(), + dock_pane_id: "dock".to_owned(), + width: 40, + }] + ); + + // The opened dock is unfocused in the fake host, so the next toggle only + // focuses it, and the persisted record must survive untouched. + assert_eq!( + socketed().toggle(&context()?, &mut host)?, + ToggleOutcome::Focused + ); + assert_eq!(dock_state::load(state.path()).records_for("sock").len(), 1); + Ok(()) +} + +#[test] +fn close_toggle_removes_the_persisted_record() -> Result<(), Box> { + let state = TempDir::new()?; + dock_state::upsert_record( + state.path(), + "sock", + DockRecord { + workspace_id: "workspace".to_owned(), + tab_id: "tab".to_owned(), + dock_pane_id: "dock".to_owned(), + width: 40, + }, + )?; + let mut host = FakeHost::new(vec![pane("origin", false), dock_pane("dock", true)]); + + assert_eq!( + launcher(state.path()) + .with_socket(Some("sock".to_owned())) + .toggle(&context()?, &mut host)?, + ToggleOutcome::Closed + ); + assert!( + dock_state::load(state.path()) + .records_for("sock") + .is_empty() + ); + Ok(()) +} + +#[test] +fn toggle_without_a_socket_leaves_no_state() -> Result<(), Box> { + let state = TempDir::new()?; + let mut host = FakeHost::new(vec![pane_with_foreground("origin", true, "/live/project")]); + + assert_eq!( + launcher(state.path()).toggle(&context()?, &mut host)?, + ToggleOutcome::Opened + ); + assert!(!state.path().join("docks.json").exists()); + Ok(()) +} diff --git a/tests/launch_binary.rs b/tests/launch_binary.rs index 8fad89b..10585d3 100644 --- a/tests/launch_binary.rs +++ b/tests/launch_binary.rs @@ -64,6 +64,9 @@ fn unknown_mode_is_rejected() -> Result<(), Box> { let output = run_binary(Some("unknown"), None)?; assert_eq!(output.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&output.stderr).contains("expected toggle or dock")); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("expected toggle, dock, restore, or on-event") + ); Ok(()) }