Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cdbd4f0e | ||
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
52f9d0679e | ||
|
|
e6ef236f89 | ||
|
|
5747c71800 | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
b646cc5adf | ||
|
|
cfc125144f | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
d3098a9400 | ||
|
|
5de04af36a | ||
|
|
36cb03961c | ||
|
|
5e34ce7a61 | ||
|
|
1fa9ce798e | ||
|
|
2c89902829 | ||
|
|
8949fdfdf6 | ||
|
|
596e7e2f49 | ||
|
|
16609eff9e | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 | ||
|
|
2a2da7f779 | ||
|
|
5eab434647 | ||
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
b80d604408 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c |
@@ -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
|
||||||
|
|||||||
@@ -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 —
|
||||||
@@ -1328,9 +1349,7 @@ mod tests {
|
|||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
||||||
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
|
||||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||||
|
|
||||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
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
|
/// 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 +1379,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:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1520,8 +1551,8 @@ mod tests {
|
|||||||
panic!("expected a draft pane");
|
panic!("expected a draft pane");
|
||||||
};
|
};
|
||||||
assert_eq!(draft.command(), "aider");
|
assert_eq!(draft.command(), "aider");
|
||||||
assert_eq!(draft.rows.len(), 1);
|
assert_eq!(draft.groups.len(), 1);
|
||||||
assert_eq!(draft.rows[0].project.name, "maestro");
|
assert_eq!(draft.groups[0].main.name, "maestro");
|
||||||
assert_eq!(draft.home_dir, "/home/u");
|
assert_eq!(draft.home_dir, "/home/u");
|
||||||
assert_eq!(draft.name.value(), "crimson-otter");
|
assert_eq!(draft.name.value(), "crimson-otter");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+948
-140
File diff suppressed because it is too large
Load Diff
@@ -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,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowPaths(form: HTMLFormElement): string[] {
|
function boxPaths(form: HTMLFormElement): string[] {
|
||||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
||||||
(r) => r.dataset.cwd ?? "",
|
(b) => b.dataset.cwd ?? "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("spawnForm project picker", () => {
|
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
||||||
let handle: SpawnFormHandle | null = null;
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renders one row per project, fresh before stale", async () => {
|
it("renders one box per project, fresh before stale", async () => {
|
||||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||||
const stale = project({
|
const stale = project({
|
||||||
name: "stale",
|
name: "stale",
|
||||||
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
|
|||||||
last_activity: "2026-01-01T00:00:00Z",
|
last_activity: "2026-01-01T00:00:00Z",
|
||||||
});
|
});
|
||||||
const form = (await mount([stale, fresh])).el;
|
const form = (await mount([stale, fresh])).el;
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
||||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
||||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nests worktrees under their base repository", async () => {
|
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||||
|
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||||
|
const form = (await mount([p])).el;
|
||||||
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
|
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||||
|
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||||
|
// projects sharing a basename (#95).
|
||||||
|
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
const wt = project({
|
const wt = project({
|
||||||
name: "wt",
|
name: "wt",
|
||||||
path: "/elsewhere/wt",
|
path: "/home/user/maestro/worktrees/wt",
|
||||||
is_worktree: true,
|
is_worktree: true,
|
||||||
base_repo: main.path,
|
base_repo: main.path,
|
||||||
});
|
});
|
||||||
const form = (await mount([wt, main])).el;
|
const form = (await mount([wt, main])).el;
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||||
const [mainRow, wtRow] = [
|
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
||||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
|
||||||
];
|
|
||||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
|
||||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters rows as the operator types", async () => {
|
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
||||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||||
const form = (await mount([a, b])).el;
|
const wt = project({
|
||||||
|
name: "bulgarians",
|
||||||
|
path: "/home/user/maestro/worktrees/bulgarians",
|
||||||
|
is_worktree: true,
|
||||||
|
base_repo: a.path,
|
||||||
|
});
|
||||||
|
const form = (await mount([a, b, wt])).el;
|
||||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||||
filter.value = "tea";
|
filter.value = "tea";
|
||||||
filter.dispatchEvent(new Event("input"));
|
filter.dispatchEvent(new Event("input"));
|
||||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||||
|
// A query that only matches a worktree still surfaces its repository box.
|
||||||
|
filter.value = "bulgarians";
|
||||||
|
filter.dispatchEvent(new Event("input"));
|
||||||
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||||
filter.value = "zzz";
|
filter.value = "zzz";
|
||||||
filter.dispatchEvent(new Event("input"));
|
filter.dispatchEvent(new Event("input"));
|
||||||
expect(rowPaths(form)).toEqual([]);
|
expect(boxPaths(form)).toEqual([]);
|
||||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
|
|||||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
onChange.mockClear();
|
onChange.mockClear();
|
||||||
|
|
||||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
row.click();
|
box.click();
|
||||||
|
|
||||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||||
const last = onChange.mock.lastCall![0];
|
const last = onChange.mock.lastCall![0];
|
||||||
expect(last.cwd).toBe("/home/user/maestro");
|
expect(last.cwd).toBe("/home/user/maestro");
|
||||||
expect(last.command).toBe("claude");
|
expect(last.command).toBe("claude");
|
||||||
expect(values).toBeTruthy();
|
expect(values).toBeTruthy();
|
||||||
// The raw input stays hidden — the picked row is the source of truth.
|
// The raw input stays hidden — the picked box is the source of truth.
|
||||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||||
expect(row.classList.contains("is-active")).toBe(true);
|
expect(box.classList.contains("is-active")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||||
|
|||||||
+294
-84
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
|
||||||
|
|
||||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
* from the repository's existing branches (#61). */
|
* from the repository's existing branches (#61). */
|
||||||
@@ -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. */
|
||||||
@@ -125,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
placeholder="bash -l" autocomplete="off" />
|
placeholder="bash -l" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Working directory</span>
|
<span class="field-label">Project</span>
|
||||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||||
autocomplete="off" />
|
autocomplete="off" />
|
||||||
<div class="picker projects"></div>
|
<div class="picker projects"></div>
|
||||||
|
<button type="button" class="project-other">Other directory…</button>
|
||||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field wt-step" hidden>
|
||||||
|
<span class="field-label">Worktree</span>
|
||||||
|
<div class="picker wt-choices"></div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Name</span>
|
<span class="field-label">Name</span>
|
||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
@@ -142,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="worktree">
|
<fieldset class="worktree">
|
||||||
<legend>
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
<span class="folder-name">Advanced options</span>
|
||||||
|
<span class="wt-summary"></span>
|
||||||
|
</button>
|
||||||
|
<div class="wt-collapsible" hidden>
|
||||||
<label class="row-inline">
|
<label class="row-inline">
|
||||||
<input type="checkbox" name="worktree_enabled" />
|
<input type="checkbox" name="worktree_enabled" />
|
||||||
Create git worktree
|
Create git worktree
|
||||||
</label>
|
</label>
|
||||||
</legend>
|
<p class="wt-hint" hidden></p>
|
||||||
<p class="wt-hint" hidden></p>
|
<div class="wt-body" hidden>
|
||||||
<div class="wt-body" hidden>
|
<p class="wt-repo"></p>
|
||||||
<p class="wt-repo"></p>
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
<label class="row-inline">
|
||||||
<label class="row-inline">
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
New branch
|
||||||
New branch
|
</label>
|
||||||
|
<label class="row-inline">
|
||||||
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||||
|
Existing branch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="wt-new-wrap">
|
||||||
|
New branch name
|
||||||
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="row-inline">
|
<label class="wt-exist-wrap" hidden>
|
||||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
Pick a branch
|
||||||
Existing branch
|
<select class="branch-select"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="wt-new-wrap">
|
|
||||||
Branch
|
|
||||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label class="wt-exist-wrap" hidden>
|
|
||||||
Branch
|
|
||||||
<select class="branch-select"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||||
@@ -182,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
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 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 cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") 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 wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||||
@@ -337,6 +358,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) {
|
||||||
@@ -345,13 +375,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();
|
||||||
}
|
}
|
||||||
@@ -367,7 +407,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(() => {
|
||||||
@@ -394,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (first) selectPreset(first.id, false);
|
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 {
|
function showRawCwd(prefillValue?: string, focus = true): void {
|
||||||
cwdRawWrap.hidden = false;
|
cwdRawWrap.hidden = false;
|
||||||
if (prefillValue != null) cwd = prefillValue;
|
if (prefillValue != null) cwd = prefillValue;
|
||||||
cwdRaw.value = cwd;
|
cwdRaw.value = cwd;
|
||||||
markActiveProject(null);
|
clearProjectSelection();
|
||||||
if (focus) {
|
if (focus) {
|
||||||
cwdRaw.focus();
|
cwdRaw.focus();
|
||||||
cwdRaw.select();
|
cwdRaw.select();
|
||||||
@@ -413,55 +481,98 @@ 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 {
|
/** Every worktree of a group, including the group's own box when the scan
|
||||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
* found no main checkout to nest it under. */
|
||||||
row.classList.toggle(
|
function worktreesOf(group: ProjectGroup): Project[] {
|
||||||
"is-active",
|
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
}
|
||||||
);
|
|
||||||
|
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 {
|
function markActiveChoice(): void {
|
||||||
cwd = project.path;
|
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;
|
cwdRawWrap.hidden = true;
|
||||||
markActiveProject(project.path);
|
renderWorktreeChoices(group);
|
||||||
// The scan already knows this directory is inside a repo (#61): the
|
markActiveBox();
|
||||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||||
wtEnabled.checked = true;
|
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
|
||||||
applyProjectRepo(project.base_repo ?? project.path);
|
}
|
||||||
|
|
||||||
|
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();
|
emit();
|
||||||
// Picking a project pre-selects the command it was last used with.
|
// The chosen directory 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 === target.last_command && p.command !== "");
|
||||||
if (match) selectPreset(match.id);
|
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 {
|
function renderProjects(): void {
|
||||||
recentsEl.replaceChildren();
|
projectsEl.replaceChildren();
|
||||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||||
for (const group of groupProjects(sorted)) {
|
const visible = visibleGroups(filterInput.value);
|
||||||
recentsEl.appendChild(projectRow(group.main));
|
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
if (discovered.length > 0 && visible.length === 0) {
|
||||||
}
|
|
||||||
if (discovered.length > 0 && sorted.length === 0) {
|
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "picker-empty";
|
empty.className = "picker-empty";
|
||||||
empty.textContent = "No projects match.";
|
empty.textContent = "No projects match.";
|
||||||
recentsEl.appendChild(empty);
|
projectsEl.appendChild(empty);
|
||||||
}
|
}
|
||||||
recentsEl.appendChild(otherDirRow());
|
markActiveBox();
|
||||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filterInput.addEventListener("input", renderProjects);
|
filterInput.addEventListener("input", renderProjects);
|
||||||
|
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||||
|
|
||||||
async function loadProjects(): Promise<void> {
|
async function loadProjects(): Promise<void> {
|
||||||
recentsEl.replaceChildren(loadingRow());
|
projectsEl.replaceChildren(loadingRow());
|
||||||
try {
|
try {
|
||||||
discovered = await fetchProjects();
|
discovered = await fetchProjects();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -472,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (seedCwd) {
|
if (seedCwd) {
|
||||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
// `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).
|
// focused in the raw input (clone / no-match flows).
|
||||||
if (prefill.cwdRawVisible === false) {
|
if (prefill.cwdRawVisible === false) {
|
||||||
cwd = seedCwd;
|
cwd = seedCwd;
|
||||||
cwdRaw.value = seedCwd;
|
cwdRaw.value = seedCwd;
|
||||||
markActiveProject(cwd);
|
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||||
|
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||||
} else {
|
} else {
|
||||||
showRawCwd(seedCwd, false);
|
showRawCwd(seedCwd, false);
|
||||||
}
|
}
|
||||||
@@ -494,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectRow(project: Project): HTMLElement {
|
/** Put the two steps back on a restored draft's cwd, without re-running the
|
||||||
const row = document.createElement("button");
|
* side effects of a fresh pick. */
|
||||||
row.type = "button";
|
function restoreSelection(path: string): boolean {
|
||||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
for (const group of groups) {
|
||||||
isStale(project, new Date()) ? " is-stale" : ""
|
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||||
}`;
|
const choice: WorktreeChoice | null =
|
||||||
row.dataset.cwd = project.path;
|
wt ? wt.path : group.main.path === path ? "new" : null;
|
||||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
if (choice === null) continue;
|
||||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
selectedGroup = group;
|
||||||
// and `dataset.cwd` (what's actually submitted).
|
worktreeChoice = choice;
|
||||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
renderWorktreeChoices(group);
|
||||||
row.innerHTML = `
|
markActiveChoice();
|
||||||
<span class="project-info">
|
markActiveBox();
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
return true;
|
||||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
}
|
||||||
</span>
|
return false;
|
||||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
|
||||||
row.addEventListener("click", () => selectProject(project));
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function otherDirRow(): HTMLElement {
|
function projectBox(group: ProjectGroup): HTMLElement {
|
||||||
const row = document.createElement("button");
|
const project = group.main;
|
||||||
row.type = "button";
|
const box = document.createElement("button");
|
||||||
row.className = "project-row project-other";
|
box.type = "button";
|
||||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
box.dataset.cwd = project.path;
|
||||||
return row;
|
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 = `
|
||||||
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
|
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||||
|
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||||
|
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 = `
|
||||||
|
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
||||||
|
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
||||||
|
btn.addEventListener("click", () => applyWorktreeChoice(choice));
|
||||||
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadingRow(): HTMLElement {
|
function loadingRow(): HTMLElement {
|
||||||
@@ -531,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
return p;
|
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) --
|
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||||
// The project scan already reports repo membership, so a picked project
|
// The project scan already reports repo membership, so a picked project
|
||||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
// 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);
|
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> {
|
||||||
@@ -598,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// Publish the untick too, or the next render seeds the box checked with
|
// Publish the untick too, or the next render seeds the box checked with
|
||||||
// no inferred repo and submitting fails.
|
// no inferred repo and submitting fails.
|
||||||
wtEnabled.checked = false;
|
wtEnabled.checked = false;
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
wtBody.hidden = true;
|
wtBody.hidden = true;
|
||||||
wtHint.hidden = false;
|
wtHint.hidden = false;
|
||||||
@@ -671,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
branchSelect.addEventListener("change", () => emit());
|
branchSelect.addEventListener("change", () => emit());
|
||||||
|
|
||||||
wtEnabled.addEventListener("change", () => {
|
wtEnabled.addEventListener("change", () => {
|
||||||
|
updateWtSummary();
|
||||||
emit();
|
emit();
|
||||||
if (wtEnabled.checked) {
|
if (wtEnabled.checked) {
|
||||||
resolveBaseRepo();
|
resolveBaseRepo();
|
||||||
@@ -684,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// The toggle defaults ON (#61): the operator almost always wants a
|
// The toggle defaults ON (#61): the operator almost always wants a
|
||||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||||
|
updateWtSummary();
|
||||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||||
setBranchMode(branchMode);
|
setBranchMode(branchMode);
|
||||||
|
|
||||||
@@ -694,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
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 ----------------------------------------------------------
|
// ---- submit ----------------------------------------------------------
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -730,6 +939,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,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<HTMLFormElement> {
|
||||||
|
stubFetch(projects);
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||||
|
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<HTMLButtonElement>(`.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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(".project-other")!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>("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<HTMLButtonElement>(".project-other")!.click();
|
||||||
|
const raw = form.querySelector<HTMLInputElement>("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<HTMLButtonElement>(`.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<HTMLInputElement>(".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<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const filter = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||||
|
"is-active",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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-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-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: [] }),
|
||||||
@@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function pickProject(form: HTMLFormElement): void {
|
function pickProject(form: HTMLFormElement): void {
|
||||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||||
form
|
form
|
||||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
.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 () => {
|
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<HTMLButtonElement>(".project-box")
|
||||||
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.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
|
||||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
// fire — debounced, so the quiet period must elapse first (#74).
|
||||||
|
form.querySelector<HTMLElement>(".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);
|
||||||
@@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|||||||
expect(modes.at(-1)).toBe("existing");
|
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<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
||||||
|
const existLabel = form.querySelector<HTMLElement>(".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<HTMLButtonElement>(".wt-toggle")!;
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(form.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLButtonElement>(".project-box")
|
||||||
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||||
|
// Still collapsed — picking a project must not force it open.
|
||||||
|
expect(handle.el.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
|
|
||||||
|
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(form.querySelector<HTMLElement>(".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 () => {
|
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||||
const gate: { release?: () => void } = {};
|
const gate: { release?: () => void } = {};
|
||||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||||
|
|||||||
+144
-45
@@ -727,10 +727,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Earlier path segments collapse to their initial; the trailing segment (the
|
/* Earlier path segments collapse to their initial; the trailing segment (the
|
||||||
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
|
one that actually names the dir) stays full. Dim the collapsed prefix. */
|
||||||
by the sidebar tree and the spawn dialog's recent-directory picker. */
|
.folder-name .folder-stem {
|
||||||
.folder-name .folder-stem,
|
|
||||||
.recent-dir .folder-stem {
|
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,6 +1213,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;
|
||||||
@@ -1283,18 +1284,33 @@ textarea {
|
|||||||
.worktree {
|
.worktree {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
padding: var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-3);
|
|
||||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worktree legend {
|
.worktree .wt-toggle {
|
||||||
padding: 0 6px;
|
margin: 0 calc(var(--sp-3) * -1);
|
||||||
color: var(--ink);
|
padding-left: var(--sp-3);
|
||||||
font-size: var(--fs-13);
|
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 {
|
.form-error {
|
||||||
@@ -1378,8 +1394,9 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||||
|
selectable. */
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1394,73 +1411,155 @@ textarea {
|
|||||||
width: 100%;
|
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;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
|
min-height: 32px;
|
||||||
padding: 6px var(--sp-3);
|
padding: 6px var(--sp-3);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-2);
|
border-radius: var(--r-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-out),
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
background var(--dur-fast) var(--ease-out);
|
background var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row:hover {
|
.wt-choice:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row.is-active {
|
.wt-choice.is-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row.is-worktree {
|
.wt-choice-name {
|
||||||
margin-left: var(--sp-4);
|
flex: 0 0 auto;
|
||||||
}
|
|
||||||
|
|
||||||
.project-row.is-stale {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-name {
|
|
||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-dir {
|
.wt-choice-new .wt-choice-name {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
color: var(--accent);
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-cmd {
|
.wt-choice-sub {
|
||||||
flex: 0 0 auto;
|
min-width: 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--fs-12);
|
font-size: var(--fs-12);
|
||||||
|
line-height: 1.35;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
.project-other .recent-dir {
|
|
||||||
font-family: inherit;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-empty {
|
.picker-empty {
|
||||||
|
|||||||
@@ -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<HTMLFormElement> {
|
||||||
|
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<HTMLElement>(".wt-step")!;
|
||||||
|
const choices = (form: HTMLFormElement) => [
|
||||||
|
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
||||||
|
];
|
||||||
|
const activeChoice = (form: HTMLFormElement) =>
|
||||||
|
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
||||||
|
const box = (form: HTMLFormElement, path: string) =>
|
||||||
|
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||||
|
const toggle = (form: HTMLFormElement) =>
|
||||||
|
form.querySelector<HTMLInputElement>("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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLButtonElement>(".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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user