Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cb03961c | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 |
@@ -97,3 +97,24 @@ jobs:
|
|||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: pnpm -C web 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
|
||||||
|
|||||||
@@ -175,8 +175,7 @@ impl App {
|
|||||||
// Render on next loop iteration.
|
// Render on next loop iteration.
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
// 30 Hz heartbeat.
|
||||||
self.perform(effects).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,7 +439,7 @@ impl App {
|
|||||||
if draft.take_name_refill() {
|
if draft.take_name_refill() {
|
||||||
effects.push(Effect::RefillNames(tab_idx));
|
effects.push(Effect::RefillNames(tab_idx));
|
||||||
}
|
}
|
||||||
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||||
effects.push(Effect::ProbeGitRoot {
|
effects.push(Effect::ProbeGitRoot {
|
||||||
tab: tab_idx,
|
tab: tab_idx,
|
||||||
seq,
|
seq,
|
||||||
@@ -453,26 +452,6 @@ impl App {
|
|||||||
effects
|
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<Effect> {
|
|
||||||
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
|
/// Populate a freshly opened draft: presets from `/api/info` (falling back
|
||||||
/// to the built-ins already in place), projects from `/api/projects`, and
|
/// 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 first name batch. Applies only if that tab still shows the draft —
|
||||||
@@ -1368,11 +1347,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
/// Editing the raw path with the worktree toggle on schedules a
|
||||||
/// keystroke burst settles (#74): nothing fires inside the debounce
|
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||||
/// window; the heartbeat picks the due probe up afterwards.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||||
@@ -1381,24 +1359,13 @@ mod tests {
|
|||||||
draft.cwd_raw_visible = true;
|
draft.cwd_raw_visible = true;
|
||||||
draft.focused_field = DraftField::CwdRaw;
|
draft.focused_field = DraftField::CwdRaw;
|
||||||
|
|
||||||
// A burst of keystrokes fires nothing.
|
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||||
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!(
|
assert!(
|
||||||
app.poll_due_probes_at(due).is_empty(),
|
matches!(
|
||||||
"the probe is consumed once"
|
effects.as_slice(),
|
||||||
|
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||||
|
),
|
||||||
|
"{effects:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-101
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
@@ -36,12 +35,6 @@ use crate::projects;
|
|||||||
/// dry.
|
/// dry.
|
||||||
pub const NAME_BATCH_SIZE: usize = 20;
|
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: `,
|
/// Width of the fixed label gutter every form line starts with (`cmd: `,
|
||||||
/// `dir: `, …).
|
/// `dir: `, …).
|
||||||
const GUTTER: usize = 5;
|
const GUTTER: usize = 5;
|
||||||
@@ -156,9 +149,6 @@ pub struct DraftPane {
|
|||||||
/// Bumped on every target change; answers carrying an older seq are stale.
|
/// Bumped on every target change; answers carrying an older seq are stale.
|
||||||
probe_seq: u32,
|
probe_seq: u32,
|
||||||
needs_probe: bool,
|
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<Instant>,
|
|
||||||
/// Set when a probe found no repository (drives the rendered hint).
|
/// Set when a probe found no repository (drives the rendered hint).
|
||||||
repo_missing: bool,
|
repo_missing: bool,
|
||||||
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
|
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
|
||||||
@@ -208,7 +198,6 @@ impl Default for DraftPane {
|
|||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
needs_probe: false,
|
needs_probe: false,
|
||||||
probe_due_at: None,
|
|
||||||
repo_missing: false,
|
repo_missing: false,
|
||||||
branch: crate::text_input::TextInput::default(),
|
branch: crate::text_input::TextInput::default(),
|
||||||
branch_edited: false,
|
branch_edited: false,
|
||||||
@@ -331,23 +320,18 @@ impl DraftPane {
|
|||||||
self.repo_missing = false;
|
self.repo_missing = false;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
self.probe_due_at = None;
|
|
||||||
self.probed_cwd = self.cwd();
|
self.probed_cwd = self.cwd();
|
||||||
self.branches_for = None;
|
self.branches_for = None;
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
/// A target with an unknown repo (raw path edit or the Other… hatch).
|
||||||
/// Cancels any in-flight probe and schedules a fresh one after the
|
/// Cancels any in-flight probe and requests a fresh one.
|
||||||
/// debounce window (#74).
|
|
||||||
fn note_unknown_repo(&mut self) {
|
fn note_unknown_repo(&mut self) {
|
||||||
self.base_repo = None;
|
self.base_repo = None;
|
||||||
self.repo_missing = false;
|
self.repo_missing = false;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
|
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.branches_for = None;
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
@@ -358,19 +342,14 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume the gitroot-probe request once its debounce window has passed
|
/// Consume the gitroot-probe request: the caller fetches
|
||||||
/// (`now` at or after [`Self::probe_due_at`]): the caller fetches
|
|
||||||
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
||||||
/// with the same seq. Returns `None` while typing is still in flight.
|
/// with the same seq.
|
||||||
pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
|
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> {
|
||||||
if !self.needs_probe {
|
if !self.needs_probe {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if now < self.probe_due_at? {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
self.probe_due_at = None;
|
|
||||||
let cwd = self.cwd()?;
|
let cwd = self.cwd()?;
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
Some((self.probe_seq, cwd))
|
Some((self.probe_seq, cwd))
|
||||||
@@ -384,7 +363,6 @@ impl DraftPane {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.needs_probe = false;
|
self.needs_probe = false;
|
||||||
self.probe_due_at = None;
|
|
||||||
self.probed_cwd = self.cwd();
|
self.probed_cwd = self.cwd();
|
||||||
match root {
|
match root {
|
||||||
Some(repo) => {
|
Some(repo) => {
|
||||||
@@ -456,9 +434,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
if self.base_repo.is_none() {
|
if self.base_repo.is_none() {
|
||||||
self.probe_seq += 1;
|
self.probe_seq += 1;
|
||||||
// A discrete toggle, not a keystroke burst: due immediately.
|
|
||||||
self.needs_probe = cwd.is_some();
|
self.needs_probe = cwd.is_some();
|
||||||
self.probe_due_at = self.needs_probe.then(Instant::now);
|
|
||||||
}
|
}
|
||||||
self.schedule_branch_load();
|
self.schedule_branch_load();
|
||||||
}
|
}
|
||||||
@@ -1301,11 +1277,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A moment comfortably past the probe debounce window (#74).
|
|
||||||
fn probe_due() -> Instant {
|
|
||||||
Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS) * 10
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
|
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
|
||||||
let draft = DraftPane::default();
|
let draft = DraftPane::default();
|
||||||
@@ -1493,7 +1464,7 @@ mod tests {
|
|||||||
typed(&mut draft, "/opt/tooling");
|
typed(&mut draft, "/opt/tooling");
|
||||||
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
|
||||||
// The raw target probed clean, so the worktree spec carries its repo.
|
// The raw target probed clean, so the worktree spec carries its repo.
|
||||||
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
||||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
|
||||||
draft.seed_names(vec!["amber-falcon".into()]);
|
draft.seed_names(vec!["amber-falcon".into()]);
|
||||||
let req = draft.build_request().unwrap();
|
let req = draft.build_request().unwrap();
|
||||||
@@ -1714,7 +1685,7 @@ mod tests {
|
|||||||
Some(Path::new("/repos/maestro")),
|
Some(Path::new("/repos/maestro")),
|
||||||
"a picked project resolves its base repo without a probe"
|
"a picked project resolves its base repo without a probe"
|
||||||
);
|
);
|
||||||
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
assert!(draft.take_gitroot_probe().is_none());
|
||||||
draft.seed_names(vec!["crimson-otter".into()]);
|
draft.seed_names(vec!["crimson-otter".into()]);
|
||||||
|
|
||||||
let wt = draft.build_request().unwrap().worktree.unwrap();
|
let wt = draft.build_request().unwrap().worktree.unwrap();
|
||||||
@@ -1754,7 +1725,7 @@ mod tests {
|
|||||||
draft.on_input(&InputKey::Enter);
|
draft.on_input(&InputKey::Enter);
|
||||||
assert!(draft.worktree_enabled);
|
assert!(draft.worktree_enabled);
|
||||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
||||||
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
assert!(draft.take_gitroot_probe().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1766,18 +1737,13 @@ mod tests {
|
|||||||
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
||||||
typed(&mut draft, "/opt/tool");
|
typed(&mut draft, "/opt/tool");
|
||||||
|
|
||||||
let (seq, path) = draft
|
let (seq, path) = draft.take_gitroot_probe().expect("probe requested");
|
||||||
.take_gitroot_probe(probe_due())
|
|
||||||
.expect("probe requested");
|
|
||||||
assert_eq!(path, PathBuf::from("/opt/tool"));
|
assert_eq!(path, PathBuf::from("/opt/tool"));
|
||||||
assert!(
|
assert!(draft.take_gitroot_probe().is_none(), "one probe at a time");
|
||||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
|
||||||
"one probe at a time"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Further typing supersedes the in-flight probe…
|
// Further typing supersedes the in-flight probe…
|
||||||
typed(&mut draft, "-x");
|
typed(&mut draft, "-x");
|
||||||
let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
let (seq2, _) = draft.take_gitroot_probe().unwrap();
|
||||||
assert_ne!(seq2, seq);
|
assert_ne!(seq2, seq);
|
||||||
// …so a late answer to the old one must be ignored.
|
// …so a late answer to the old one must be ignored.
|
||||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
|
||||||
@@ -1798,7 +1764,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
draft.on_input(&InputKey::Enter);
|
draft.on_input(&InputKey::Enter);
|
||||||
typed(&mut draft, "/not/a/repo");
|
typed(&mut draft, "/not/a/repo");
|
||||||
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
let (seq, _) = draft.take_gitroot_probe().unwrap();
|
||||||
|
|
||||||
draft.resolve_gitroot(seq, None);
|
draft.resolve_gitroot(seq, None);
|
||||||
assert!(!draft.worktree_enabled);
|
assert!(!draft.worktree_enabled);
|
||||||
@@ -1942,61 +1908,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
focused_field: DraftField::DirPicker,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.on_input(&InputKey::Enter); // no projects → Other directory…
|
|
||||||
|
|
||||||
// Burst: every keystroke reschedules; nothing may fire inside the
|
|
||||||
// debounce window (#74).
|
|
||||||
for c in "/opt/tooling".chars() {
|
|
||||||
draft.on_input(&InputKey::char(c));
|
|
||||||
assert!(
|
|
||||||
draft.take_gitroot_probe(Instant::now()).is_none(),
|
|
||||||
"no probe while typing is still in flight"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the quiet period passes, exactly one probe fires.
|
|
||||||
let (seq, path) = draft
|
|
||||||
.take_gitroot_probe(probe_due())
|
|
||||||
.expect("probe due after the quiet period");
|
|
||||||
assert_eq!(path, PathBuf::from("/opt/tooling"));
|
|
||||||
assert!(
|
|
||||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
|
||||||
"one probe total"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The answer still lands through the unchanged seq guard.
|
|
||||||
draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling")));
|
|
||||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() {
|
|
||||||
let mut draft = DraftPane {
|
|
||||||
focused_field: DraftField::DirPicker,
|
|
||||||
..DraftPane::default()
|
|
||||||
};
|
|
||||||
draft.on_input(&InputKey::Enter); // Other directory…
|
|
||||||
|
|
||||||
typed(&mut draft, "/repo/old");
|
|
||||||
let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
||||||
|
|
||||||
// Retyping supersedes the in-flight probe and gets a newer seq…
|
|
||||||
typed(&mut draft, "/repo/new");
|
|
||||||
let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
|
||||||
assert_ne!(new_seq, old_seq);
|
|
||||||
|
|
||||||
// …so the late answer to the old target must be ignored.
|
|
||||||
draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new")));
|
|
||||||
draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old")));
|
|
||||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
@@ -2022,7 +1933,7 @@ mod tests {
|
|||||||
draft.on_input(&InputKey::Enter);
|
draft.on_input(&InputKey::Enter);
|
||||||
assert!(draft.worktree_enabled);
|
assert!(draft.worktree_enabled);
|
||||||
assert!(
|
assert!(
|
||||||
draft.take_gitroot_probe(probe_due()).is_none(),
|
draft.take_gitroot_probe().is_none(),
|
||||||
"re-enabling reuses the known repo for an unchanged target"
|
"re-enabling reuses the known repo for an unchanged target"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-23
@@ -94,11 +94,6 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
|
|||||||
{ id: "custom", label: "Custom…", command: "" },
|
{ id: "custom", label: "Custom…", command: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
|
|
||||||
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
|
||||||
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
|
|
||||||
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
|
||||||
|
|
||||||
export interface SpawnFormHandle {
|
export interface SpawnFormHandle {
|
||||||
el: HTMLFormElement;
|
el: HTMLFormElement;
|
||||||
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
||||||
@@ -342,6 +337,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
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 {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
@@ -350,13 +354,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
tile.className = "tile";
|
tile.className = "tile";
|
||||||
tile.dataset.id = p.id;
|
tile.dataset.id = p.id;
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||||
tile.addEventListener("click", () => selectPreset(p.id));
|
tile.addEventListener("click", () => {
|
||||||
|
commandTouched = true;
|
||||||
|
selectPreset(p.id);
|
||||||
|
});
|
||||||
cmdTiles.appendChild(tile);
|
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", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
|
commandTouched = true;
|
||||||
command = cmdRaw.value.trim();
|
command = cmdRaw.value.trim();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
@@ -372,7 +386,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (info.presets?.length) {
|
if (info.presets?.length) {
|
||||||
presets = info.presets;
|
presets = info.presets;
|
||||||
renderCommandTiles();
|
renderCommandTiles();
|
||||||
applyInitialCommand();
|
if (commandTouched) {
|
||||||
|
reapplyActiveTile();
|
||||||
|
} else {
|
||||||
|
applyInitialCommand();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -418,7 +436,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd = cwdRaw.value.trim();
|
cwd = cwdRaw.value.trim();
|
||||||
branchCache = null;
|
branchCache = null;
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) scheduleGitRootProbe();
|
if (wtEnabled.checked) probeGitRoot();
|
||||||
});
|
});
|
||||||
|
|
||||||
function markActiveProject(activeCwd: string | null): void {
|
function markActiveProject(activeCwd: string | null): void {
|
||||||
@@ -584,20 +602,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
applyProjectRepo(project.base_repo ?? project.path);
|
applyProjectRepo(project.base_repo ?? project.path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleGitRootProbe();
|
void probeGitRoot();
|
||||||
}
|
|
||||||
|
|
||||||
// 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<void> {
|
async function probeGitRoot(): Promise<void> {
|
||||||
@@ -758,7 +763,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
el: form,
|
el: form,
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
window.clearTimeout(probeTimer);
|
|
||||||
autocomplete.destroy();
|
autocomplete.destroy();
|
||||||
form.remove();
|
form.remove();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<void>((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<void> {
|
||||||
|
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<HTMLButtonElement>(".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<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "custom",
|
||||||
|
)!;
|
||||||
|
customTile.click();
|
||||||
|
const cmdRaw = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "fish",
|
||||||
|
);
|
||||||
|
expect(fishTile).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// @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<string, () => void>();
|
|
||||||
const held = new Map<string, Promise<void>>();
|
|
||||||
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<void>((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<typeof stubFetch>[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<HTMLInputElement>("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<HTMLElement>(".project-row.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<HTMLElement>(".project-row.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<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
|
||||||
expect(
|
|
||||||
form.querySelector<HTMLInputElement>("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<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
|
||||||
expect(
|
|
||||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -132,13 +132,11 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
it("still probes the gitroot for a free-text directory", async () => {
|
it("still probes the gitroot for a free-text directory", async () => {
|
||||||
const form = await mount();
|
const form = await mount();
|
||||||
|
|
||||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
||||||
// fire — debounced, so the quiet period must elapse first (#74).
|
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||||
raw.value = "/opt/somewhere";
|
raw.value = "/opt/somewhere";
|
||||||
raw.dispatchEvent(new Event("input"));
|
raw.dispatchEvent(new Event("input"));
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
|
||||||
await drain();
|
await drain();
|
||||||
|
|
||||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user