Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
596e7e2f49 | ||
|
|
16609eff9e | ||
|
|
2a2da7f779 | ||
|
|
5eab434647 | ||
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
b80d604408 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c |
@@ -261,17 +261,13 @@ impl ProjectScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
/// 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<Vec<DiscoveredRepo>> {
|
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||||
self.repos_inner(false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
|
||||||
let mut cache = self.cache.lock().await;
|
let mut cache = self.cache.lock().await;
|
||||||
if !force {
|
if let Some((at, repos)) = cache.as_ref() {
|
||||||
if let Some((at, repos)) = cache.as_ref() {
|
if at.elapsed() < self.ttl {
|
||||||
if at.elapsed() < self.ttl {
|
return repos.clone();
|
||||||
return repos.clone();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let roots = self.roots.clone();
|
let roots = self.roots.clone();
|
||||||
@@ -287,11 +283,11 @@ impl ProjectScanner {
|
|||||||
repos
|
repos
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||||
/// leaving requests to pay for the rescan instead (#54 review).
|
/// roots every [`REFRESH_INTERVAL`].
|
||||||
async fn refresh(&self) {
|
async fn refresh(&self) {
|
||||||
self.repos_inner(true).await;
|
self.repos().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||||
@@ -544,6 +540,32 @@ mod tests {
|
|||||||
assert_eq!(fresh[0].name, "late");
|
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]
|
#[test]
|
||||||
fn disabled_scanner_always_serves_empty() {
|
fn disabled_scanner_always_serves_empty() {
|
||||||
let scanner = ProjectScanner::disabled();
|
let scanner = ProjectScanner::disabled();
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ 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)) => {
|
||||||
// 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() {
|
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() {
|
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
||||||
effects.push(Effect::ProbeGitRoot {
|
effects.push(Effect::ProbeGitRoot {
|
||||||
tab: tab_idx,
|
tab: tab_idx,
|
||||||
seq,
|
seq,
|
||||||
@@ -452,6 +453,26 @@ 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 —
|
||||||
@@ -1347,10 +1368,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Editing the raw path with the worktree toggle on schedules a
|
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
||||||
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
/// keystroke burst settles (#74): nothing fires inside the debounce
|
||||||
|
/// window; the heartbeat picks the due probe up afterwards.
|
||||||
#[test]
|
#[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();
|
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 {
|
||||||
@@ -1359,13 +1381,24 @@ mod tests {
|
|||||||
draft.cwd_raw_visible = true;
|
draft.cwd_raw_visible = true;
|
||||||
draft.focused_field = DraftField::CwdRaw;
|
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!(
|
assert!(
|
||||||
matches!(
|
app.poll_due_probes_at(due).is_empty(),
|
||||||
effects.as_slice(),
|
"the probe is consumed once"
|
||||||
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
|
||||||
),
|
|
||||||
"{effects:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,26 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
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]
|
#[test]
|
||||||
fn custom_chord_timeout_is_applied() {
|
fn custom_chord_timeout_is_applied() {
|
||||||
let mut section = TuiSection::default();
|
let mut section = TuiSection::default();
|
||||||
|
|||||||
+139
-11
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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};
|
||||||
@@ -35,6 +36,12 @@ 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;
|
||||||
@@ -149,6 +156,9 @@ 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.
|
||||||
@@ -198,6 +208,7 @@ 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,
|
||||||
@@ -320,18 +331,23 @@ 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 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) {
|
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();
|
||||||
}
|
}
|
||||||
@@ -342,14 +358,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`]
|
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
|
||||||
/// with the same seq.
|
/// with the same seq. Returns `None` while typing is still in flight.
|
||||||
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> {
|
pub fn take_gitroot_probe(&mut self, now: Instant) -> 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))
|
||||||
@@ -363,6 +384,7 @@ 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) => {
|
||||||
@@ -434,7 +456,9 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -654,6 +678,11 @@ impl DraftPane {
|
|||||||
let project = visible[sel].project.clone();
|
let project = visible[sel].project.clone();
|
||||||
self.picked_cwd = Some(project.path.clone());
|
self.picked_cwd = Some(project.path.clone());
|
||||||
self.cwd_raw_visible = false;
|
self.cwd_raw_visible = false;
|
||||||
|
// A directory that *is* a linked worktree defaults to spawning in
|
||||||
|
// place (#91): creating a second worktree of its branch fails.
|
||||||
|
// Turning it back on by hand still tries (git's own message),
|
||||||
|
// which is deliberate (#91 non-goal).
|
||||||
|
self.worktree_enabled = !project.is_worktree;
|
||||||
// The scan already knows this directory's repo (#61): no probe
|
// The scan already knows this directory's repo (#61): no probe
|
||||||
// round trip needed, exactly like the web form.
|
// round trip needed, exactly like the web form.
|
||||||
let repo = project
|
let repo = project
|
||||||
@@ -1272,6 +1301,11 @@ 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();
|
||||||
@@ -1459,7 +1493,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().unwrap();
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).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();
|
||||||
@@ -1680,7 +1714,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().is_none());
|
assert!(draft.take_gitroot_probe(probe_due()).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();
|
||||||
@@ -1689,6 +1723,40 @@ mod tests {
|
|||||||
assert!(wt.create_branch);
|
assert!(wt.create_branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picking_a_worktree_project_defaults_the_toggle_off() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
|
||||||
|
wt.is_worktree = true;
|
||||||
|
draft.set_projects(
|
||||||
|
vec![
|
||||||
|
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||||
|
wt,
|
||||||
|
],
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
draft.focused_field = DraftField::DirPicker;
|
||||||
|
typed(&mut draft, "wt-91");
|
||||||
|
draft.on_input(&InputKey::Enter);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!draft.worktree_enabled,
|
||||||
|
"the directory is already a worktree; spawning in place"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
draft.build_request().unwrap().worktree.is_none(),
|
||||||
|
"no worktree record — the session just runs there (#91)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deliberately re-enabling still works, and the scan-known base repo
|
||||||
|
// survives so the toggle-on path needs no new probe.
|
||||||
|
draft.focused_field = DraftField::WorktreeToggle;
|
||||||
|
draft.on_input(&InputKey::Enter);
|
||||||
|
assert!(draft.worktree_enabled);
|
||||||
|
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
||||||
|
assert!(draft.take_gitroot_probe(probe_due()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
|
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
@@ -1698,13 +1766,18 @@ 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.take_gitroot_probe().expect("probe requested");
|
let (seq, path) = draft
|
||||||
|
.take_gitroot_probe(probe_due())
|
||||||
|
.expect("probe requested");
|
||||||
assert_eq!(path, PathBuf::from("/opt/tool"));
|
assert_eq!(path, PathBuf::from("/opt/tool"));
|
||||||
assert!(draft.take_gitroot_probe().is_none(), "one probe at a time");
|
assert!(
|
||||||
|
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().unwrap();
|
let (seq2, _) = draft.take_gitroot_probe(probe_due()).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")));
|
||||||
@@ -1725,7 +1798,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().unwrap();
|
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
|
||||||
|
|
||||||
draft.resolve_gitroot(seq, None);
|
draft.resolve_gitroot(seq, None);
|
||||||
assert!(!draft.worktree_enabled);
|
assert!(!draft.worktree_enabled);
|
||||||
@@ -1869,6 +1942,61 @@ 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();
|
||||||
@@ -1894,7 +2022,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().is_none(),
|
draft.take_gitroot_probe(probe_due()).is_none(),
|
||||||
"re-enabling reuses the known repo for an unchanged target"
|
"re-enabling reuses the known repo for an unchanged target"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ impl InputKey {
|
|||||||
if *alt {
|
if *alt {
|
||||||
out.push_str("alt-");
|
out.push_str("alt-");
|
||||||
}
|
}
|
||||||
if *shift {
|
// Uppercase already implies shift; crossterm sets the flag
|
||||||
|
// on every such char, so skip the redundant prefix (#70).
|
||||||
|
if *shift && !c.is_uppercase() {
|
||||||
out.push_str("shift-");
|
out.push_str("shift-");
|
||||||
}
|
}
|
||||||
out.push(*c);
|
out.push(*c);
|
||||||
@@ -580,12 +582,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_simple_chars() {
|
fn canonical_round_trips_simple_chars() {
|
||||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
||||||
|
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||||
|
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||||
|
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||||
|
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||||
|
#[test]
|
||||||
|
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||||
|
let k = InputKey::Char {
|
||||||
|
c: 'S',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: true,
|
||||||
|
};
|
||||||
|
assert_eq!(k.canonical(), "S");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_specials() {
|
fn canonical_round_trips_specials() {
|
||||||
for s in [
|
for s in [
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
|
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const empty = splitLeaf(tab, first, null, "row", true);
|
||||||
|
tab.focusedLeafId = empty;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||||
|
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
expect(tab.focusedLeafId).toBe(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const empty = splitLeaf(tab, first, null, "row", true);
|
||||||
|
tab.focusedLeafId = empty;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||||
|
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||||
|
expect(leaf.draft?.command).toBe("claude");
|
||||||
|
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||||
|
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds a clone's cwd and command into the draft", async () => {
|
it("seeds a clone's cwd and command into the draft", async () => {
|
||||||
const layout = emptyLayout();
|
const layout = emptyLayout();
|
||||||
const mv = makeMv(layout);
|
const mv = makeMv(layout);
|
||||||
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect(leaf.draft?.command).toBe("claude");
|
expect(leaf.draft?.command).toBe("claude");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multiview pane-drag swap (#73)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
||||||
|
* drag handler does — modelled after the pointer sequence in runDrag()/
|
||||||
|
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||||
|
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||||
|
// The real drag-start gate requires the pointerdown target to sit inside
|
||||||
|
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||||
|
// no such element, so stand one in for the gate check itself.
|
||||||
|
const marker = document.createElement("div");
|
||||||
|
marker.className = "attach-header";
|
||||||
|
sourcePane.appendChild(marker);
|
||||||
|
|
||||||
|
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
right: 200,
|
||||||
|
bottom: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
toJSON() {},
|
||||||
|
} as DOMRect);
|
||||||
|
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
||||||
|
targetPane,
|
||||||
|
];
|
||||||
|
|
||||||
|
marker.dispatchEvent(
|
||||||
|
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||||
|
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||||
|
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||||
|
|
||||||
|
nameInputOf(leaf1).value = "west";
|
||||||
|
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||||
|
nameInputOf(leaf2).value = "east";
|
||||||
|
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||||
|
|
||||||
|
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||||
|
|
||||||
|
// The leaf data swaps (already correct pre-fix)...
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||||
|
// ...and the mounted forms must follow, not stay behind showing the old
|
||||||
|
// pane's values (#73).
|
||||||
|
expect(nameInputOf(leaf1).value).toBe("east");
|
||||||
|
expect(nameInputOf(leaf2).value).toBe("west");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+18
-2
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||||
if (!target.sessionId && !target.draft) {
|
if (!target.sessionId) {
|
||||||
// An already-empty pane becomes the draft in place.
|
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||||
|
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||||
target.draft = draft();
|
target.draft = draft();
|
||||||
|
const stale = draftForms.get(target.id);
|
||||||
|
if (stale) {
|
||||||
|
stale.destroy();
|
||||||
|
draftForms.delete(target.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Split a fresh draft beside the focused pane.
|
// Split a fresh draft beside the focused pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||||
@@ -695,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const dragged = findLeaf(tab.root, self)!;
|
const dragged = findLeaf(tab.root, self)!;
|
||||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||||
|
// The leaf data moved but each pane's mounted form is still keyed to
|
||||||
|
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||||
|
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||||
|
for (const id of [dragged.id, target.id]) {
|
||||||
|
const stale = draftForms.get(id);
|
||||||
|
if (stale) {
|
||||||
|
stale.destroy();
|
||||||
|
draftForms.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
tab.focusedLeafId = target.id;
|
tab.focusedLeafId = target.id;
|
||||||
} else if (sessionId) {
|
} else if (sessionId) {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
|
|||||||
+34
-5
@@ -94,6 +94,11 @@ 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. */
|
||||||
@@ -413,7 +418,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwd = cwdRaw.value.trim();
|
cwd = cwdRaw.value.trim();
|
||||||
branchCache = null;
|
branchCache = null;
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) probeGitRoot();
|
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||||
});
|
});
|
||||||
|
|
||||||
function markActiveProject(activeCwd: string | null): void {
|
function markActiveProject(activeCwd: string | null): void {
|
||||||
@@ -430,9 +435,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
cwdRawWrap.hidden = true;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveProject(project.path);
|
markActiveProject(project.path);
|
||||||
// The scan already knows this directory is inside a repo (#61): the
|
// The scan already knows this directory is inside a repo (#61): the
|
||||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
// toggle defaults back on and no /api/gitroot round trip is needed —
|
||||||
wtEnabled.checked = true;
|
// unless the directory *is* a linked worktree itself (#91): spawning
|
||||||
applyProjectRepo(project.base_repo ?? project.path);
|
// in place is the only thing that works there, so default the toggle
|
||||||
|
// off. Turning it back on by hand still tries (and fails with git's
|
||||||
|
// own message), which is deliberate (#91 non-goal).
|
||||||
|
wtEnabled.checked = !project.is_worktree;
|
||||||
|
if (project.is_worktree) {
|
||||||
|
inferredBaseRepo = null;
|
||||||
|
wtBody.hidden = true;
|
||||||
|
wtHint.hidden = true;
|
||||||
|
} else {
|
||||||
|
applyProjectRepo(project.base_repo ?? project.path);
|
||||||
|
}
|
||||||
emit();
|
emit();
|
||||||
// Picking a project pre-selects the command it was last used with.
|
// Picking a project pre-selects the command it was last used with.
|
||||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||||
@@ -569,7 +584,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
applyProjectRepo(project.base_repo ?? project.path);
|
applyProjectRepo(project.base_repo ?? project.path);
|
||||||
return;
|
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<void> {
|
async function probeGitRoot(): Promise<void> {
|
||||||
@@ -730,6 +758,7 @@ 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,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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const BRANCHES = [
|
|||||||
* its branch list is still in flight. */
|
* its branch list is still in flight. */
|
||||||
function stubFetch(opts?: {
|
function stubFetch(opts?: {
|
||||||
gateBranches?: (release: () => void) => void;
|
gateBranches?: (release: () => void) => void;
|
||||||
|
projects?: unknown[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
const gated = new Promise<void>((resolve) => {
|
const gated = new Promise<void>((resolve) => {
|
||||||
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
|
|||||||
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
||||||
}
|
}
|
||||||
if (url.includes("/api/projects")) {
|
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(
|
return new Response(
|
||||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||||
@@ -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<HTMLButtonElement>(".project-row")
|
||||||
|
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||||
|
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
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 fire.
|
// 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<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);
|
||||||
|
|||||||
@@ -1215,6 +1215,9 @@ textarea {
|
|||||||
/* ---------- forms ---------- */
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
.spawn-form {
|
.spawn-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0 auto;
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user