`s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered overlay instead of taking over the pane. The attached session stays attached behind it and comes back untouched on Escape; the modal owns every key while it is up, so `x` and `j` typed into a field no longer reach the keymap. Tab::pane is a session id again and PaneRef is gone. The form lives on App next to the rename buffer, so its loads no longer address a tab index that could not identify them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
7.3 KiB
Rust
217 lines
7.3 KiB
Rust
//! Pure helpers behind the spawn form's project picker (#64): staleness,
|
|
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
|
|
//! `web/src/projectList.ts` so both clients order and dim identically — the
|
|
//! daemon reports the timestamps; what "stale" means is a client decision
|
|
//! (#54).
|
|
|
|
use chrono::{DateTime, TimeDelta, Utc};
|
|
use maestro_protocol::Project;
|
|
|
|
/// Projects whose last activity is older than this are dimmed and sort after
|
|
/// fresh ones — still reachable, visibly stale.
|
|
pub const STALE_AFTER_DAYS: i64 = 7;
|
|
|
|
/// True when a project has no usable activity timestamp, one in the future,
|
|
/// or its last activity is older than [`STALE_AFTER_DAYS`] days.
|
|
pub fn is_stale(project: &Project, now: DateTime<Utc>) -> bool {
|
|
match project.last_activity {
|
|
None => true,
|
|
Some(t) => {
|
|
let age = now.signed_duration_since(t);
|
|
age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn time_of(project: &Project) -> DateTime<Utc> {
|
|
project.last_activity.unwrap_or(DateTime::UNIX_EPOCH)
|
|
}
|
|
|
|
/// Fresh projects first, each block newest-activity-first; unknown or future
|
|
/// timestamps sort as stalest. Ties fall back to name, then path, so the
|
|
/// order is stable.
|
|
pub fn sort_projects(mut projects: Vec<Project>, now: DateTime<Utc>) -> Vec<Project> {
|
|
projects.sort_by(|a, b| {
|
|
let stale = is_stale(a, now).cmp(&is_stale(b, now));
|
|
stale
|
|
.then_with(|| time_of(b).cmp(&time_of(a)))
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
.then_with(|| a.path.cmp(&b.path))
|
|
});
|
|
projects
|
|
}
|
|
|
|
/// Substring filter over project name and full path, case-insensitive.
|
|
pub fn matches_filter(project: &Project, query: &str) -> bool {
|
|
let q = query.trim().to_lowercase();
|
|
if q.is_empty() {
|
|
return true;
|
|
}
|
|
project.name.to_lowercase().contains(&q)
|
|
|| project.path.to_string_lossy().to_lowercase().contains(&q)
|
|
}
|
|
|
|
/// One repository group: the main checkout followed by its linked worktrees.
|
|
/// A group's position follows its freshest member, since a worktree worked on
|
|
/// today should pull its (dormant) base repo up with it.
|
|
#[derive(Debug)]
|
|
pub struct ProjectGroup<'a> {
|
|
pub main: &'a Project,
|
|
pub worktrees: Vec<&'a Project>,
|
|
}
|
|
|
|
/// Group `sorted` projects by their base repository. A worktree nests under
|
|
/// its main checkout even when the main sorts later; a main checkout missing
|
|
/// from the list doesn't drop its worktrees.
|
|
pub fn group_projects(sorted: &[Project]) -> Vec<ProjectGroup<'_>> {
|
|
let mut members: Vec<(String, Vec<&Project>)> = Vec::new();
|
|
for p in sorted {
|
|
let key = p
|
|
.base_repo
|
|
.as_ref()
|
|
.unwrap_or(&p.path)
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
match members.iter_mut().find(|(k, _)| *k == key) {
|
|
Some((_, list)) => list.push(p),
|
|
None => members.push((key, vec![p])),
|
|
}
|
|
}
|
|
members
|
|
.into_iter()
|
|
.map(|(key, mut list)| {
|
|
let main_pos = list
|
|
.iter()
|
|
.position(|p| p.path.to_string_lossy() == key && !p.is_worktree)
|
|
.unwrap_or(0);
|
|
let main = list.remove(main_pos);
|
|
// Keep the daemon's newest-first order within the group.
|
|
list.sort_by_key(|p| std::cmp::Reverse(time_of(p)));
|
|
ProjectGroup {
|
|
main,
|
|
worktrees: list,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
fn days_ago(days: i64) -> DateTime<Utc> {
|
|
Utc::now() - TimeDelta::days(days)
|
|
}
|
|
|
|
fn project(name: &str, path: &str, last_activity: Option<DateTime<Utc>>) -> Project {
|
|
Project {
|
|
path: PathBuf::from(path),
|
|
name: name.into(),
|
|
is_worktree: false,
|
|
base_repo: None,
|
|
last_activity,
|
|
last_command: None,
|
|
}
|
|
}
|
|
|
|
fn worktree(
|
|
name: &str,
|
|
path: &str,
|
|
base: &str,
|
|
last_activity: Option<DateTime<Utc>>,
|
|
) -> Project {
|
|
Project {
|
|
is_worktree: true,
|
|
base_repo: Some(PathBuf::from(base)),
|
|
..project(name, path, last_activity)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_and_future_activity_are_stale() {
|
|
let now = Utc::now();
|
|
assert!(is_stale(&project("x", "/x", None), now));
|
|
assert!(is_stale(
|
|
&project("x", "/x", Some(now + TimeDelta::hours(1))),
|
|
now
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn activity_within_a_week_is_fresh_beyond_is_stale() {
|
|
let now = Utc::now();
|
|
assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now));
|
|
assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now));
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_sorts_before_stale_then_newest_first() {
|
|
let now = Utc::now();
|
|
let sorted = sort_projects(
|
|
vec![
|
|
project("old-fresh", "/a", Some(days_ago(5))),
|
|
project("stale", "/b", Some(days_ago(30))),
|
|
project("newest", "/c", Some(days_ago(1))),
|
|
project("unknown", "/d", None),
|
|
],
|
|
now,
|
|
);
|
|
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
|
assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]);
|
|
}
|
|
|
|
#[test]
|
|
fn ties_break_by_name_then_path() {
|
|
let now = Utc::now();
|
|
let t = Some(days_ago(2));
|
|
let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now);
|
|
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
|
|
assert_eq!(names, ["a", "b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn filter_matches_name_or_path_case_insensitively() {
|
|
let p = project("Maestro", "/home/u/tea/Maestro", None);
|
|
assert!(matches_filter(&p, "maes"));
|
|
assert!(matches_filter(&p, " tea "));
|
|
assert!(!matches_filter(&p, "zzz"));
|
|
assert!(matches_filter(&p, ""), "empty query matches everything");
|
|
}
|
|
|
|
#[test]
|
|
fn worktrees_nest_under_their_main_checkout() {
|
|
let now = Utc::now();
|
|
let main = project("maestro", "/repos/maestro", Some(days_ago(10)));
|
|
let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1)));
|
|
let other = project("other", "/repos/other", Some(days_ago(2)));
|
|
let sorted = sort_projects(vec![main, wt, other], now);
|
|
let groups = group_projects(&sorted);
|
|
assert_eq!(groups.len(), 2);
|
|
assert_eq!(
|
|
groups[0].main.name, "maestro",
|
|
"fresh worktree pulls its repo up"
|
|
);
|
|
assert_eq!(groups[0].worktrees.len(), 1);
|
|
assert_eq!(groups[0].worktrees[0].name, "64-tui");
|
|
assert_eq!(groups[1].main.name, "other");
|
|
}
|
|
|
|
/// A main checkout absent from the scan must not drop its worktrees.
|
|
#[test]
|
|
fn worktrees_survive_a_missing_main_checkout() {
|
|
let now = Utc::now();
|
|
let wt = worktree(
|
|
"solo-wt",
|
|
"/repos/solo-wt",
|
|
"/repos/solo",
|
|
Some(days_ago(1)),
|
|
);
|
|
let sorted = sort_projects(vec![wt], now);
|
|
let groups = group_projects(&sorted);
|
|
assert_eq!(groups.len(), 1);
|
|
assert_eq!(groups[0].main.name, "solo-wt");
|
|
assert!(groups[0].worktrees.is_empty());
|
|
}
|
|
}
|