diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 57b91fc..41f5c2e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -97,3 +97,24 @@ jobs: - name: build run: pnpm -C web build + + # The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile + # change invalidates the pin silently elsewhere, so the packaged build has to + # fail here too. + flake: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install nix + run: | + curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + + - name: build flake packages + run: | + nix build \ + .#webFrontend \ + .#maestro \ + .#maestro-web \ + --no-link diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs index aab747e..78db18f 100644 --- a/crates/maestro-daemon/src/projects.rs +++ b/crates/maestro-daemon/src/projects.rs @@ -261,17 +261,13 @@ impl ProjectScanner { } /// Cached snapshot, rescanning (off-thread) when the TTL has lapsed. + /// Also the background timer's entry point: a tick inside the TTL is a + /// no-op (#75). pub async fn repos(&self) -> std::sync::Arc> { - self.repos_inner(false).await - } - - async fn repos_inner(&self, force: bool) -> std::sync::Arc> { let mut cache = self.cache.lock().await; - if !force { - if let Some((at, repos)) = cache.as_ref() { - if at.elapsed() < self.ttl { - return repos.clone(); - } + if let Some((at, repos)) = cache.as_ref() { + if at.elapsed() < self.ttl { + return repos.clone(); } } let roots = self.roots.clone(); @@ -287,11 +283,11 @@ impl ProjectScanner { repos } - /// Rescan now, ignoring the TTL. Called by the background timer — going - /// through `repos()` would hit the cache and make most ticks no-ops, - /// leaving requests to pay for the rescan instead (#54 review). + /// One background-refresh tick: rescan only if the cached snapshot has + /// passed its TTL (#75) — the timer used to bypass it and re-walk the + /// roots every [`REFRESH_INTERVAL`]. async fn refresh(&self) { - self.repos_inner(true).await; + self.repos().await; } /// Spawn the slow background refresh task. Drop the returned handle to @@ -544,6 +540,32 @@ mod tests { assert_eq!(fresh[0].name, "late"); } + #[tokio::test] + async fn background_refresh_honours_the_ttl() { + let tmp = tempfile::tempdir().unwrap(); + let mut scanner = ProjectScanner::new( + vec![tmp.path().to_path_buf()], + 3, + PathBuf::from("/nonexistent"), + ); + + let empty = scanner.repos().await; + assert!(empty.is_empty()); + + make_repo(tmp.path(), "late"); + // A background tick inside the TTL must not re-walk the filesystem: + // the new repo stays invisible until the cache expires. + scanner.refresh().await; + assert!(scanner.repos().await.is_empty()); + + // Once the TTL lapses, the next tick picks it up. + scanner.ttl = Duration::ZERO; + scanner.refresh().await; + let fresh = scanner.repos().await; + assert_eq!(fresh.len(), 1); + assert_eq!(fresh[0].name, "late"); + } + #[test] fn disabled_scanner_always_serves_empty() { let scanner = ProjectScanner::disabled(); diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index cecf43e..dff6470 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -175,7 +175,8 @@ impl App { // Render on next loop iteration. } _ = tokio::time::sleep(Duration::from_millis(33)) => { - // 30 Hz heartbeat. + let effects = self.poll_due_probes_at(std::time::Instant::now()); + self.perform(effects).await; } } } @@ -439,7 +440,7 @@ impl App { if draft.take_name_refill() { effects.push(Effect::RefillNames(tab_idx)); } - if let Some((seq, path)) = draft.take_gitroot_probe() { + if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) { effects.push(Effect::ProbeGitRoot { tab: tab_idx, seq, @@ -452,6 +453,26 @@ impl App { effects } + /// Fire any draft gitroot probes whose debounce window has elapsed (#74). + /// The 30 Hz heartbeat calls this so a burst of typing issues its one + /// probe even after the last keystroke. + fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec { + let mut effects = Vec::new(); + for tab_idx in 0..self.state.tabs.len() { + let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else { + continue; + }; + if let Some((seq, path)) = draft.take_gitroot_probe(now) { + effects.push(Effect::ProbeGitRoot { + tab: tab_idx, + seq, + path, + }); + } + } + effects + } + /// 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 — @@ -1328,9 +1349,7 @@ mod tests { for c in "crimson-otter".chars() { app.handle_draft_input(&InputKey::char(c)); } - app.handle_draft_input(&InputKey::Tab); // → worktree toggle - app.handle_draft_input(&InputKey::Tab); // → branch mode - app.handle_draft_input(&InputKey::Tab); // → new-branch name + app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93) app.handle_draft_input(&InputKey::Tab); // → submit match app.handle_draft_input(&InputKey::Enter).as_slice() { @@ -1347,10 +1366,11 @@ mod tests { } } - /// Editing the raw path with the worktree toggle on schedules a - /// `/api/gitroot` probe so the section learns its base repo (#65). + /// Editing the raw path schedules a `/api/gitroot` probe only once the + /// keystroke burst settles (#74): nothing fires inside the debounce + /// window; the heartbeat picks the due probe up afterwards. #[test] - fn typing_a_raw_path_schedules_a_gitroot_probe() { + fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() { let mut app = make_app(); app.dispatch(Action::NewDraftPane); let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { @@ -1359,13 +1379,24 @@ mod tests { draft.cwd_raw_visible = true; draft.focused_field = DraftField::CwdRaw; - let effects = app.handle_draft_input(&InputKey::char('/')); + // A burst of keystrokes fires nothing. + for c in "/opt/tooling".chars() { + assert!(app.handle_draft_input(&InputKey::char(c)).is_empty()); + assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty()); + } + + // Once the quiet period passes, exactly one probe is due. + let due = std::time::Instant::now() + + std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10; + match app.poll_due_probes_at(due).as_slice() { + [Effect::ProbeGitRoot { tab: 0, path, .. }] => { + assert_eq!(path, &std::path::PathBuf::from("/opt/tooling")); + } + other => panic!("expected one ProbeGitRoot effect, got {other:?}"), + } assert!( - matches!( - effects.as_slice(), - [Effect::ProbeGitRoot { tab: 0, seq: 2, .. }] - ), - "{effects:?}" + app.poll_due_probes_at(due).is_empty(), + "the probe is consumed once" ); } @@ -1520,8 +1551,8 @@ mod tests { 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.groups.len(), 1); + assert_eq!(draft.groups[0].main.name, "maestro"); assert_eq!(draft.home_dir, "/home/u"); assert_eq!(draft.name.value(), "crimson-otter"); } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 9f4eb2b..790e6bd 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -238,6 +238,26 @@ mod tests { assert!(s.contains("ctrl-glorb"), "diagnostic: {s}"); } + /// Regression for #70: the previous canonical form of a real Shift+S + /// keypress was `"shift-S"`, which the default `"S"` binding never + /// matched. Drive the actual crossterm event, not the binding string, + /// so the dead-key bug can't come back unnoticed. + #[test] + fn real_shift_s_keypress_resolves_to_fast_forward_spawn() { + use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers}; + + use crate::input::{canonicalise, Region}; + + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises"); + + assert_eq!( + cfg.keymap.resolve(Mode::Normal, &key.canonical()), + Some(Action::FastForwardSpawn), + ); + } + #[test] fn custom_chord_timeout_is_applied() { let mut section = TuiSection::default(); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 3e26386..ab5db6a 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -7,16 +7,35 @@ //! //! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`): //! command presets from `/api/info` (with an implicit `custom` free-text -//! entry), a project picker fed by [`crate::projects`] over `/api/projects` -//! plus an `Other directory…` escape hatch, a generated session name from +//! entry), a two-step project/worktree picker (#96, mirroring the web +//! redesign in #95) fed by [`crate::projects`] over `/api/projects` plus an +//! `Other directory…` escape hatch, a generated session name from //! `/api/names` with a reroll key, and validation that reports which field is //! missing instead of posting an empty request. A worktree section (#65) //! mirrors `web/src/spawnForm.ts`: on by default over a git repository, //! branch tracking the generated name until touched, with a new-or-existing //! branch switch fed by `/api/branches`. +//! +//! The picker itself is two steps (#96), verbatim from the rules in +//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment: +//! 1. Step one is one box per repository (name, then full path). A worktree +//! is a box only when its main checkout is missing from the scan. +//! 2. The filter keeps a repository when the query matches it or any of its +//! worktrees, so a worktree name still finds its box. +//! 3. Picking a box reveals step two: "+ new", then that repository's +//! worktrees newest-first. "Other directory…" clears both steps. +//! 4. "+ new" is the default selection — except on a box that is itself a +//! linked worktree, which defaults to that worktree. +//! 5. "+ new" means create: cwd is the repository, create-worktree on. +//! An existing worktree means run in it: cwd is that worktree, +//! create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so +//! these are separate acts. +//! 6. Neither step probes `/api/gitroot`; the scan already reports +//! repository membership. Only the free-text hatch pays for a probe. use std::collections::VecDeque; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; @@ -35,6 +54,12 @@ use crate::projects; /// dry. pub const NAME_BATCH_SIZE: usize = 20; +/// Quiet period after raw-path typing before a `/api/gitroot` probe fires +/// (#74). Every keystroke resets it, so a burst issues one request once the +/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in +/// web/src/spawnForm.ts so both clients debounce alike. +pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250; + /// Width of the fixed label gutter every form line starts with (`cmd: `, /// `dir: `, …). const GUTTER: usize = 5; @@ -45,11 +70,19 @@ pub enum DraftField { Preset, /// Free-text command, reachable only while a custom preset is selected. CustomCommand, - /// Project picker: typing filters, Up / Down highlight, Enter picks. + /// Step one: repository picker. Typing filters, Up / Down highlight, + /// Enter picks and reveals step two (#96). DirPicker, /// Free-text path, revealed by picking `Other directory…`. CwdRaw, + /// Step two, shown once a repository is picked: "+ new" then that + /// repository's worktrees newest-first (#96 rule 3). Up / Down move and + /// apply immediately, mirroring the web form's per-click choice. + WorktreeChoice, Name, + /// Disclosure collapsing the worktree/branch controls below (#93). + /// Closed by default; Enter / Space toggles. + Advanced, /// Worktree on/off for this session (#65). Enter / Space toggles. WorktreeToggle, /// New-vs-existing branch source; Left / Right switches. @@ -71,14 +104,50 @@ pub enum WorktreeBranchMode { Existing, } -/// One flattened project-picker entry: a discovered repo/worktree plus what -/// rendering needs to know about it. `indent` marks worktrees nested under -/// their main checkout. +/// "new" (create a worktree) or an existing worktree to run in — the TUI +/// mirror of the web form's `WorktreeChoice` (#96 rule 5). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct DraftRow { +pub enum WorktreeChoice { + New, + Existing(PathBuf), +} + +/// One repository box for step one of the picker (#96 rule 1): the main +/// checkout (or, when it's missing from the scan, its freshest worktree +/// standing in for it) plus every other worktree of the same repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftGroup { + pub main: Project, + pub main_stale: bool, + pub worktrees: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DraftWorktreeRow { pub project: Project, pub stale: bool, - pub indent: bool, +} + +/// The repository key a [`DraftGroup`] was formed under — same key +/// `projects::group_projects` groups by, kept separately because the main +/// box's own path only equals it when the main checkout itself was found. +fn group_key(main: &Project) -> PathBuf { + main.base_repo.clone().unwrap_or_else(|| main.path.clone()) +} + +/// Every worktree of a group, including the group's own box when the scan +/// found no main checkout to nest it under — the TUI mirror of the web +/// form's `worktreesOf`. Already newest-first: when the main box stands in +/// for a missing main checkout it is the freshest member by construction +/// ([`projects::group_projects`]), and `worktrees` itself is sorted +/// newest-first. +fn worktree_rows_of(group: &DraftGroup) -> Vec { + let mut rows = Vec::new(); + if group.main.is_worktree { + rows.push(group.main.clone()); + } + rows.extend(group.worktrees.iter().map(|w| w.project.clone())); + rows } /// Why a submit was refused. The message names the missing field so the form @@ -123,15 +192,29 @@ pub struct DraftPane { pub presets: Vec, pub selected_preset: usize, pub custom_command: crate::text_input::TextInput, - /// Discovered projects, sorted fresh-first with worktrees nested. - pub rows: Vec, + /// Discovered projects, sorted fresh-first and grouped by repository + /// (#96 step one). + pub groups: Vec, pub filter: crate::text_input::TextInput, - /// Highlighted entry within the visible list; the implicit - /// `Other directory…` entry sits at index `visible_rows().len()`. + /// Highlighted entry within the visible step-one list; the implicit + /// `Other directory…` entry sits at index `visible_groups().len()`. pub highlighted: usize, + /// Set by an explicit Up/Down move, cleared by typing (#97): typing + /// mirrors the web form's plain filter input, where Enter always + /// resolves and submits; an explicit arrow move mirrors Tab landing on + /// a not-yet-active picker button there, where a first Enter only + /// chooses. See `dir_picker_choice_active` at the Enter match arm. + dir_picker_navigated: bool, pub cwd_raw_visible: bool, pub cwd_raw: crate::text_input::TextInput, pub picked_cwd: Option, + /// The repository picked in step one ([`group_key`] of its main box), + /// `None` until a box is picked. Looked up fresh in `groups` rather than + /// cached, so a project-list refresh can't leave it stale. + pub selected_group_key: Option, + /// Step two's current pick (#96 rule 4/5). Meaningless while + /// `selected_group_key` is `None`. + pub worktree_choice: WorktreeChoice, pub name: crate::text_input::TextInput, pub name_queue: VecDeque, /// Set once the operator types or rerolls a name; guards refills against @@ -141,6 +224,10 @@ pub struct DraftPane { /// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe /// unticks it, like the web form. pub worktree_enabled: bool, + /// Whether the worktree/branch controls are collapsed behind the + /// "Advanced options" disclosure (#93). Closed by default — most spawns + /// take the default target. + pub advanced_collapsed: bool, /// Enclosing repository of the chosen target — from the project scan for /// picked rows, `/api/gitroot` for raw paths. `None` until known. pub base_repo: Option, @@ -149,6 +236,9 @@ pub struct DraftPane { /// Bumped on every target change; answers carrying an older seq are stale. probe_seq: u32, needs_probe: bool, + /// When the pending probe may fire (debounce, #74). Typing into the raw + /// path resets it; a discrete action like the toggle makes it due at once. + probe_due_at: Option, /// Set when a probe found no repository (drives the rendered hint). repo_missing: bool, /// New-branch name. Tracks [`Self::name`] until edited, like the web form. @@ -183,21 +273,26 @@ impl Default for DraftPane { presets, selected_preset: 0, custom_command: crate::text_input::TextInput::default(), - rows: Vec::new(), + groups: Vec::new(), filter: crate::text_input::TextInput::default(), highlighted: 0, + dir_picker_navigated: false, cwd_raw_visible: false, cwd_raw: crate::text_input::TextInput::default(), picked_cwd: None, + selected_group_key: None, + worktree_choice: WorktreeChoice::New, name: crate::text_input::TextInput::default(), name_queue: VecDeque::new(), name_touched: false, needs_name_refill: false, worktree_enabled: true, + advanced_collapsed: true, base_repo: None, probed_cwd: None, probe_seq: 0, needs_probe: false, + probe_due_at: None, repo_missing: false, branch: crate::text_input::TextInput::default(), branch_edited: false, @@ -229,24 +324,24 @@ impl DraftPane { .unwrap_or(0); } - /// Replace the project snapshot: sort fresh-first (#54), nest worktrees - /// under their main checkout, and precompute staleness for rendering. + /// Replace the project snapshot: sort fresh-first (#54), group by + /// repository (#96), and precompute staleness for rendering. pub fn set_projects(&mut self, discovered: Vec, now: DateTime) { let sorted = projects::sort_projects(discovered, now); let groups = projects::group_projects(&sorted); - self.rows = groups + self.groups = groups .iter() - .flat_map(|g| { - std::iter::once(DraftRow { - stale: projects::is_stale(g.main, now), - indent: false, - project: g.main.clone(), - }) - .chain(g.worktrees.iter().map(|wt| DraftRow { - stale: projects::is_stale(wt, now), - indent: true, - project: (*wt).clone(), - })) + .map(|g| DraftGroup { + main: g.main.clone(), + main_stale: projects::is_stale(g.main, now), + worktrees: g + .worktrees + .iter() + .map(|wt| DraftWorktreeRow { + project: (*wt).clone(), + stale: projects::is_stale(wt, now), + }) + .collect(), }) .collect(); self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1)); @@ -320,18 +415,23 @@ impl DraftPane { self.repo_missing = false; self.probe_seq += 1; self.needs_probe = false; + self.probe_due_at = None; self.probed_cwd = self.cwd(); self.branches_for = None; self.schedule_branch_load(); } /// A target with an unknown repo (raw path edit or the Other… hatch). - /// Cancels any in-flight probe and requests a fresh one. + /// Cancels any in-flight probe and schedules a fresh one after the + /// debounce window (#74). fn note_unknown_repo(&mut self) { self.base_repo = None; self.repo_missing = false; self.probe_seq += 1; self.needs_probe = self.worktree_enabled && self.cwd().is_some(); + self.probe_due_at = self + .needs_probe + .then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS)); self.branches_for = None; self.schedule_branch_load(); } @@ -342,14 +442,19 @@ impl DraftPane { } } - /// Consume the gitroot-probe request: the caller fetches + /// Consume the gitroot-probe request once its debounce window has passed + /// (`now` at or after [`Self::probe_due_at`]): the caller fetches /// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`] - /// with the same seq. - pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> { + /// with the same seq. Returns `None` while typing is still in flight. + pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> { if !self.needs_probe { return None; } + if now < self.probe_due_at? { + return None; + } self.needs_probe = false; + self.probe_due_at = None; let cwd = self.cwd()?; self.probe_seq += 1; Some((self.probe_seq, cwd)) @@ -363,6 +468,7 @@ impl DraftPane { return; } self.needs_probe = false; + self.probe_due_at = None; self.probed_cwd = self.cwd(); match root { Some(repo) => { @@ -420,6 +526,21 @@ impl DraftPane { self.set_branch_mode(next); } + /// Enter / Space on the "Advanced options" header (#93). + pub fn toggle_advanced(&mut self) { + self.advanced_collapsed = !self.advanced_collapsed; + } + + /// What the collapsed header says about this spawn's default, so it stays + /// visible without expanding (#93). + fn advanced_summary(&self) -> &'static str { + if self.worktree_enabled { + "creates a worktree" + } else { + "no worktree" + } + } + /// Enter / Space on the toggle. Re-enabling reuses a still-valid repo /// answer instead of re-probing. pub fn toggle_worktree(&mut self) { @@ -434,7 +555,9 @@ impl DraftPane { } if self.base_repo.is_none() { self.probe_seq += 1; + // A discrete toggle, not a keystroke burst: due immediately. self.needs_probe = cwd.is_some(); + self.probe_due_at = self.needs_probe.then(Instant::now); } self.schedule_branch_load(); } @@ -576,15 +699,20 @@ impl DraftPane { order.push(DraftField::DirPicker); if self.cwd_raw_visible { order.push(DraftField::CwdRaw); + } else if self.selected_group_key.is_some() { + order.push(DraftField::WorktreeChoice); } order.push(DraftField::Name); - order.push(DraftField::WorktreeToggle); - if self.worktree_body_visible() { - order.push(DraftField::BranchMode); - order.push(match self.branch_mode { - WorktreeBranchMode::New => DraftField::BranchNew, - WorktreeBranchMode::Existing => DraftField::BranchExisting, - }); + order.push(DraftField::Advanced); + if !self.advanced_collapsed { + order.push(DraftField::WorktreeToggle); + if self.worktree_body_visible() { + order.push(DraftField::BranchMode); + order.push(match self.branch_mode { + WorktreeBranchMode::New => DraftField::BranchNew, + WorktreeBranchMode::Existing => DraftField::BranchExisting, + }); + } } order.extend([DraftField::Submit]); order @@ -617,18 +745,25 @@ impl DraftPane { ((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize; } - /// Rows surviving the type-to-filter, in display order. - pub fn visible_rows(&self) -> Vec<&DraftRow> { + /// Step-one boxes surviving the type-to-filter, in display order. A + /// group survives when the query matches its box or any of its + /// worktrees (#96 rule 2), so a worktree name still finds its box. + pub fn visible_groups(&self) -> Vec<&DraftGroup> { let query = self.filter.value(); - self.rows + self.groups .iter() - .filter(|r| projects::matches_filter(&r.project, query)) + .filter(|g| { + projects::matches_filter(&g.main, query) + || g.worktrees + .iter() + .any(|w| projects::matches_filter(&w.project, query)) + }) .collect() } /// Visible entries including the trailing implicit `Other directory…`. pub fn entry_count(&self) -> usize { - self.visible_rows().len() + 1 + self.visible_groups().len() + 1 } /// Highlight clamped to the current entry count. @@ -641,33 +776,62 @@ impl DraftPane { if count == 0 { return; } + self.dir_picker_navigated = true; self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize; } - /// Act on the highlighted picker entry: a project becomes the working - /// directory (and pre-selects the command it was last used with), while - /// the trailing entry reveals the free-text path input. + /// The step-one group currently picked, looked up fresh against the + /// latest snapshot so a project-list refresh can't leave a stale clone + /// behind. + pub fn selected_group(&self) -> Option { + let key = self.selected_group_key.as_ref()?; + self.groups + .iter() + .find(|g| &group_key(&g.main) == key) + .cloned() + } + + /// Step two's rows: "+ new" first, then the repository's worktrees + /// newest-first (#96 rule 3). Empty while no group is picked. + pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> { + let Some(group) = self.selected_group() else { + return Vec::new(); + }; + let mut rows = vec![( + WorktreeChoice::New, + "+ new".to_string(), + format!("new worktree of {}", group.main.name), + )]; + rows.extend(worktree_rows_of(&group).into_iter().map(|p| { + ( + WorktreeChoice::Existing(p.path.clone()), + p.name.clone(), + p.path.to_string_lossy().into_owned(), + ) + })); + rows + } + + /// Whether the highlighted step-one row is already the picked group + /// (#97): once true, a further Enter submits instead of re-picking it. + /// Always false on the trailing "Other directory…" row — picking it is + /// never a no-op, so it always takes the Enter to reveal the raw field. + fn dir_picker_choice_active(&self) -> bool { + let visible = self.visible_groups(); + let sel = self.selection(); + sel < visible.len() + && self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main)) + } + + /// Act on the highlighted step-one box: reveals step two, defaulting per + /// rule 4. The trailing entry reveals the free-text path input and + /// clears both steps (rule 3). pub fn select_entry(&mut self) { - let visible = self.visible_rows(); + let visible = self.visible_groups(); let sel = self.selection(); if sel < visible.len() { - let project = visible[sel].project.clone(); - self.picked_cwd = Some(project.path.clone()); - self.cwd_raw_visible = false; - // The scan already knows this directory's repo (#61): no probe - // round trip needed, exactly like the web form. - let repo = project - .base_repo - .clone() - .unwrap_or_else(|| project.path.clone()); - if let Some(cmd) = project.last_command.as_deref() { - if !cmd.is_empty() { - if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { - self.selected_preset = idx; - } - } - } - self.note_picked_repo(repo); + let group = visible[sel].clone(); + self.select_group(&group); return; } let seed = self.cwd().unwrap_or_else(|| { @@ -677,6 +841,7 @@ impl DraftPane { PathBuf::from(&self.home_dir) } }); + self.clear_project_selection(); self.picked_cwd = None; self.cwd_raw_visible = true; self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy()); @@ -684,6 +849,95 @@ impl DraftPane { self.note_unknown_repo(); } + /// Pick a step-one box: remember it and default step two (#96 rule 4) — + /// "+ new", except a box that is itself a linked worktree (the scan + /// found no main checkout for it), which defaults to that worktree. + fn select_group(&mut self, group: &DraftGroup) { + self.selected_group_key = Some(group_key(&group.main)); + let default = if group.main.is_worktree { + WorktreeChoice::Existing(group.main.path.clone()) + } else { + WorktreeChoice::New + }; + self.apply_worktree_choice(default); + } + + /// Clear both picker steps (#96 rule 3), e.g. for `Other directory…`. + fn clear_project_selection(&mut self) { + self.selected_group_key = None; + self.worktree_choice = WorktreeChoice::New; + } + + /// Apply a step-two pick (#96 rule 5): "+ new" targets the repository + /// itself with worktree creation on; an existing worktree targets it + /// directly with worktree creation off — the daemon never adopts + /// (docs/WORKTREES.md). Neither path probes `/api/gitroot` (rule 6): the + /// scan already reported this target's repository. + pub fn apply_worktree_choice(&mut self, choice: WorktreeChoice) { + let Some(group) = self.selected_group() else { + return; + }; + let target = match &choice { + WorktreeChoice::New => group.main.clone(), + WorktreeChoice::Existing(path) => { + match worktree_rows_of(&group) + .into_iter() + .find(|p| &p.path == path) + { + Some(p) => p, + None => return, + } + } + }; + self.worktree_choice = choice.clone(); + self.picked_cwd = Some(target.path.clone()); + self.cwd_raw_visible = false; + if let Some(cmd) = target.last_command.as_deref() { + if !cmd.is_empty() { + if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { + self.selected_preset = idx; + } + } + } + match choice { + WorktreeChoice::New => { + self.worktree_enabled = true; + let repo = target + .base_repo + .clone() + .unwrap_or_else(|| target.path.clone()); + self.note_picked_repo(repo); + } + WorktreeChoice::Existing(_) => { + self.worktree_enabled = false; + self.base_repo = None; + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = false; + self.probe_due_at = None; + self.probed_cwd = self.cwd(); + self.branches_for = None; + } + } + } + + /// Move step two's highlight and apply the newly-highlighted choice at + /// once, matching the web form's per-click pick — there is no separate + /// confirm step. + pub fn move_worktree_choice(&mut self, delta: i32) { + let rows = self.worktree_choice_rows(); + let count = rows.len() as i32; + if count == 0 { + return; + } + let cur = rows + .iter() + .position(|(c, _, _)| *c == self.worktree_choice) + .unwrap_or(0) as i32; + let next = ((cur + delta).rem_euclid(count)) as usize; + self.apply_worktree_choice(rows[next].0.clone()); + } + /// Handle one keystroke while this draft owns input (Insert mode, pane /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; /// the caller validates, fires the POST and replaces the draft with the @@ -698,23 +952,41 @@ impl DraftPane { self.prev_field(); DraftInputResult::Consumed } - InputKey::Enter => match self.focused_field { - DraftField::Submit => DraftInputResult::Submit, - DraftField::DirPicker => { - self.select_entry(); + // Enter submits from any field (#97), mirroring web/src/spawnForm.ts. + // Two fields keep their own Enter instead: `DirPicker`, whose + // highlighted row isn't the picked one until a first Enter chooses + // it (the next Enter, now that it's active, falls through to + // submit); and the disclosure/toggle pair (`Advanced`, + // `WorktreeToggle`), which activate on Enter like a button rather + // than submitting, same as the web form's own ` +
Name
@@ -142,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
- + +
@@ -182,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const errEl = form.querySelector(".form-error") as HTMLElement; const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement; - const recentsEl = form.querySelector(".projects") as HTMLElement; + const projectsEl = form.querySelector(".projects") as HTMLElement; const filterInput = form.querySelector(".project-filter") as HTMLInputElement; + const otherBtn = form.querySelector(".project-other") as HTMLButtonElement; + const wtStep = form.querySelector(".wt-step") as HTMLElement; + const wtChoices = form.querySelector(".wt-choices") as HTMLElement; const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement; const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement; const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement; + const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement; + const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement; + const wtSummary = form.querySelector(".wt-summary") as HTMLElement; const wtHint = form.querySelector(".wt-hint") as HTMLElement; const wtBody = form.querySelector(".wt-body") as HTMLElement; const wtRepo = form.querySelector(".wt-repo") as HTMLElement; @@ -337,6 +358,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { emit(); } + /** Re-highlight the operator's already-picked tile after `renderCommandTiles` + * rebuilds the row from scratch (new tiles carry no `is-active` class) — + * without touching `command`, since that was already settled. */ + function reapplyActiveTile(): void { + for (const tile of cmdTiles.querySelectorAll(".tile")) { + tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId); + } + } + function renderCommandTiles(): void { cmdTiles.replaceChildren(); for (const p of presets) { @@ -345,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { tile.className = "tile"; tile.dataset.id = p.id; tile.innerHTML = `${escapeHtml(p.label)}`; - tile.addEventListener("click", () => selectPreset(p.id)); + tile.addEventListener("click", () => { + commandTouched = true; + selectPreset(p.id); + }); cmdTiles.appendChild(tile); } } + // Set once the operator picks a tile or types a custom command — guards + // `applyInitialCommand()` the same way `nameTouched` guards a late name + // batch: a late `/api/info` resolution must never overwrite a field the + // operator has already touched (#76). + let commandTouched = false; + cmdRaw.addEventListener("input", () => { if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { + commandTouched = true; command = cmdRaw.value.trim(); emit(); } @@ -367,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (info.presets?.length) { presets = info.presets; renderCommandTiles(); - applyInitialCommand(); + if (commandTouched) { + reapplyActiveTile(); + } else { + applyInitialCommand(); + } } }) .catch(() => { @@ -394,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (first) selectPreset(first.id, false); } - // ---- working-directory picker --------------------------------------- + // ---- working-directory picker (#95) ---------------------------------- + // Two steps, and the TUI mirror (#96) must match these rules verbatim: + // 1. Step one is one box per repository (name, then full path). A worktree + // is a box only when its main checkout is missing from the scan. + // 2. The filter keeps a repository when the query matches it or any of its + // worktrees, so a worktree name still finds its box. + // 3. Picking a box reveals step two: "+ new", then that repository's + // worktrees newest-first. "Other directory…" clears both steps. + // 4. "+ new" is the default selection — except on a box that is itself a + // linked worktree, which defaults to that worktree. + // 5. "+ new" means create: cwd is the repository, create-worktree on. + // An existing worktree means run in it: cwd is that worktree, + // create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so + // these are separate acts — #91's rule, now an explicit choice. + // 6. Neither step probes /api/gitroot; the scan already reports repository + // membership. Only the free-text hatch pays for a probe. + + /** "new" (create a worktree) or the path of an existing worktree to run in. */ + type WorktreeChoice = "new" | string; + + let discovered: Project[] = []; // daemon snapshot, daemon-sorted + let groups: ProjectGroup[] = []; // rebuilt on every render + let selectedGroup: ProjectGroup | null = null; + let worktreeChoice: WorktreeChoice = "new"; + function showRawCwd(prefillValue?: string, focus = true): void { cwdRawWrap.hidden = false; if (prefillValue != null) cwd = prefillValue; cwdRaw.value = cwd; - markActiveProject(null); + clearProjectSelection(); if (focus) { cwdRaw.focus(); cwdRaw.select(); @@ -413,55 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwd = cwdRaw.value.trim(); branchCache = null; emit(); - if (wtEnabled.checked) probeGitRoot(); + if (wtEnabled.checked) scheduleGitRootProbe(); }); - function markActiveProject(activeCwd: string | null): void { - for (const row of recentsEl.querySelectorAll(".project-row")) { - row.classList.toggle( - "is-active", - (row as HTMLElement).dataset.cwd === activeCwd, - ); + /** Every worktree of a group, including the group's own box when the scan + * found no main checkout to nest it under. */ + function worktreesOf(group: ProjectGroup): Project[] { + return [group.main, ...group.worktrees].filter((p) => p.is_worktree); + } + + function markActiveBox(): void { + const active = selectedGroup?.main.path ?? null; + for (const box of projectsEl.querySelectorAll(".project-box")) { + box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active); } } - function selectProject(project: Project): void { - cwd = project.path; + function markActiveChoice(): void { + for (const btn of wtChoices.querySelectorAll(".wt-choice")) { + const el = btn as HTMLElement; + const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? ""); + el.classList.toggle("is-active", value === worktreeChoice); + } + } + + function clearProjectSelection(): void { + selectedGroup = null; + worktreeChoice = "new"; + wtChoices.replaceChildren(); + wtStep.hidden = true; + markActiveBox(); + } + + function selectProject(group: ProjectGroup): void { + selectedGroup = group; cwdRawWrap.hidden = true; - markActiveProject(project.path); - // The scan already knows this directory is inside a repo (#61): the - // toggle defaults back on and no /api/gitroot round trip is needed. - wtEnabled.checked = true; - applyProjectRepo(project.base_repo ?? project.path); + renderWorktreeChoices(group); + markActiveBox(); + // Rule 4: a box that is itself a linked worktree defaults to running there. + applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new"); + } + + function applyWorktreeChoice(choice: WorktreeChoice): void { + const group = selectedGroup; + if (!group) return; + const target = + choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice); + if (!target) return; + worktreeChoice = choice; + cwd = target.path; + branchCache = null; + markActiveChoice(); + if (choice === "new") { + wtEnabled.checked = true; + updateWtSummary(); + applyProjectRepo(group.main.base_repo ?? group.main.path); + } else { + wtEnabled.checked = false; + updateWtSummary(); + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = true; + } emit(); - // Picking a project pre-selects the command it was last used with. - const match = presets.find((p) => p.command === project.last_command && p.command !== ""); + // The chosen directory pre-selects the command it was last used with. + const match = presets.find((p) => p.command === target.last_command && p.command !== ""); if (match) selectPreset(match.id); } - let discovered: Project[] = []; // daemon snapshot, daemon-sorted + /** Groups with at least one member (itself or a worktree) matching `query`. */ + function visibleGroups(query: string): ProjectGroup[] { + return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + } function renderProjects(): void { - recentsEl.replaceChildren(); - const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date()); - for (const group of groupProjects(sorted)) { - recentsEl.appendChild(projectRow(group.main)); - for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt)); - } - if (discovered.length > 0 && sorted.length === 0) { + projectsEl.replaceChildren(); + groups = groupProjects(sortProjects(discovered, new Date())); + const visible = visibleGroups(filterInput.value); + for (const group of visible) projectsEl.appendChild(projectBox(group)); + if (discovered.length > 0 && visible.length === 0) { const empty = document.createElement("p"); empty.className = "picker-empty"; empty.textContent = "No projects match."; - recentsEl.appendChild(empty); + projectsEl.appendChild(empty); } - recentsEl.appendChild(otherDirRow()); - markActiveProject(cwdRawWrap.hidden ? cwd : null); + markActiveBox(); } filterInput.addEventListener("input", renderProjects); + otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir)); async function loadProjects(): Promise { - recentsEl.replaceChildren(loadingRow()); + projectsEl.replaceChildren(loadingRow()); try { discovered = await fetchProjects(); } catch { @@ -472,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (seedCwd) { // A seed sets the directory verbatim, regardless of what the scan found. // `cwdRawVisible === false` marks a draft restore whose cwd came from a - // picked row: keep the picker and don't steal focus. Otherwise land + // picked box: keep the picker and don't steal focus. Otherwise land // focused in the raw input (clone / no-match flows). if (prefill.cwdRawVisible === false) { cwd = seedCwd; cwdRaw.value = seedCwd; - markActiveProject(cwd); + // Restoring must not touch the worktree toggle — the seed carries it. + if (!restoreSelection(seedCwd)) clearProjectSelection(); } else { showRawCwd(seedCwd, false); } @@ -494,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { emit(); } - function projectRow(project: Project): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${ - isStale(project, new Date()) ? " is-stale" : "" - }`; - row.dataset.cwd = project.path; - // Abbreviate exactly like the sidebar: home → `~`, then collapse leading - // segments to initials (`~/g/n/maestro`). The full path stays in the tooltip - // and `dataset.cwd` (what's actually submitted). - const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir)); - row.innerHTML = ` - - ${escapeHtml(project.name)} - ${display} - - ${escapeHtml(project.last_command ?? "")}`; - row.addEventListener("click", () => selectProject(project)); - return row; + /** Put the two steps back on a restored draft's cwd, without re-running the + * side effects of a fresh pick. */ + function restoreSelection(path: string): boolean { + for (const group of groups) { + const wt = worktreesOf(group).find((p) => p.path === path); + const choice: WorktreeChoice | null = + wt ? wt.path : group.main.path === path ? "new" : null; + if (choice === null) continue; + selectedGroup = group; + worktreeChoice = choice; + renderWorktreeChoices(group); + markActiveChoice(); + markActiveBox(); + return true; + } + return false; } - function otherDirRow(): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "project-row project-other"; - row.innerHTML = `Other directory…`; - row.addEventListener("click", () => showRawCwd(cwd || homeDir)); - return row; + function projectBox(group: ProjectGroup): HTMLElement { + const project = group.main; + const box = document.createElement("button"); + box.type = "button"; + box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; + box.dataset.cwd = project.path; + const trees = worktreesOf(group).length; + // The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what + // tells two projects sharing a basename apart (#95). + const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""] + .filter(Boolean) + .join(" · "); + box.innerHTML = ` + ${escapeHtml(project.name)} + ${escapeHtml(project.path)} + ${escapeHtml(meta)}`; + box.addEventListener("click", () => selectProject(group)); + return box; + } + + function renderWorktreeChoices(group: ProjectGroup): void { + wtChoices.replaceChildren(); + wtChoices.appendChild( + choiceButton("new", "+ new", `new worktree of ${group.main.name}`), + ); + for (const wt of worktreesOf(group)) { + wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path)); + } + wtStep.hidden = false; + } + + function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`; + btn.dataset.kind = choice === "new" ? "new" : "existing"; + if (choice !== "new") btn.dataset.cwd = choice; + btn.innerHTML = ` + ${escapeHtml(label)} + ${escapeHtml(sub)}`; + btn.addEventListener("click", () => applyWorktreeChoice(choice)); + return btn; } function loadingRow(): HTMLElement { @@ -531,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { return p; } + // ---- worktree: disclosure (#93), reuses the sidebar's folder-header --- + let advancedCollapsed = true; + function setAdvancedCollapsed(collapsed: boolean): void { + advancedCollapsed = collapsed; + wtToggle.setAttribute("aria-expanded", String(!collapsed)); + wtCollapsible.hidden = collapsed; + } + function updateWtSummary(): void { + wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree"; + } + wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed)); + // ---- worktree (branch-only; base_repo inferred via scan or /gitroot) -- // The project scan already reports repo membership, so a picked project // resolves its base repo locally (#61); only the free-text escape hatch @@ -569,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { applyProjectRepo(project.base_repo ?? project.path); return; } - void probeGitRoot(); + scheduleGitRootProbe(); + } + + // Every keystroke resets this timer, so a burst of typing issues one + // request once the operator pauses. `probeToken` inside probeGitRoot still + // owns ordering: a scheduled probe that fires after a newer one started + // loses the token race and is dropped. + let probeTimer: number | undefined; + function scheduleGitRootProbe(): void { + window.clearTimeout(probeTimer); + probeTimer = window.setTimeout(() => { + probeTimer = undefined; + void probeGitRoot(); + }, GITROOT_PROBE_DEBOUNCE_MS); } async function probeGitRoot(): Promise { @@ -598,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // Publish the untick too, or the next render seeds the box checked with // no inferred repo and submitting fails. wtEnabled.checked = false; + updateWtSummary(); emit(); wtBody.hidden = true; wtHint.hidden = false; @@ -671,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { branchSelect.addEventListener("change", () => emit()); wtEnabled.addEventListener("change", () => { + updateWtSummary(); emit(); if (wtEnabled.checked) { resolveBaseRepo(); @@ -684,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // The toggle defaults ON (#61): the operator almost always wants a // worktree, and every non-repo target unticks it via the gitroot probe. wtEnabled.checked = prefill.worktreeEnabled ?? true; + updateWtSummary(); if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch; setBranchMode(branchMode); @@ -694,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ const autocomplete = attachDirAutocomplete(cwdRaw); + // ---- Enter submits from anywhere (#97) -------------------------------- + // A project-box/wt-choice that isn't yet the active pick takes the Enter to + // choose it instead; the next Enter, now that it's active, submits. + // dirAutocomplete's own Enter (accept the highlight) already preventDefaults + // when it handles the key, which the `e.defaultPrevented` guard defers to. + // The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle` + // does the same) rather than folding it into the blanket-submit rule below. + form.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.defaultPrevented) return; + const target = e.target as HTMLElement; + + if (target === wtEnabled) { + e.preventDefault(); + wtEnabled.checked = !wtEnabled.checked; + wtEnabled.dispatchEvent(new Event("change")); + return; + } + + if (target instanceof HTMLButtonElement) { + if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return; + e.preventDefault(); + if (!target.classList.contains("is-active")) { + target.click(); + return; + } + form.requestSubmit(); + return; + } + + e.preventDefault(); + if (target === filterInput) { + const top = visibleGroups(filterInput.value)[0]; + if (top && top.main.path !== selectedGroup?.main.path) selectProject(top); + } + form.requestSubmit(); + }); + // ---- submit ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); @@ -730,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { el: form, destroy() { destroyed = true; + window.clearTimeout(probeTimer); autocomplete.destroy(); form.remove(); }, diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts new file mode 100644 index 0000000..d856603 --- /dev/null +++ b/web/src/spawnFormEnter.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment happy-dom +// Enter submits the draft form from any field, at any point (#97). The two +// exceptions: a picker button that isn't yet the active choice takes the +// Enter to *choose* it instead (the next Enter submits), and the project +// filter must resolve to the highlighted project rather than sending +// whatever cwd was already set. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const MAESTRO: Project = { + path: "/home/user/tea/maestro", + name: "maestro", + is_worktree: false, + last_activity: "2026-08-20T10:00:00Z", +}; + +const WT_A: Project = { + path: "/home/user/tea/maestro/worktrees/bulgarians", + name: "bulgarians", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-22T10:00:00Z", +}; + +const OTHER: Project = { + path: "/home/user/tea/rev", + name: "rev", + is_worktree: false, + last_activity: "2026-08-19T10:00:00Z", +}; + +function stubFetch(projects: Project[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/projects") + ? projects + : url.includes("/api/names") + ? { names: ["amber-fox"] } + : url.includes("/api/branches") + ? [] + : url.includes("/api/dirs") + ? { path: "/opt", entries: ["nomatch-zzz"], truncated: false } + : { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +describe("spawnForm Enter-to-submit (#97)", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") }); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + async function mount(projects: Project[]): Promise { + stubFetch(projects); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + await drain(); + return handle.el; + } + + function submitSpy(): ReturnType { + return vi.fn(); + } + + function pressEnter(el: HTMLElement): { defaultPrevented: boolean } { + const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + el.dispatchEvent(evt); + return { defaultPrevented: evt.defaultPrevented }; + } + + const box = (form: HTMLFormElement, path: string) => + form.querySelector(`.project-box[data-cwd="${path}"]`)!; + + it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const toggle = form.querySelector("input[name=worktree_enabled]")!; + expect(toggle.checked).toBe(true); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(toggle); + expect(toggle.checked).toBe(false); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(toggle); + expect(toggle.checked).toBe(true); + expect(spy).not.toHaveBeenCalled(); + }); + + describe("submits immediately from ordinary fields", () => { + it("name field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=name]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree branch field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(form.querySelector("input[name=worktree_branch]")!); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("custom command field", async () => { + const form = await mount([MAESTRO]); + box(form, MAESTRO.path).click(); + await drain(); + form.querySelector('.cmd-tiles .tile[data-id="custom"]')!.click(); + const raw = form.querySelector(".cmd-raw")!; + raw.value = "bash -l"; + raw.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("raw cwd field once the autocomplete dropdown is closed", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.value = "/opt/xyz-nothing-here"; + raw.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(300); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(raw); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => { + const form = await mount([MAESTRO]); + form.querySelector(".project-other")!.click(); + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.focus(); + raw.value = "/opt/"; + raw.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(150); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const { defaultPrevented } = pressEnter(raw); + + expect(defaultPrevented).toBe(true); + expect(spy).not.toHaveBeenCalled(); + expect(raw.value).toBe("/opt/nomatch-zzz/"); + }); + }); + + describe("two-step picker: first Enter chooses, second submits", () => { + it("project box", async () => { + const form = await mount([MAESTRO]); + const b = box(form, MAESTRO.path); + b.focus(); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + const first = pressEnter(b); + expect(first.defaultPrevented).toBe(true); + expect(b.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(b); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("worktree choice", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + const choice = form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!; + expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(choice); + expect(choice.classList.contains("is-active")).toBe(true); + expect(spy).not.toHaveBeenCalled(); + + pressEnter(choice); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe("project filter", () => { + it("submits with the highlighted (first matching) project, not the filter text", async () => { + const form = await mount([MAESTRO, OTHER]); + const filter = form.querySelector(".project-filter")!; + filter.value = "rev"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(filter); + + expect(spy).toHaveBeenCalledTimes(1); + expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true); + }); + + it("does not clobber an already-chosen worktree when the filter still matches it", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click(); + await drain(); + + const filter = form.querySelector(".project-filter")!; + filter.value = "maestro"; + filter.dispatchEvent(new Event("input")); + const spy = submitSpy(); + form.addEventListener("submit", spy); + + pressEnter(filter); + + expect(spy).toHaveBeenCalledTimes(1); + expect( + form.querySelector(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains( + "is-active", + ), + ).toBe(true); + }); + }); +}); diff --git a/web/src/spawnFormPreset.test.ts b/web/src/spawnFormPreset.test.ts new file mode 100644 index 0000000..e7fe7bc --- /dev/null +++ b/web/src/spawnFormPreset.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; + +/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called — + * everything else (names, projects, gitroot) answers immediately with an + * empty/default body. Lets a test pick a preset (or type a custom command) + * mid-flight, then observe what the late `/api/info` resolution does to it. */ +function stubDeferredInfo(): { resolveInfo: () => void } { + let releaseInfo: () => void = () => {}; + const infoGate = new Promise((resolve) => { + releaseInfo = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/info")) { + await infoGate; + return new Response( + JSON.stringify({ + hostname: "h", + version: "0", + home_dir: "/home/user", + presets: [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "fish", label: "Fish", command: "fish" }, + { id: "custom", label: "Custom…", command: "" }, + ], + }), + { status: 200 }, + ); + } + if (url.includes("/api/names")) { + return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }), + ); + return { resolveInfo: releaseInfo }; +} + +describe("spawnForm preset selection vs. late /api/info (#76)", () => { + let handle: SpawnFormHandle | null = null; + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + }); + + async function drain(): Promise { + for (let i = 0; i < 40; i++) await Promise.resolve(); + } + + it("keeps an operator-picked tile after /api/info resolves late", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + // Fallback presets are up; the operator picks "zsh" before /api/info lands. + const zshTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "zsh", + )!; + zshTile.click(); + expect(zshTile.classList.contains("is-active")).toBe(true); + + // /api/info lands late with a preset list that doesn't even include "zsh". + resolveInfo(); + await drain(); + + // The operator's pick must not be discarded back to the new list's first + // entry — `onChange` only starts firing once the initial load settles + // (`ready`), which happens right here, so this is the first place the + // published command is observable. + expect(values?.command).toBe("zsh"); + }); + + it("keeps an operator-typed custom command after /api/info resolves late", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + const customTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "custom", + )!; + customTile.click(); + const cmdRaw = form.querySelector(".cmd-raw")!; + cmdRaw.value = "htop"; + cmdRaw.dispatchEvent(new Event("input")); + + resolveInfo(); + await drain(); + + expect(values?.command).toBe("htop"); + expect(cmdRaw.value).toBe("htop"); + }); + + it("still applies the real presets' first entry when the operator never touched the command", async () => { + const { resolveInfo } = stubDeferredInfo(); + let values: SpawnFormValues | undefined; + handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} }); + const form = handle.el; + document.body.appendChild(form); + await drain(); + + resolveInfo(); + await drain(); + + // Untouched: the refined preset list is still free to settle the command. + expect(values?.command).toBe("claude"); + const fishTile = [...form.querySelectorAll(".tile")].find( + (t) => t.dataset.id === "fish", + ); + expect(fishTile).toBeDefined(); + }); +}); diff --git a/web/src/spawnFormProbe.test.ts b/web/src/spawnFormProbe.test.ts new file mode 100644 index 0000000..b711340 --- /dev/null +++ b/web/src/spawnFormProbe.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; + +/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in + * spawnForm.ts (#74). */ +const DEBOUNCE_MS = 250; + +const PROJECTS = [ + { path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" }, +]; + +/** Stub the fetches the form makes. Gitroot responses for a given path can be + * held until `releaseGitRoot(path)` is called, so tests can land answers out + * of order. */ +function stubFetch(opts?: { holdGitRootFor?: string[] }): { + urls: string[]; + gitRootCalls: string[]; + releaseGitRoot: (path: string) => void; +} { + const urls: string[] = []; + const gitRootCalls: string[] = []; + const releases = new Map void>(); + const held = new Map>(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url.includes("/api/dirs")) { + return new Response(JSON.stringify({ entries: [] }), { status: 200 }); + } + if (url.includes("/api/gitroot")) { + const path = new URL(url, "http://localhost").searchParams.get("path") ?? ""; + gitRootCalls.push(path); + if (opts?.holdGitRootFor?.includes(path)) { + let release!: () => void; + held.set( + path, + new Promise((r) => { + release = r; + }), + ); + releases.set(path, release); + } + await held.get(path); + return new Response(JSON.stringify({ root: path }), { status: 200 }); + } + if (url.includes("/api/names")) { + return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 }); + } + if (url.includes("/api/projects")) { + return new Response(JSON.stringify(PROJECTS), { status: 200 }); + } + return new Response( + JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), + { status: 200 }, + ); + }), + ); + return { + urls, + gitRootCalls, + releaseGitRoot: (path: string) => releases.get(path)?.(), + }; +} + +describe("spawnForm gitroot probe debounce (#74)", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(opts?: Parameters[0]) { + const stub = stubFetch(opts); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + for (let i = 0; i < 40; i++) await Promise.resolve(); + return { form: handle.el, stub }; + } + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + function typeRaw(form: HTMLFormElement, value: string): void { + const raw = form.querySelector("input[name=cwd_raw]")!; + raw.value = value; + raw.dispatchEvent(new Event("input")); + } + + it("a keystroke burst fires exactly one probe after the quiet period", async () => { + const { form, stub } = await mount(); + + form.querySelector(".project-other")!.click(); + // Burst: every keystroke reschedules; nothing may hit /api/gitroot yet. + for (let i = 1; i <= "/opt/somewhere".length; i++) { + typeRaw(form, "/opt/somewhere".slice(0, i)); + await drain(); + expect(stub.gitRootCalls).toEqual([]); + } + + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + await drain(); + + expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]); + expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1); + }); + + it("a stale probe answer cannot overwrite a newer input", async () => { + const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] }); + + form.querySelector(".project-other")!.click(); + + // Probe for the old target, held in flight. + typeRaw(form, "/repo/old"); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + + // Newer target supersedes it and answers immediately. + typeRaw(form, "/repo/new"); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + await drain(); + + expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]); + expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); + expect( + form.querySelector("input[name=worktree_enabled]")!.checked, + ).toBe(true); + + // The late answer to the old target lands last but must lose. + stub.releaseGitRoot("/repo/old"); + await drain(); + + expect(form.querySelector(".wt-repo")!.textContent).toContain("/repo/new"); + expect( + form.querySelector("input[name=worktree_enabled]")!.checked, + ).toBe(true); + }); +}); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index 76fd12c..6a39479 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -18,6 +18,7 @@ const BRANCHES = [ * its branch list is still in flight. */ function stubFetch(opts?: { gateBranches?: (release: () => void) => void; + projects?: unknown[]; }): string[] { const urls: string[] = []; const gated = new Promise((resolve) => { @@ -37,7 +38,7 @@ function stubFetch(opts?: { return new Response(JSON.stringify(BRANCHES), { status: 200 }); } if (url.includes("/api/projects")) { - return new Response(JSON.stringify(PROJECTS), { status: 200 }); + return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 }); } return new Response( JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }), @@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { }; function pickProject(form: HTMLFormElement): void { - form.querySelector(".project-row.project-other")!.click(); + form.querySelector(".project-other")!.click(); form - .querySelectorAll(".project-row") - .forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click()); + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click()); } it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => { @@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { ); }); + it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => { + urls = stubFetch({ + projects: [ + { + path: "/home/user/wt-91", + name: "wt-91", + is_worktree: true, + base_repo: "/home/user/maestro", + last_activity: "2026-08-20T10:00:00Z", + }, + ], + }); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + for (let i = 0; i < 40; i++) await Promise.resolve(); + + handle.el + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); + await drain(); + + expect(handle.el.querySelector("input[name=worktree_enabled]")!.checked).toBe( + false, + ); + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + expect(handle.el.querySelector(".wt-body")!.hidden).toBe(true); + }); + it("still probes the gitroot for a free-text directory", async () => { const form = await mount(); - // Escape hatch: switch to a free-text dir; only now does /api/gitroot fire. - form.querySelector(".project-row.project-other")!.click(); + // Escape hatch: switch to a free-text dir; only now does /api/gitroot + // fire — debounced, so the quiet period must elapse first (#74). + form.querySelector(".project-other")!.click(); const raw = form.querySelector("input[name=cwd_raw]")!; raw.value = "/opt/somewhere"; raw.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(250); await drain(); expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true); @@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { expect(modes.at(-1)).toBe("existing"); }); + it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => { + const form = await mount(); + pickProject(form); + await drain(); + + const newLabel = form.querySelector(".wt-new-wrap")!.textContent!.trim(); + const existLabel = form.querySelector(".wt-exist-wrap")!.textContent!.trim(); + + expect(newLabel).not.toBe("Branch"); + expect(existLabel).not.toBe("Branch"); + expect(newLabel).not.toBe(existLabel); + expect(newLabel).toMatch(/name/i); + expect(existLabel).toMatch(/pick|choose|select/i); + }); + + it("collapses the advanced (worktree) section by default (#93)", async () => { + const form = await mount(); + const toggle = form.querySelector(".wt-toggle")!; + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(form.querySelector(".wt-collapsible")!.hidden).toBe(true); + }); + + it("states in the collapsed header that a worktree will be created by default (#93)", async () => { + const form = await mount(); + expect(form.querySelector(".wt-summary")!.textContent).toMatch(/creates? a worktree/i); + }); + + it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => { + urls = stubFetch({ + projects: [ + { + path: "/home/user/wt-91", + name: "wt-91", + is_worktree: true, + base_repo: "/home/user/maestro", + last_activity: "2026-08-20T10:00:00Z", + }, + ], + }); + handle = createSpawnForm({ onSuccess() {} }); + document.body.appendChild(handle.el); + for (let i = 0; i < 40; i++) await Promise.resolve(); + + handle.el + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); + await drain(); + + expect(handle.el.querySelector(".wt-summary")!.textContent).toMatch(/no worktree/i); + // Still collapsed — picking a project must not force it open. + expect(handle.el.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( + "false", + ); + }); + + it("expanding the advanced section does not reorder the fields above it (#93)", async () => { + const form = await mount(); + const before = [...form.querySelectorAll(".field")]; + + form.querySelector(".wt-toggle")!.click(); + + expect(form.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( + "true", + ); + expect(form.querySelector(".wt-collapsible")!.hidden).toBe(false); + expect([...form.querySelectorAll(".field")]).toStrictEqual(before); + }); + it("does not repopulate a destroyed form when the branch list lands late", async () => { const gate: { release?: () => void } = {}; urls = stubFetch({ gateBranches: (r) => (gate.release = r) }); diff --git a/web/src/styles.css b/web/src/styles.css index 065d7e1..cd89d81 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -727,10 +727,8 @@ textarea { } /* Earlier path segments collapse to their initial; the trailing segment (the - one that actually names the dir) stays full. Dim the collapsed prefix. Shared - by the sidebar tree and the spawn dialog's recent-directory picker. */ -.folder-name .folder-stem, -.recent-dir .folder-stem { + one that actually names the dir) stays full. Dim the collapsed prefix. */ +.folder-name .folder-stem { color: var(--faint); } @@ -1215,6 +1213,9 @@ textarea { /* ---------- forms ---------- */ .spawn-form { + width: 100%; + max-width: 60ch; + margin: 0 auto; padding: var(--sp-3); overflow: auto; display: flex; @@ -1283,18 +1284,33 @@ textarea { .worktree { border: 1px solid var(--border); border-radius: var(--r-2); - padding: var(--sp-3); + padding: 0 var(--sp-3); margin: 0; display: flex; flex-direction: column; - gap: var(--sp-3); background: color-mix(in oklab, var(--surface-2) 50%, transparent); } -.worktree legend { - padding: 0 6px; - color: var(--ink); - font-size: var(--fs-13); +.worktree .wt-toggle { + margin: 0 calc(var(--sp-3) * -1); + padding-left: var(--sp-3); + width: calc(100% + var(--sp-3) * 2); +} + +.wt-summary { + color: var(--muted); + font-size: var(--fs-12); +} + +.wt-collapsible { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding-bottom: var(--sp-3); +} + +.wt-collapsible[hidden] { + display: none; } .form-error { @@ -1378,8 +1394,9 @@ textarea { color: var(--muted); } -/* Project picker rows (#59). Worktree rows are indented under their base - repository; rows stale beyond 7 days are dimmed but still selectable. */ +/* Working-directory picker (#95). Two steps: a grid of project boxes, then the + picked project's worktree choices. Stale beyond 7 days dims but stays + selectable. */ .picker { display: flex; flex-direction: column; @@ -1394,73 +1411,155 @@ textarea { width: 100%; } -.project-row { +/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so + step two and the raw-cwd hatch stayed visible while hidden. */ +.wt-step[hidden], +.raw-wrap[hidden] { + display: none; +} + +.picker.projects { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + align-content: start; + gap: var(--sp-2); + max-height: 300px; + padding: 1px; +} + +.project-box { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: var(--sp-2) var(--sp-3); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-2); + color: var(--ink); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + border-color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.project-box:hover { + border-color: var(--border-strong); +} + +.project-box.is-active { + border-color: var(--accent); + background: var(--accent-tint); +} + +.project-box.is-stale { + opacity: 0.55; +} + +.project-name { + font-size: var(--fs-13); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The whole path, never abbreviated — it is what tells two projects sharing a + basename apart. Wraps rather than truncating so none of it is hidden. */ +.project-path { + font-family: var(--font-mono); + font-size: var(--fs-12); + line-height: 1.35; + color: var(--muted); + overflow-wrap: anywhere; +} + +.project-meta { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-meta:empty { + display: none; +} + +.project-other { + align-self: flex-start; + padding: 4px 0; + min-height: 30px; + background: none; + border: 0; + color: var(--muted); + font-family: inherit; + font-size: var(--fs-13); + font-style: italic; + text-align: left; + cursor: pointer; +} + +.project-other:hover { + color: var(--ink); +} + +/* Step two: "+ new" (create one) or an existing worktree (run in it). */ +.picker.wt-choices { + max-height: 200px; +} + +.wt-choice { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); + min-height: 32px; padding: 6px var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); color: var(--ink); font-family: inherit; - cursor: pointer; text-align: left; + cursor: pointer; transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); } -.project-row:hover { +.wt-choice:hover { border-color: var(--border-strong); } -.project-row.is-active { +.wt-choice.is-active { border-color: var(--accent); background: var(--accent-tint); } -.project-row.is-worktree { - margin-left: var(--sp-4); -} - -.project-row.is-stale { - opacity: 0.55; -} - -.project-info { - display: flex; - align-items: baseline; - gap: var(--sp-2); - min-width: 0; -} - -.project-name { +.wt-choice-name { + flex: 0 0 auto; font-size: var(--fs-13); font-weight: 600; white-space: nowrap; } -.recent-dir { +.wt-choice-new .wt-choice-name { font-family: var(--font-mono); - font-size: var(--fs-12); - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: var(--accent); } -.recent-cmd { - flex: 0 0 auto; +.wt-choice-sub { + min-width: 0; font-family: var(--font-mono); font-size: var(--fs-12); + line-height: 1.35; color: var(--muted); -} - -.project-other .recent-dir { - font-family: inherit; - font-style: italic; + text-align: right; + overflow-wrap: anywhere; } .picker-empty { diff --git a/web/src/worktreeStep.test.ts b/web/src/worktreeStep.test.ts new file mode 100644 index 0000000..c89a011 --- /dev/null +++ b/web/src/worktreeStep.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment happy-dom +// Second step of the working-directory picker (#95). See the interaction rules +// block in spawnForm.ts for what each choice means. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const MAESTRO: Project = { + path: "/home/user/tea/maestro", + name: "maestro", + is_worktree: false, + last_activity: "2026-08-20T10:00:00Z", + last_command: "claude", +}; + +const WT_A: Project = { + path: "/home/user/tea/maestro/worktrees/bulgarians", + name: "bulgarians", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-22T10:00:00Z", +}; + +const WT_B: Project = { + path: "/home/user/tea/maestro/worktrees/spawn-v2", + name: "spawn-v2", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-21T10:00:00Z", +}; + +const OTHER: Project = { + path: "/home/user/tea/rev", + name: "rev", + is_worktree: false, + last_activity: "2026-08-19T10:00:00Z", +}; + +function stubFetch(projects: Project[]): string[] { + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + const body = url.includes("/api/projects") + ? projects + : url.includes("/api/names") + ? { names: ["amber-fox"] } + : url.includes("/api/branches") + ? [] + : { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); + return urls; +} + +describe("spawnForm worktree step (#95)", () => { + let handle: SpawnFormHandle | null = null; + let urls: string[] = []; + let changes: SpawnFormValues[] = []; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") }); + changes = []; + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + async function mount(projects: Project[]): Promise { + urls = stubFetch(projects); + handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) }); + document.body.appendChild(handle.el); + await drain(); + return handle.el; + } + + const step = (form: HTMLFormElement) => form.querySelector(".wt-step")!; + const choices = (form: HTMLFormElement) => [ + ...form.querySelectorAll(".wt-choice"), + ]; + const activeChoice = (form: HTMLFormElement) => + form.querySelector(".wt-choice.is-active"); + const box = (form: HTMLFormElement, path: string) => + form.querySelector(`.project-box[data-cwd="${path}"]`)!; + const toggle = (form: HTMLFormElement) => + form.querySelector("input[name=worktree_enabled]")!; + + it("hides the worktree step until a project is picked", async () => { + const form = await mount([MAESTRO, WT_A]); + expect(step(form).hidden).toBe(true); + expect(choices(form)).toEqual([]); + + box(form, MAESTRO.path).click(); + await drain(); + + expect(step(form).hidden).toBe(false); + }); + + it("lists '+ new' first and selected by default, then the project's worktrees", async () => { + const form = await mount([MAESTRO, WT_A, WT_B]); + box(form, MAESTRO.path).click(); + await drain(); + + const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent); + expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + }); + + it("taking the '+ new' default runs in the repo with the create toggle on", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(MAESTRO.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + expect(toggle(form).checked).toBe(true); + expect(form.querySelector(".wt-repo")!.textContent).toContain(MAESTRO.path); + // The scan already knows the repo — no probe. + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + }); + + it("picking an existing worktree runs in it with the create toggle off (#91)", async () => { + const form = await mount([MAESTRO, WT_A, WT_B]); + box(form, MAESTRO.path).click(); + await drain(); + + const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!; + existing.click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(false); + expect(toggle(form).checked).toBe(false); + expect(form.querySelector(".wt-body")!.hidden).toBe(true); + expect(existing.classList.contains("is-active")).toBe(true); + // Still no probe: the scan reported this worktree's repo membership. + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + // The project box stays marked — it is what the worktree list belongs to. + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + }); + + it("switching back to '+ new' re-enables the create toggle", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + choices(form) + .find((c) => c.dataset.cwd === WT_A.path)! + .click(); + await drain(); + + choices(form) + .find((c) => c.dataset.kind === "new")! + .click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(MAESTRO.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + expect(toggle(form).checked).toBe(true); + }); + + it("repopulates and resets to '+ new' when another project is picked", async () => { + const form = await mount([MAESTRO, WT_A, OTHER]); + box(form, MAESTRO.path).click(); + await drain(); + choices(form) + .find((c) => c.dataset.cwd === WT_A.path)! + .click(); + await drain(); + + box(form, OTHER.path).click(); + await drain(); + + expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + expect(changes.at(-1)!.cwd).toBe(OTHER.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + }); + + it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => { + // No main checkout in the scan, so the orphan worktree is its own box. + const form = await mount([WT_A]); + box(form, WT_A.path).click(); + await drain(); + + expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(false); + expect(toggle(form).checked).toBe(false); + expect(form.querySelector(".wt-body")!.hidden).toBe(true); + }); + + it("hides the worktree step again when the operator takes the raw-input hatch", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + expect(step(form).hidden).toBe(false); + + form.querySelector(".project-other")!.click(); + await drain(); + + expect(step(form).hidden).toBe(true); + expect(choices(form)).toEqual([]); + expect(form.querySelector(".project-box.is-active")).toBeNull(); + }); + + it("restores the picked worktree from a draft seed", async () => { + urls = stubFetch([MAESTRO, WT_A, WT_B]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + expect(step(form).hidden).toBe(false); + expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path); + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + expect(toggle(form).checked).toBe(false); + }); +});