diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 12fa99b..5de9d3f 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::io::Write; +use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{ ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session, SessionStatus, WorktreeSpec, @@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String { .unwrap_or_default() } -/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web -/// UI's abbreviation so CLI and browser show the same directory shorthand. -pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { - let p = path.to_string_lossy(); - if !home.is_empty() { - if p == home { - return "~".into(); - } - if let Some(rest) = p.strip_prefix(&format!("{home}/")) { - return format!("~/{rest}"); - } - } - p.into_owned() -} - #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/crates/maestro-cli/src/worktrees.rs b/crates/maestro-cli/src/worktrees.rs index c00237c..0f9bd21 100644 --- a/crates/maestro-cli/src/worktrees.rs +++ b/crates/maestro-cli/src/worktrees.rs @@ -6,10 +6,10 @@ use std::io::Write; +use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree}; use crate::cli::{ListWorktreesArgs, ReclaimArgs}; -use crate::sessions::abbreviate_dir; /// `maestro worktrees list` pub async fn run_list( diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs new file mode 100644 index 0000000..582503d --- /dev/null +++ b/crates/maestro-protocol/src/display.rs @@ -0,0 +1,321 @@ +//! Path-abbreviation helpers shared by every client that renders a session's +//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and +//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`, +//! and `sessionTreePath` — keep them in sync. + +use std::path::{Path, PathBuf}; + +use crate::Session; + +/// Collapse a daemon's home dir to `~`. `home` empty leaves the path +/// untouched. Mirrors the web's `abbreviateCwd`. +pub fn abbreviate_dir(path: &Path, home: &str) -> String { + let p = path.to_string_lossy(); + if !home.is_empty() { + if p == home { + return "~".into(); + } + if let Some(rest) = p.strip_prefix(&format!("{home}/")) { + return format!("~/{rest}"); + } + } + p.into_owned() +} + +/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim, +/// and — when there was something to collapse — the shortened lead-in a +/// caller can render dimmed (the web's `.folder-stem`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AbbreviatedFolderName { + /// The collapsed earlier segments, joined by `/`, with no trailing + /// slash. `None` when `name` had two or fewer segments — nothing to + /// gain from collapsing, so `last` carries the name unabbreviated. + pub stem: Option, + pub last: String, +} + +/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the +/// last segment verbatim, collapse every earlier one to its first character. +/// Two-or-fewer segments are left alone. Mirrors the web's +/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui +/// caller styles `stem` dim itself rather than parsing markup out of it. +pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName { + let parts: Vec<&str> = name.split('/').collect(); + if parts.len() <= 2 { + return AbbreviatedFolderName { + stem: None, + last: name.to_string(), + }; + } + let last = parts[parts.len() - 1].to_string(); + let stem = parts[..parts.len() - 1] + .iter() + .map(|p| { + if *p == "~" { + "~".to_string() + } else { + p.chars().next().map(String::from).unwrap_or_default() + } + }) + .collect::>() + .join("/"); + AbbreviatedFolderName { + stem: Some(stem), + last, + } +} + +/// The directory a session lives in: its live `current_cwd` if the daemon +/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`. +fn session_cwd(s: &Session) -> Option { + if let Some(cur) = &s.current_cwd { + if !cur.as_os_str().is_empty() { + return Some(cur.clone()); + } + } + if !s.cwd.as_os_str().is_empty() { + return Some(s.cwd.clone()); + } + None +} + +/// Where a session sits in the directory tree. A worktree session belongs to +/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills` +/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one +/// session of the same name, and the repository stops being where you look +/// for the work. Mirrors the web's `sessionTreePath`. +pub fn session_tree_path(s: &Session) -> Option { + if let Some(wt) = &s.worktree { + return Some(wt.base_repo.clone()); + } + session_cwd(s) +} + +/// One node of a directory tree grouping sessions by path, generic over the +/// leaf payload since building the tree itself (grouping key, sort order, +/// filtering) is not this crate's job — only [`merge_linear`] is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeNode { + Folder(TreeFolder), + Leaf(T), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeFolder { + pub name: String, + pub path: String, + pub children: Vec>, +} + +/// Fold a folder with a single folder-child into one node (`a` + `b` → +/// `a/b`), repeating down the chain, then recurse into the surviving +/// branches. The merged node keeps the deepest path so collapse/expand state +/// stays stable as it grows. Mirrors the web's `mergeLinear`. +pub fn merge_linear(folder: TreeFolder) -> TreeFolder { + let mut cur = folder; + loop { + let single_folder_child = + cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_)); + if !single_folder_child { + break; + } + let child = match cur.children.pop().expect("checked len == 1 above") { + TreeNode::Folder(f) => f, + TreeNode::Leaf(_) => unreachable!("checked matches! above"), + }; + cur = TreeFolder { + name: format!("{}/{}", cur.name, child.name), + path: child.path, + children: child.children, + }; + } + cur.children = cur + .children + .into_iter() + .map(|c| match c { + TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)), + leaf => leaf, + }) + .collect(); + cur +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SessionStatus, SessionWorktree}; + use chrono::{TimeZone, Utc}; + use std::collections::HashMap; + use uuid::Uuid; + + fn session(cwd: &str) -> Session { + Session { + id: Uuid::from_u128(1), + cwd: PathBuf::from(cwd), + command: "bash".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + status: SessionStatus::Running, + created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: Utc::now(), + current_cwd: None, + blocked_work: None, + } + } + + #[test] + fn abbreviate_dir_collapses_home() { + assert_eq!( + abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"), + "~/maestro" + ); + assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~"); + } + + #[test] + fn abbreviate_dir_leaves_paths_outside_home_untouched() { + assert_eq!( + abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"), + "/srv/work/api" + ); + assert_eq!( + abbreviate_dir(Path::new("/srv/work/api"), ""), + "/srv/work/api" + ); + } + + #[test] + fn abbreviate_folder_name_collapses_the_stem() { + let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro"); + assert_eq!( + got, + AbbreviatedFolderName { + stem: Some("~/g/n".into()), + last: "maestro".into(), + } + ); + } + + /// Two-or-fewer segments are left alone — nothing to gain from collapsing. + #[test] + fn abbreviate_folder_name_two_segment_no_op() { + assert_eq!( + abbreviate_folder_name("~/work"), + AbbreviatedFolderName { + stem: None, + last: "~/work".into(), + } + ); + assert_eq!( + abbreviate_folder_name("work"), + AbbreviatedFolderName { + stem: None, + last: "work".into(), + } + ); + } + + #[test] + fn session_tree_path_prefers_worktree_base_repo() { + let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet"); + s.worktree = Some(SessionWorktree { + path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"), + base_repo: PathBuf::from("/home/n/tea/agent-skills"), + }); + assert_eq!( + session_tree_path(&s), + Some(PathBuf::from("/home/n/tea/agent-skills")) + ); + } + + #[test] + fn session_tree_path_falls_back_to_cwd_without_a_worktree() { + let s = session("/home/n/tea/arr"); + assert_eq!( + session_tree_path(&s), + Some(PathBuf::from("/home/n/tea/arr")) + ); + } + + #[test] + fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() { + let mut s = session("/home/n/tea/arr"); + s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates")); + assert_eq!( + session_tree_path(&s), + Some(PathBuf::from("/home/n/tea/arr/crates")) + ); + } + + fn leaf(name: &str) -> TreeNode { + TreeNode::Leaf(name.into()) + } + + fn folder(name: &str, path: &str, children: Vec>) -> TreeFolder { + TreeFolder { + name: name.into(), + path: path.into(), + children, + } + } + + /// The web's mergeLinear test: keeps the deepest path so collapse state + /// survives growth. + #[test] + fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() { + let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]); + let merged = merge_linear(tree); + assert_eq!(merged.name, "a/b"); + assert_eq!(merged.path, "a/b"); + } + + #[test] + fn merge_linear_stops_where_a_directory_branches() { + let tree = folder( + "git", + "git", + vec![ + TreeNode::Folder(folder("one", "git/one", vec![])), + TreeNode::Folder(folder("two", "git/two", vec![])), + ], + ); + let merged = merge_linear(tree); + assert_eq!(merged.name, "git"); + assert_eq!(merged.children.len(), 2); + } + + #[test] + fn merge_linear_stops_at_a_leaf() { + let tree = folder("a", "a", vec![leaf("session")]); + let merged = merge_linear(tree); + assert_eq!(merged.name, "a"); + assert_eq!(merged.children, vec![leaf("session")]); + } + + #[test] + fn merge_linear_recurses_into_surviving_branches() { + let tree = folder( + "root", + "root", + vec![ + TreeNode::Folder(folder( + "a", + "root/a", + vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))], + )), + leaf("other"), + ], + ); + let merged = merge_linear(tree); + assert_eq!(merged.children.len(), 2); + let TreeNode::Folder(nested) = &merged.children[0] else { + panic!("expected folder") + }; + assert_eq!(nested.name, "a/b"); + } +} diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 390c245..8286ea7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -9,6 +9,7 @@ #[cfg(feature = "client")] pub mod client; +pub mod display; pub mod routes; #[cfg(feature = "client")]