|
|
|
@@ -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<String>,
|
|
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.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<PathBuf> {
|
|
|
|
|
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<PathBuf> {
|
|
|
|
|
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<T> {
|
|
|
|
|
Folder(TreeFolder<T>),
|
|
|
|
|
Leaf(T),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct TreeFolder<T> {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub path: String,
|
|
|
|
|
pub children: Vec<TreeNode<T>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|
|
|
|
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<String> {
|
|
|
|
|
TreeNode::Leaf(name.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|