Compare commits

...
Author SHA1 Message Date
Miguel Palhas ad690ab2fa feat(maestro): tui draft form gains presets, project picker, names, validation (#64) 2026-08-23 22:01:53 +01:00
Miguel Palhas 7dcb87cd6e feat(maestro): project-list helpers for the tui draft picker (#64) 2026-08-23 21:46:54 +01:00
Miguel Palhas 26b0fa5e8a merge: worktree on by default, new-or-existing branch picker (#61) 2026-08-23 21:36:40 +01:00
Miguel Palhas 8b3c5d73b6 fix(maestro): third-review findings — nested worktrees, staleness, placement
The one that loses data: the project picker lists linked worktrees as
pickable rows, so `base_repo` was easily a worktree rather than the main
checkout, and the new placement then nested the tree inside it. Git hides
a registered worktree's contents from the outer `git status`, so the
outer tree read clean and removing it deleted the inner one's uncommitted
work — while that session could still be running. Placement now resolves
to the main checkout via `--git-common-dir`, and removal refuses outright
when `worktree list` reports anything registered underneath.

Also:

- The `worktree_root` override dropped the old UUID without replacing it,
  so two repos spawning the same branch name targeted one directory and
  the second failed. The repo basename now sits between root and slug.
- Project staleness read `.git/HEAD`'s mtime, which only moves on
  checkout. A repo committed to daily from one long-lived branch reported
  its last branch switch and got dimmed. Prefer `logs/HEAD`.
- The projects cache TTL (5s) was shorter than its own background refresh
  (60s), so nearly every request rescanned all of $HOME under the scanner
  lock — and draft panes re-request on unrelated re-renders. Now 180s.
- onSuccess resolved the leaf against the active tab only, so switching
  tabs mid-POST orphaned the session. Searches every tab.
2026-08-23 21:36:29 +01:00
12 changed files with 1716 additions and 195 deletions
Generated
+1
View File
@@ -1193,6 +1193,7 @@ dependencies = [
"maestro-protocol",
"maestro-testing",
"ratatui",
"reqwest",
"serde_json",
"thiserror 1.0.69",
"tokio",
+20 -6
View File
@@ -29,9 +29,14 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
/// How long a discovery snapshot is served as-is before the next request
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
/// `git worktree list` per hit.
pub const CACHE_TTL: Duration = Duration::from_secs(5);
/// triggers a rescan.
///
/// A scan walks the roots and runs one `git worktree list` per hit, holding
/// the scanner lock throughout, and clients re-request whenever a draft pane
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
/// what refreshes, and a request only rescans when that timer has failed to.
pub const CACHE_TTL: Duration = Duration::from_secs(180);
/// Background refresh cadence. Repositories appear rarely; this keeps the
/// served list eventually fresh without anyone asking.
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
@@ -154,9 +159,15 @@ fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
(!paths.is_empty()).then_some(paths)
}
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
/// rather than nothing.
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
///
/// `HEAD` is only rewritten on checkout — committing updates
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
/// repo worked on daily from one long-lived branch would otherwise report its
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
///
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
/// resolve it so worktrees report their own reflog rather than nothing.
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
let dot_git = repo.join(".git");
let head = if dot_git.is_dir() {
@@ -173,6 +184,9 @@ fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
};
p.join("HEAD")
};
// Prefer the reflog next to whichever HEAD we resolved.
let reflog = head.with_file_name("logs").join("HEAD");
let head = if reflog.exists() { reflog } else { head };
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
Some(DateTime::<Utc>::from(mtime))
}
+66 -3
View File
@@ -39,14 +39,32 @@ impl WorktreeManager {
}
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
/// predictable (no UUID suffix), so an existing directory at the target
/// is an error rather than a silently different location.
///
/// `base_repo` is whatever the client picked, which the project list makes
/// easy to point at a *linked worktree* — placing the new tree inside it
/// would nest one worktree in another, and git hides a registered
/// worktree's contents from the outer `git status`, so the outer tree
/// would later look clean and its removal would delete the inner one's
/// uncommitted work (#54 review). Always place against the main checkout.
///
/// The override root gets the repo's basename too: without it two repos
/// spawning the same branch name target one directory and the second
/// fails with `PathExists`.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
let main = main_checkout(base_repo);
match &self.root_override {
Some(root) => root.join(slug),
None => base_repo.join("worktrees").join(slug),
Some(root) => {
let repo = main
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
root.join(repo).join(slug)
}
None => main.join("worktrees").join(slug),
}
}
@@ -108,6 +126,12 @@ impl WorktreeManager {
if !path.exists() {
return prune(base_repo);
}
if let Some(inner) = nested_worktree(path) {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason: format!("holds a nested worktree at {}", inner.display()),
});
}
if let Some(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
@@ -124,6 +148,45 @@ impl WorktreeManager {
}
}
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output();
let Ok(out) = out else {
return path.to_path_buf();
};
if !out.status.success() {
return path.to_path_buf();
}
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
common
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| path.to_path_buf())
}
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
/// those directories from `git status`, so the dirty-work guard cannot see
/// them and removal would delete them (#54 review).
fn nested_worktree(path: &Path) -> Option<PathBuf> {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.filter_map(|l| l.strip_prefix("worktree "))
.map(PathBuf::from)
.find(|p| p != path && p.starts_with(path))
}
fn sanitize_branch_for_path(branch: &str) -> String {
let mut out = String::with_capacity(branch.len());
for c in branch.chars() {
+76 -1
View File
@@ -70,8 +70,11 @@ fn worktree_root_override_redirects_placement() {
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// The repo's basename sits between the root and the branch slug, so two
// repos spawning the same branch name don't target one directory.
let repo_name = repo.file_name().unwrap();
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, wt_root.path().join("feat-example"));
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
assert!(path.exists());
}
@@ -288,3 +291,75 @@ fn add_fails_when_branch_already_exists() {
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
/// is easily a worktree rather than the main checkout. Placing inside it would
/// nest one worktree in another — and git hides a registered worktree's
/// contents from the outer `git status`, so the outer tree reads clean and
/// removing it deletes the inner one's uncommitted work (#54 review).
#[test]
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Spawn again with the *worktree* as the base, the way the picker would.
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
assert!(
!inner.starts_with(&outer),
"must not nest: {inner:?} inside {outer:?}"
);
assert_eq!(
inner.parent().and_then(|p| p.parent()),
Some(repo.canonicalize().unwrap().as_path()),
"placed against the main checkout"
);
}
/// Belt and braces for a nesting that predates this daemon: refuse to remove
/// a worktree that has another registered underneath it.
#[test]
fn remove_refuses_a_worktree_holding_a_nested_one() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Register one by hand inside `outer`, bypassing our placement.
let nested = outer.join("worktrees").join("hand-rolled");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"feat/hand",
nested.to_str().unwrap(),
],
);
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
mgr.remove(&repo, &outer)
.expect_err("a worktree holding a nested one must be refused");
assert!(nested.join("notes.txt").exists(), "inner work survives");
}
/// Two repos spawning the same branch name under one override root must not
/// collide (#54 review).
#[test]
fn override_root_keeps_repos_apart() {
let root_dir = TempDir::new().unwrap();
let a_dir = TempDir::new().unwrap();
let b_dir = TempDir::new().unwrap();
init_repo(a_dir.path());
init_repo(b_dir.path());
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
assert_ne!(a, b, "same branch name in two repos must not collide");
}
+2
View File
@@ -8,12 +8,14 @@ publish.workspace = true
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
[dependencies]
chrono.workspace = true
clap.workspace = true
crossterm.workspace = true
futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
ratatui.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
+296 -36
View File
@@ -14,9 +14,10 @@ use std::collections::{HashMap, HashSet};
use std::io::Stdout;
use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{CreateSessionRequest, DaemonClient};
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -53,6 +54,9 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64).
http: reqwest::Client,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -76,7 +80,14 @@ pub enum Effect {
Attach(Uuid),
Spawn(CreateSessionRequest),
Kill(Uuid),
Forward { id: Uuid, bytes: Vec<u8> },
Forward {
id: Uuid,
bytes: Vec<u8>,
},
/// Fetch presets / projects / names for a freshly opened draft (#64).
LoadDraft(usize),
/// A reroll drained the draft's local name batch (#64).
RefillNames(usize),
}
impl App {
@@ -85,6 +96,11 @@ impl App {
let help = HelpOverlay {
lines: render_help_lines(&cfg),
};
let http = reqwest::Client::builder()
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
.build()
.expect("build reqwest client");
Self {
cfg,
catalog,
@@ -93,6 +109,7 @@ impl App {
help,
chord: ChordState::new(chord_timeout),
daemon,
http,
region: RegionMap::default(),
last_sent_size: HashMap::new(),
clicks: ClickTracker::default(),
@@ -153,15 +170,13 @@ impl App {
{
// Insert mode over a focused pane: a draft eats the key
// into its form, a session gets the raw bytes.
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key)
self.forward_to_session(&key).into_iter().collect()
};
if let Some(effect) = effect {
self.perform(vec![effect]).await;
}
self.perform(effects).await;
}
Ok(false)
}
@@ -176,6 +191,8 @@ impl App {
Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -252,7 +269,10 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::NewDraftPane => self.state.open_draft_pane(),
Action::NewDraftPane => {
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
}
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -309,21 +329,97 @@ impl App {
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted the form.
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
return None;
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
let tab_idx = self.state.active_tab_idx;
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
return Vec::new();
};
if draft.on_input(key) != DraftInputResult::Submit {
return None;
let mut effects = Vec::new();
if draft.on_input(key) == DraftInputResult::Submit {
match draft.build_request() {
Ok(req) => {
draft.error = None;
effects.push(Effect::Spawn(req));
}
Err(e) => draft.error = Some(e.to_string()),
}
}
if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx));
}
effects
}
/// One `/api/names` batch. [`DaemonClient`] has no typed call for this
/// route yet, so it goes through plain reqwest against the same base URL
/// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`).
async fn fetch_names(&self, count: usize) -> Vec<String> {
let url = format!(
"{}{}",
maestro_protocol::routes::normalize_base(self.daemon.base()),
routes::NAMES
);
match self
.http
.get(url)
.query(&[(routes::COUNT_QUERY, count)])
.send()
.await
{
Ok(resp) => resp
.json::<NameBatch>()
.await
.map(|batch| batch.names)
.unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Populate a freshly opened draft: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and
/// a first name batch. Applies only if that tab still shows the draft —
/// a slow response must never clobber what replaced it.
async fn load_draft_data(&mut self, tab_idx: usize) {
let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects());
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
let Some(PaneRef::Draft(draft)) = self
.state
.tabs
.get_mut(tab_idx)
.and_then(|t| t.pane.as_mut())
else {
return;
};
if let Ok(info) = info {
draft.home_dir.clone_from(&info.home_dir);
if !info.presets.is_empty() {
draft.set_presets(info.presets);
}
}
if let Ok(projects) = projects {
draft.set_projects(projects, Utc::now());
}
if !names.is_empty() {
draft.seed_names(names);
}
}
/// Fetch a fresh name batch after a reroll drained the local queue.
async fn refill_names(&mut self, tab_idx: usize) {
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
let Some(PaneRef::Draft(draft)) = self
.state
.tabs
.get_mut(tab_idx)
.and_then(|t| t.pane.as_mut())
else {
return;
};
if !names.is_empty() {
draft.extend_names(names);
}
Some(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
command: draft.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}))
}
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
@@ -335,7 +431,19 @@ impl App {
self.state.complete_spawn(sid);
self.attach(sid);
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
Err(e) => {
// A failed POST belongs in the form it came from (#64), not a
// toast that dismisses on the next keystroke.
let message = e
.daemon_message()
.map(String::from)
.unwrap_or_else(|| e.to_string());
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
draft.error = Some(format!("spawn failed: {message}"));
} else {
self.state.toast(format!("spawn failed: {e}"));
}
}
}
}
@@ -509,12 +617,12 @@ mod tests {
use maestro_config::TuiSection;
fn make_app() -> App {
make_app_at("http://127.0.0.1:7070")
}
fn make_app_at(base: &str) -> App {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
App::new(
cfg,
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
)
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
}
#[test]
@@ -638,34 +746,186 @@ mod tests {
assert!(app.state.overlay().is_none());
}
#[test]
fn new_draft_pane_schedules_a_data_load() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::NewDraftPane),
vec![Effect::LoadDraft(0)]
);
}
/// A valid form flows through: pick a project (set directly — the picker
/// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row.
#[test]
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!("expected a draft pane");
};
for c in "/srv/work".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // cwd → command
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom
app.handle_draft_input(&InputKey::Tab); // preset → custom command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // command → submit
app.handle_draft_input(&InputKey::Tab); // → dir picker
app.handle_draft_input(&InputKey::Tab); // → name
for c in "crimson-otter".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // → submit
match app.handle_draft_input(&InputKey::Enter) {
Some(Effect::Spawn(req)) => {
match app.handle_draft_input(&InputKey::Enter).as_slice() {
[Effect::Spawn(req)] => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter"),
"the generated name rides along as metadata.name"
);
}
other => panic!("expected a Spawn effect, got {other:?}"),
other => panic!("expected a single Spawn effect, got {other:?}"),
}
}
/// Submitting with no directory names the missing field inside the form
/// and posts nothing (#64).
#[test]
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
// Walk to Submit without picking anything.
for _ in 0..3 {
app.handle_draft_input(&InputKey::Tab);
}
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
assert_eq!(
draft.error.as_deref(),
Some("Pick a working directory."),
"the refusal shows in the form"
);
// With a directory but an empty custom command, it names the command.
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!();
};
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!();
};
assert_eq!(
draft.error.as_deref(),
Some("Pick a command (or fill the custom command).")
);
}
/// A failed POST lands back in the form as an error line (#64), not a
/// toast that the next keystroke dismisses.
#[tokio::test]
async fn a_failed_spawn_shows_the_daemons_error_in_the_form() {
use std::time::Duration;
let mut app = App::new(
TuiConfig::load(&TuiSection::default()).unwrap(),
CatalogStore::default(),
DaemonClient::with_timeouts(
"http://127.0.0.1:9",
Duration::from_millis(200),
Duration::from_millis(500),
),
);
app.dispatch(Action::NewDraftPane);
let req = CreateSessionRequest {
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
app.submit_spawn(req).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
let error = draft
.error
.as_deref()
.expect("spawn failure must reach the form");
assert!(error.starts_with("spawn failed"), "{error}");
assert!(
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
"no kill-confirm style overlay took over"
);
}
/// The full data load against a live daemon stub: presets replace the
/// built-ins, projects land sorted, and the first generated name seeds
/// the field.
#[tokio::test]
async fn load_draft_data_fills_presets_projects_and_names() {
use axum::routing::get;
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({
"hostname": "box",
"version": "0.1.0",
"home_dir": "/home/u",
"presets": [
{"id": "aider", "label": "Aider", "command": "aider"},
{"id": "custom", "label": "Custom…", "command": ""}
]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([
{"path": "/repos/maestro", "name": "maestro",
"last_activity": chrono::Utc::now().to_rfc3339(),
"last_command": "claude"}
]))
}
async fn names() -> Json<serde_json::Value> {
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::PROJECTS, get(projects))
.route(maestro_protocol::routes::NAMES, get(names));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::NewDraftPane);
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
app.perform(effects).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider");
assert_eq!(draft.rows.len(), 1);
assert_eq!(draft.rows[0].project.name, "maestro");
assert_eq!(draft.home_dir, "/home/u");
assert_eq!(draft.name.value(), "crimson-otter");
}
/// Keys fed while no draft exists must be ignored, not panic.
#[test]
fn draft_input_is_a_no_op_without_a_draft() {
let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@ pub mod input;
pub mod keymap;
pub mod overlays;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
+216
View File
@@ -0,0 +1,216 @@
//! Pure helpers behind the draft pane'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());
}
}
+8 -1
View File
@@ -315,7 +315,14 @@ mod tests {
}
out.push('\n');
}
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
for s in [
"draft",
"cmd:",
"dir:",
"name:",
"[ Spawn ]",
"Other directory…",
] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
+3
View File
@@ -20,6 +20,9 @@ pub enum Focus {
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
// that boxing every access costs more than the copy does.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum PaneRef {
Session(Uuid),
+6 -5
View File
@@ -154,13 +154,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
onSuccess: (session) => {
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there.
const t = activeTab(layout);
const attached = leaves(t.root).includes(leaf);
if (attached) {
// pane at all, so only claim the leaf while it is still there —
// searching every tab, since the operator may have switched tabs
// while the request was in flight.
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
if (host) {
leaf.sessionId = session.id;
leaf.draft = null;
t.focusedLeafId = leaf.id;
host.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();