Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cb03961c | ||
|
|
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
|
||||
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.
|
||||
/// 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>> {
|
||||
self.repos_inner(false).await
|
||||
}
|
||||
|
||||
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if !force {
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
@@ -287,11 +283,11 @@ impl ProjectScanner {
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
||||
/// leaving requests to pay for the rescan instead (#54 review).
|
||||
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||
/// roots every [`REFRESH_INTERVAL`].
|
||||
async fn refresh(&self) {
|
||||
self.repos_inner(true).await;
|
||||
self.repos().await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
@@ -544,6 +540,32 @@ mod tests {
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_honours_the_ttl() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// A background tick inside the TTL must not re-walk the filesystem:
|
||||
// the new repo stays invisible until the cache expires.
|
||||
scanner.refresh().await;
|
||||
assert!(scanner.repos().await.is_empty());
|
||||
|
||||
// Once the TTL lapses, the next tick picks it up.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
scanner.refresh().await;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
|
||||
@@ -238,6 +238,26 @@ mod tests {
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||
/// matched. Drive the actual crossterm event, not the binding string,
|
||||
/// so the dead-key bug can't come back unnoticed.
|
||||
#[test]
|
||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::input::{canonicalise, Region};
|
||||
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chord_timeout_is_applied() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
@@ -654,6 +654,11 @@ impl DraftPane {
|
||||
let project = visible[sel].project.clone();
|
||||
self.picked_cwd = Some(project.path.clone());
|
||||
self.cwd_raw_visible = false;
|
||||
// A directory that *is* a linked worktree defaults to spawning in
|
||||
// place (#91): creating a second worktree of its branch fails.
|
||||
// Turning it back on by hand still tries (git's own message),
|
||||
// which is deliberate (#91 non-goal).
|
||||
self.worktree_enabled = !project.is_worktree;
|
||||
// The scan already knows this directory's repo (#61): no probe
|
||||
// round trip needed, exactly like the web form.
|
||||
let repo = project
|
||||
@@ -1689,6 +1694,40 @@ mod tests {
|
||||
assert!(wt.create_branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_a_worktree_project_defaults_the_toggle_off() {
|
||||
let mut draft = DraftPane::default();
|
||||
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
|
||||
wt.is_worktree = true;
|
||||
draft.set_projects(
|
||||
vec![
|
||||
repo_project("main", "/repos/main", "/repos/main", 2, None),
|
||||
wt,
|
||||
],
|
||||
Utc::now(),
|
||||
);
|
||||
draft.focused_field = DraftField::DirPicker;
|
||||
typed(&mut draft, "wt-91");
|
||||
draft.on_input(&InputKey::Enter);
|
||||
|
||||
assert!(
|
||||
!draft.worktree_enabled,
|
||||
"the directory is already a worktree; spawning in place"
|
||||
);
|
||||
assert!(
|
||||
draft.build_request().unwrap().worktree.is_none(),
|
||||
"no worktree record — the session just runs there (#91)"
|
||||
);
|
||||
|
||||
// Deliberately re-enabling still works, and the scan-known base repo
|
||||
// survives so the toggle-on path needs no new probe.
|
||||
draft.focused_field = DraftField::WorktreeToggle;
|
||||
draft.on_input(&InputKey::Enter);
|
||||
assert!(draft.worktree_enabled);
|
||||
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
|
||||
assert!(draft.take_gitroot_probe().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -117,7 +117,9 @@ impl InputKey {
|
||||
if *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(*c);
|
||||
@@ -580,12 +582,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
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]
|
||||
fn canonical_round_trips_specials() {
|
||||
for s in [
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
|
||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
||||
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 () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
@@ -160,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
|
||||
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;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
if (!target.sessionId) {
|
||||
// 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();
|
||||
const stale = draftForms.get(target.id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(target.id);
|
||||
}
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
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)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
[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;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
|
||||
+38
-5
@@ -337,6 +337,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||
* without touching `command`, since that was already settled. */
|
||||
function reapplyActiveTile(): void {
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommandTiles(): void {
|
||||
cmdTiles.replaceChildren();
|
||||
for (const p of presets) {
|
||||
@@ -345,13 +354,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
tile.className = "tile";
|
||||
tile.dataset.id = p.id;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Set once the operator picks a tile or types a custom command — guards
|
||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
commandTouched = true;
|
||||
command = cmdRaw.value.trim();
|
||||
emit();
|
||||
}
|
||||
@@ -367,7 +386,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (info.presets?.length) {
|
||||
presets = info.presets;
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
if (commandTouched) {
|
||||
reapplyActiveTile();
|
||||
} else {
|
||||
applyInitialCommand();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -430,9 +453,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed —
|
||||
// unless the directory *is* a linked worktree itself (#91): spawning
|
||||
// in place is the only thing that works there, so default the toggle
|
||||
// off. Turning it back on by hand still tries (and fails with git's
|
||||
// own message), which is deliberate (#91 non-goal).
|
||||
wtEnabled.checked = !project.is_worktree;
|
||||
if (project.is_worktree) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
} else {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
}
|
||||
emit();
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const BRANCHES = [
|
||||
* its branch list is still in flight. */
|
||||
function stubFetch(opts?: {
|
||||
gateBranches?: (release: () => void) => void;
|
||||
projects?: unknown[];
|
||||
}): string[] {
|
||||
const urls: string[] = [];
|
||||
const gated = new Promise<void>((resolve) => {
|
||||
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
|
||||
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
@@ -100,6 +101,34 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("still probes the gitroot for a free-text directory", async () => {
|
||||
const form = await mount();
|
||||
|
||||
|
||||
@@ -1215,6 +1215,9 @@ textarea {
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.spawn-form {
|
||||
width: 100%;
|
||||
max-width: 60ch;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user