diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 71ae127..dd4203f 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -15,7 +15,8 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ - ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session, + ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project, + RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -138,6 +139,10 @@ impl DaemonClient { .await } + pub async fn list_projects(&self) -> Result, ClientError> { + self.get(routes::PROJECTS, &[]).await + } + // -- plumbing ----------------------------------------------------------- async fn get( diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 82c73b7..b06e3b7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -188,6 +188,30 @@ pub struct GitRoot { pub root: Option, } +/// One discovered git repository (or linked worktree) for `GET /api/projects` +/// (#54). The daemon reports the timestamp; clients decide what "stale" means +/// (the web UI dims entries older than 7 days). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Project { + /// Absolute path of the repository/worktree toplevel. + pub path: PathBuf, + /// Basename of `path`. + pub name: String, + #[serde(default)] + pub is_worktree: bool, + /// The main checkout this worktree belongs to. Set only when + /// [`Self::is_worktree`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_repo: Option, + /// Later of the repo's git HEAD mtime and the newest spawn-history use of + /// any directory inside it. `None` when neither is known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_activity: Option>, + /// Command last spawned in this project, from spawn history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { @@ -329,6 +353,34 @@ mod tests { serde_json::from_str(&json).expect("deserialize") } + #[test] + fn project_round_trips_and_defaults_on_legacy_payload() { + let full = Project { + path: PathBuf::from("/home/u/tea/maestro"), + name: "maestro".into(), + is_worktree: true, + base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")), + last_activity: Some( + DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + last_command: Some("claude".into()), + }; + assert_eq!(full, round_trip(&full)); + + // A payload from a daemon predating the optional fields still decodes. + let legacy = serde_json::json!({ + "path": "/home/u/proj", + "name": "proj", + }); + let p: Project = serde_json::from_value(legacy).unwrap(); + assert!(!p.is_worktree); + assert_eq!(p.base_repo, None); + assert_eq!(p.last_activity, None); + assert_eq!(p.last_command, None); + } + fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 10c8131..777d086 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info"; pub const DIRS: &str = "/api/dirs"; /// `GET` spawn history. pub const RECENT_DIRS: &str = "/api/recent-dirs"; +/// `GET` discovered git repositories and worktrees, newest activity first. +pub const PROJECTS: &str = "/api/projects"; /// `GET` the enclosing repository of a path. Takes a `path` query param. pub const GITROOT: &str = "/api/gitroot"; /// `WS` catalog snapshot + session deltas. @@ -172,7 +174,7 @@ mod tests { #[test] fn every_route_sits_under_the_api_prefix() { - for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] { + for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX));