diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 57b91fc..41f5c2e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -97,3 +97,24 @@ jobs: - name: build run: pnpm -C web build + + # The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile + # change invalidates the pin silently elsewhere, so the packaged build has to + # fail here too. + flake: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install nix + run: | + curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + + - name: build flake packages + run: | + nix build \ + .#webFrontend \ + .#maestro \ + .#maestro-web \ + --no-link diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts index ba6b821..6932566 100644 --- a/web/src/projectPicker.test.ts +++ b/web/src/projectPicker.test.ts @@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void { ); } -function rowPaths(form: HTMLFormElement): string[] { - return [...form.querySelectorAll(".project-row:not(.project-other)")].map( - (r) => r.dataset.cwd ?? "", +function boxPaths(form: HTMLFormElement): string[] { + return [...form.querySelectorAll(".project-box")].map( + (b) => b.dataset.cwd ?? "", ); } -describe("spawnForm project picker", () => { +describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => { let handle: SpawnFormHandle | null = null; beforeEach(() => { @@ -65,7 +65,7 @@ describe("spawnForm project picker", () => { 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 stale = project({ name: "stale", @@ -73,40 +73,56 @@ describe("spawnForm project picker", () => { last_activity: "2026-01-01T00:00:00Z", }); const form = (await mount([stale, fresh])).el; - expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]); - const rows = [...form.querySelectorAll(".project-row")]; - expect(rows[1].classList.contains("is-stale")).toBe(true); - expect(rows[0].classList.contains("is-stale")).toBe(false); + expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]); + const boxes = [...form.querySelectorAll(".project-box")]; + expect(boxes[1].classList.contains("is-stale")).toBe(true); + 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(".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 wt = project({ name: "wt", - path: "/elsewhere/wt", + path: "/home/user/maestro/worktrees/wt", is_worktree: true, base_repo: main.path, }); const form = (await mount([wt, main])).el; - expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]); - const [mainRow, wtRow] = [ - ...form.querySelectorAll(".project-row:not(.project-other)"), - ]; - expect(wtRow.classList.contains("is-worktree")).toBe(true); - expect(mainRow.classList.contains("is-worktree")).toBe(false); + expect(boxPaths(form)).toEqual(["/home/user/maestro"]); + expect(form.querySelector(".project-box.is-worktree")).toBeNull(); }); - 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 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(".project-filter")!; filter.value = "tea"; 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.dispatchEvent(new Event("input")); - expect(rowPaths(form)).toEqual([]); + expect(boxPaths(form)).toEqual([]); expect(form.querySelector(".picker-empty")).toBeTruthy(); }); @@ -135,17 +151,17 @@ describe("spawnForm project picker", () => { for (let i = 0; i < 20; i++) await Promise.resolve(); onChange.mockClear(); - const row = form.querySelector(".project-row")!; - row.click(); + const box = form.querySelector(".project-box")!; + box.click(); const values = form.querySelector("input[name=name]")!.value; const last = onChange.mock.lastCall![0]; expect(last.cwd).toBe("/home/user/maestro"); expect(last.command).toBe("claude"); 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(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 () => { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 714796b..42a0ca1 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -9,8 +9,8 @@ import { } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.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 { abbreviateCwd, abbreviateFolderName } from "./types.js"; /** Where a worktree's branch comes from: freshly created (default) or picked * from the repository's existing branches (#61). */ @@ -130,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { placeholder="bash -l" autocomplete="off" />
- Working directory + Project
+
+
Name
@@ -192,8 +197,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const errEl = form.querySelector(".form-error") as HTMLElement; const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement; - const recentsEl = form.querySelector(".projects") as HTMLElement; + const projectsEl = form.querySelector(".projects") as HTMLElement; const filterInput = form.querySelector(".project-filter") as HTMLInputElement; + const otherBtn = form.querySelector(".project-other") as HTMLButtonElement; + const wtStep = form.querySelector(".wt-step") as HTMLElement; + const wtChoices = form.querySelector(".wt-choices") as HTMLElement; const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement; const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement; const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement; @@ -430,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (first) selectPreset(first.id, false); } - // ---- working-directory picker --------------------------------------- + // ---- working-directory picker (#95) ---------------------------------- + // Two steps, and the TUI mirror (#96) must match these rules verbatim: + // 1. Step one is one box per repository (name, then full path). A worktree + // is a box only when its main checkout is missing from the scan. + // 2. The filter keeps a repository when the query matches it or any of its + // worktrees, so a worktree name still finds its box. + // 3. Picking a box reveals step two: "+ new", then that repository's + // worktrees newest-first. "Other directory…" clears both steps. + // 4. "+ new" is the default selection — except on a box that is itself a + // linked worktree, which defaults to that worktree. + // 5. "+ new" means create: cwd is the repository, create-worktree on. + // An existing worktree means run in it: cwd is that worktree, + // create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so + // these are separate acts — #91's rule, now an explicit choice. + // 6. Neither step probes /api/gitroot; the scan already reports repository + // membership. Only the free-text hatch pays for a probe. + + /** "new" (create a worktree) or the path of an existing worktree to run in. */ + type WorktreeChoice = "new" | string; + + let discovered: Project[] = []; // daemon snapshot, daemon-sorted + let groups: ProjectGroup[] = []; // rebuilt on every render + let selectedGroup: ProjectGroup | null = null; + let worktreeChoice: WorktreeChoice = "new"; + function showRawCwd(prefillValue?: string, focus = true): void { cwdRawWrap.hidden = false; if (prefillValue != null) cwd = prefillValue; cwdRaw.value = cwd; - markActiveProject(null); + clearProjectSelection(); if (focus) { cwdRaw.focus(); cwdRaw.select(); @@ -452,63 +484,91 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (wtEnabled.checked) scheduleGitRootProbe(); }); - function markActiveProject(activeCwd: string | null): void { - for (const row of recentsEl.querySelectorAll(".project-row")) { - row.classList.toggle( - "is-active", - (row as HTMLElement).dataset.cwd === activeCwd, - ); + /** Every worktree of a group, including the group's own box when the scan + * found no main checkout to nest it under. */ + function worktreesOf(group: ProjectGroup): Project[] { + return [group.main, ...group.worktrees].filter((p) => p.is_worktree); + } + + function markActiveBox(): void { + const active = selectedGroup?.main.path ?? null; + for (const box of projectsEl.querySelectorAll(".project-box")) { + box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active); } } - function selectProject(project: Project): void { - cwd = project.path; + function markActiveChoice(): void { + for (const btn of wtChoices.querySelectorAll(".wt-choice")) { + const el = btn as HTMLElement; + const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? ""); + el.classList.toggle("is-active", value === worktreeChoice); + } + } + + function clearProjectSelection(): void { + selectedGroup = null; + worktreeChoice = "new"; + wtChoices.replaceChildren(); + wtStep.hidden = true; + markActiveBox(); + } + + function selectProject(group: ProjectGroup): void { + selectedGroup = group; cwdRawWrap.hidden = true; - markActiveProject(project.path); - // The scan already knows this directory is inside a repo (#61): the - // toggle defaults back on and no /api/gitroot round trip is needed — - // 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; - updateWtSummary(); - if (project.is_worktree) { + renderWorktreeChoices(group); + markActiveBox(); + // Rule 4: a box that is itself a linked worktree defaults to running there. + applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new"); + } + + function applyWorktreeChoice(choice: WorktreeChoice): void { + const group = selectedGroup; + if (!group) return; + const target = + choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice); + if (!target) return; + worktreeChoice = choice; + cwd = target.path; + branchCache = null; + markActiveChoice(); + if (choice === "new") { + wtEnabled.checked = true; + updateWtSummary(); + applyProjectRepo(group.main.base_repo ?? group.main.path); + } else { + wtEnabled.checked = false; + updateWtSummary(); inferredBaseRepo = null; wtBody.hidden = true; wtHint.hidden = true; - } 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 !== ""); + // The chosen directory pre-selects the command it was last used with. + const match = presets.find((p) => p.command === target.last_command && p.command !== ""); if (match) selectPreset(match.id); } - let discovered: Project[] = []; // daemon snapshot, daemon-sorted - function renderProjects(): void { - recentsEl.replaceChildren(); - const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date()); - for (const group of groupProjects(sorted)) { - recentsEl.appendChild(projectRow(group.main)); - for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt)); - } - if (discovered.length > 0 && sorted.length === 0) { + projectsEl.replaceChildren(); + groups = groupProjects(sortProjects(discovered, new Date())); + const query = filterInput.value; + const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0); + for (const group of visible) projectsEl.appendChild(projectBox(group)); + if (discovered.length > 0 && visible.length === 0) { const empty = document.createElement("p"); empty.className = "picker-empty"; empty.textContent = "No projects match."; - recentsEl.appendChild(empty); + projectsEl.appendChild(empty); } - recentsEl.appendChild(otherDirRow()); - markActiveProject(cwdRawWrap.hidden ? cwd : null); + markActiveBox(); } filterInput.addEventListener("input", renderProjects); + otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir)); async function loadProjects(): Promise { - recentsEl.replaceChildren(loadingRow()); + projectsEl.replaceChildren(loadingRow()); try { discovered = await fetchProjects(); } catch { @@ -519,12 +579,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (seedCwd) { // A seed sets the directory verbatim, regardless of what the scan found. // `cwdRawVisible === false` marks a draft restore whose cwd came from a - // picked row: keep the picker and don't steal focus. Otherwise land + // picked box: keep the picker and don't steal focus. Otherwise land // focused in the raw input (clone / no-match flows). if (prefill.cwdRawVisible === false) { cwd = seedCwd; cwdRaw.value = seedCwd; - markActiveProject(cwd); + // Restoring must not touch the worktree toggle — the seed carries it. + if (!restoreSelection(seedCwd)) clearProjectSelection(); } else { showRawCwd(seedCwd, false); } @@ -541,34 +602,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { emit(); } - function projectRow(project: Project): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${ - isStale(project, new Date()) ? " is-stale" : "" - }`; - row.dataset.cwd = project.path; - // Abbreviate exactly like the sidebar: home → `~`, then collapse leading - // segments to initials (`~/g/n/maestro`). The full path stays in the tooltip - // and `dataset.cwd` (what's actually submitted). - const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir)); - row.innerHTML = ` - - ${escapeHtml(project.name)} - ${display} - - ${escapeHtml(project.last_command ?? "")}`; - row.addEventListener("click", () => selectProject(project)); - return row; + /** Put the two steps back on a restored draft's cwd, without re-running the + * side effects of a fresh pick. */ + function restoreSelection(path: string): boolean { + for (const group of groups) { + const wt = worktreesOf(group).find((p) => p.path === path); + const choice: WorktreeChoice | null = + wt ? wt.path : group.main.path === path ? "new" : null; + if (choice === null) continue; + selectedGroup = group; + worktreeChoice = choice; + renderWorktreeChoices(group); + markActiveChoice(); + markActiveBox(); + return true; + } + return false; } - function otherDirRow(): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "project-row project-other"; - row.innerHTML = `Other directory…`; - row.addEventListener("click", () => showRawCwd(cwd || homeDir)); - return row; + function projectBox(group: ProjectGroup): HTMLElement { + const project = group.main; + const box = document.createElement("button"); + box.type = "button"; + box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; + box.dataset.cwd = project.path; + const trees = worktreesOf(group).length; + // The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what + // tells two projects sharing a basename apart (#95). + const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""] + .filter(Boolean) + .join(" · "); + box.innerHTML = ` + ${escapeHtml(project.name)} + ${escapeHtml(project.path)} + ${escapeHtml(meta)}`; + box.addEventListener("click", () => selectProject(group)); + return box; + } + + function renderWorktreeChoices(group: ProjectGroup): void { + wtChoices.replaceChildren(); + wtChoices.appendChild( + choiceButton("new", "+ new", `new worktree of ${group.main.name}`), + ); + for (const wt of worktreesOf(group)) { + wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path)); + } + wtStep.hidden = false; + } + + function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`; + btn.dataset.kind = choice === "new" ? "new" : "existing"; + if (choice !== "new") btn.dataset.cwd = choice; + btn.innerHTML = ` + ${escapeHtml(label)} + ${escapeHtml(sub)}`; + btn.addEventListener("click", () => applyWorktreeChoice(choice)); + return btn; } function loadingRow(): HTMLElement { diff --git a/web/src/spawnFormProbe.test.ts b/web/src/spawnFormProbe.test.ts index 48eb074..b711340 100644 --- a/web/src/spawnFormProbe.test.ts +++ b/web/src/spawnFormProbe.test.ts @@ -101,7 +101,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => { it("a keystroke burst fires exactly one probe after the quiet period", async () => { const { form, stub } = await mount(); - form.querySelector(".project-row.project-other")!.click(); + form.querySelector(".project-other")!.click(); // Burst: every keystroke reschedules; nothing may hit /api/gitroot yet. for (let i = 1; i <= "/opt/somewhere".length; i++) { typeRaw(form, "/opt/somewhere".slice(0, i)); @@ -119,7 +119,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => { it("a stale probe answer cannot overwrite a newer input", async () => { const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] }); - form.querySelector(".project-row.project-other")!.click(); + form.querySelector(".project-other")!.click(); // Probe for the old target, held in flight. typeRaw(form, "/repo/old"); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index ff5ca1e..87be6d3 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -80,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { }; function pickProject(form: HTMLFormElement): void { - form.querySelector(".project-row.project-other")!.click(); + form.querySelector(".project-other")!.click(); form - .querySelectorAll(".project-row") - .forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click()); + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click()); } it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => { @@ -118,8 +118,8 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { for (let i = 0; i < 40; i++) await Promise.resolve(); handle.el - .querySelectorAll(".project-row") - .forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click()); + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); await drain(); expect(handle.el.querySelector("input[name=worktree_enabled]")!.checked).toBe( @@ -134,7 +134,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { // Escape hatch: switch to a free-text dir; only now does /api/gitroot // fire — debounced, so the quiet period must elapse first (#74). - form.querySelector(".project-row.project-other")!.click(); + form.querySelector(".project-other")!.click(); const raw = form.querySelector("input[name=cwd_raw]")!; raw.value = "/opt/somewhere"; raw.dispatchEvent(new Event("input")); @@ -209,8 +209,8 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => { for (let i = 0; i < 40; i++) await Promise.resolve(); handle.el - .querySelectorAll(".project-row") - .forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click()); + .querySelectorAll(".project-box") + .forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click()); await drain(); expect(handle.el.querySelector(".wt-summary")!.textContent).toMatch(/no worktree/i); diff --git a/web/src/styles.css b/web/src/styles.css index 5aef4a8..cd89d81 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -727,10 +727,8 @@ textarea { } /* Earlier path segments collapse to their initial; the trailing segment (the - one that actually names the dir) stays full. Dim the collapsed prefix. Shared - by the sidebar tree and the spawn dialog's recent-directory picker. */ -.folder-name .folder-stem, -.recent-dir .folder-stem { + one that actually names the dir) stays full. Dim the collapsed prefix. */ +.folder-name .folder-stem { color: var(--faint); } @@ -1396,8 +1394,9 @@ textarea { color: var(--muted); } -/* Project picker rows (#59). Worktree rows are indented under their base - repository; rows stale beyond 7 days are dimmed but still selectable. */ +/* Working-directory picker (#95). Two steps: a grid of project boxes, then the + picked project's worktree choices. Stale beyond 7 days dims but stays + selectable. */ .picker { display: flex; flex-direction: column; @@ -1412,73 +1411,155 @@ textarea { width: 100%; } -.project-row { +/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so + step two and the raw-cwd hatch stayed visible while hidden. */ +.wt-step[hidden], +.raw-wrap[hidden] { + display: none; +} + +.picker.projects { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + align-content: start; + gap: var(--sp-2); + max-height: 300px; + padding: 1px; +} + +.project-box { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: var(--sp-2) var(--sp-3); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-2); + color: var(--ink); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + border-color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.project-box:hover { + border-color: var(--border-strong); +} + +.project-box.is-active { + border-color: var(--accent); + background: var(--accent-tint); +} + +.project-box.is-stale { + opacity: 0.55; +} + +.project-name { + font-size: var(--fs-13); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The whole path, never abbreviated — it is what tells two projects sharing a + basename apart. Wraps rather than truncating so none of it is hidden. */ +.project-path { + font-family: var(--font-mono); + font-size: var(--fs-12); + line-height: 1.35; + color: var(--muted); + overflow-wrap: anywhere; +} + +.project-meta { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-meta:empty { + display: none; +} + +.project-other { + align-self: flex-start; + padding: 4px 0; + min-height: 30px; + background: none; + border: 0; + color: var(--muted); + font-family: inherit; + font-size: var(--fs-13); + font-style: italic; + text-align: left; + cursor: pointer; +} + +.project-other:hover { + color: var(--ink); +} + +/* Step two: "+ new" (create one) or an existing worktree (run in it). */ +.picker.wt-choices { + max-height: 200px; +} + +.wt-choice { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); + min-height: 32px; padding: 6px var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); color: var(--ink); font-family: inherit; - cursor: pointer; text-align: left; + cursor: pointer; transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); } -.project-row:hover { +.wt-choice:hover { border-color: var(--border-strong); } -.project-row.is-active { +.wt-choice.is-active { border-color: var(--accent); background: var(--accent-tint); } -.project-row.is-worktree { - margin-left: var(--sp-4); -} - -.project-row.is-stale { - opacity: 0.55; -} - -.project-info { - display: flex; - align-items: baseline; - gap: var(--sp-2); - min-width: 0; -} - -.project-name { +.wt-choice-name { + flex: 0 0 auto; font-size: var(--fs-13); font-weight: 600; white-space: nowrap; } -.recent-dir { +.wt-choice-new .wt-choice-name { font-family: var(--font-mono); - font-size: var(--fs-12); - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: var(--accent); } -.recent-cmd { - flex: 0 0 auto; +.wt-choice-sub { + min-width: 0; font-family: var(--font-mono); font-size: var(--fs-12); + line-height: 1.35; color: var(--muted); -} - -.project-other .recent-dir { - font-family: inherit; - font-style: italic; + text-align: right; + overflow-wrap: anywhere; } .picker-empty { diff --git a/web/src/worktreeStep.test.ts b/web/src/worktreeStep.test.ts new file mode 100644 index 0000000..c89a011 --- /dev/null +++ b/web/src/worktreeStep.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment happy-dom +// Second step of the working-directory picker (#95). See the interaction rules +// block in spawnForm.ts for what each choice means. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const MAESTRO: Project = { + path: "/home/user/tea/maestro", + name: "maestro", + is_worktree: false, + last_activity: "2026-08-20T10:00:00Z", + last_command: "claude", +}; + +const WT_A: Project = { + path: "/home/user/tea/maestro/worktrees/bulgarians", + name: "bulgarians", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-22T10:00:00Z", +}; + +const WT_B: Project = { + path: "/home/user/tea/maestro/worktrees/spawn-v2", + name: "spawn-v2", + is_worktree: true, + base_repo: MAESTRO.path, + last_activity: "2026-08-21T10:00:00Z", +}; + +const OTHER: Project = { + path: "/home/user/tea/rev", + name: "rev", + is_worktree: false, + last_activity: "2026-08-19T10:00:00Z", +}; + +function stubFetch(projects: Project[]): string[] { + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + const body = url.includes("/api/projects") + ? projects + : url.includes("/api/names") + ? { names: ["amber-fox"] } + : url.includes("/api/branches") + ? [] + : { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); + return urls; +} + +describe("spawnForm worktree step (#95)", () => { + let handle: SpawnFormHandle | null = null; + let urls: string[] = []; + let changes: SpawnFormValues[] = []; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") }); + changes = []; + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const drain = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + }; + + async function mount(projects: Project[]): Promise { + urls = stubFetch(projects); + handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) }); + document.body.appendChild(handle.el); + await drain(); + return handle.el; + } + + const step = (form: HTMLFormElement) => form.querySelector(".wt-step")!; + const choices = (form: HTMLFormElement) => [ + ...form.querySelectorAll(".wt-choice"), + ]; + const activeChoice = (form: HTMLFormElement) => + form.querySelector(".wt-choice.is-active"); + const box = (form: HTMLFormElement, path: string) => + form.querySelector(`.project-box[data-cwd="${path}"]`)!; + const toggle = (form: HTMLFormElement) => + form.querySelector("input[name=worktree_enabled]")!; + + it("hides the worktree step until a project is picked", async () => { + const form = await mount([MAESTRO, WT_A]); + expect(step(form).hidden).toBe(true); + expect(choices(form)).toEqual([]); + + box(form, MAESTRO.path).click(); + await drain(); + + expect(step(form).hidden).toBe(false); + }); + + it("lists '+ new' first and selected by default, then the project's worktrees", async () => { + const form = await mount([MAESTRO, WT_A, WT_B]); + box(form, MAESTRO.path).click(); + await drain(); + + const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent); + expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + }); + + it("taking the '+ new' default runs in the repo with the create toggle on", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(MAESTRO.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + expect(toggle(form).checked).toBe(true); + expect(form.querySelector(".wt-repo")!.textContent).toContain(MAESTRO.path); + // The scan already knows the repo — no probe. + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + }); + + it("picking an existing worktree runs in it with the create toggle off (#91)", async () => { + const form = await mount([MAESTRO, WT_A, WT_B]); + box(form, MAESTRO.path).click(); + await drain(); + + const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!; + existing.click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(false); + expect(toggle(form).checked).toBe(false); + expect(form.querySelector(".wt-body")!.hidden).toBe(true); + expect(existing.classList.contains("is-active")).toBe(true); + // Still no probe: the scan reported this worktree's repo membership. + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + // The project box stays marked — it is what the worktree list belongs to. + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + }); + + it("switching back to '+ new' re-enables the create toggle", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + choices(form) + .find((c) => c.dataset.cwd === WT_A.path)! + .click(); + await drain(); + + choices(form) + .find((c) => c.dataset.kind === "new")! + .click(); + await drain(); + + expect(changes.at(-1)!.cwd).toBe(MAESTRO.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + expect(toggle(form).checked).toBe(true); + }); + + it("repopulates and resets to '+ new' when another project is picked", async () => { + const form = await mount([MAESTRO, WT_A, OTHER]); + box(form, MAESTRO.path).click(); + await drain(); + choices(form) + .find((c) => c.dataset.cwd === WT_A.path)! + .click(); + await drain(); + + box(form, OTHER.path).click(); + await drain(); + + expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]); + expect(activeChoice(form)!.dataset.kind).toBe("new"); + expect(changes.at(-1)!.cwd).toBe(OTHER.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(true); + }); + + it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => { + // No main checkout in the scan, so the orphan worktree is its own box. + const form = await mount([WT_A]); + box(form, WT_A.path).click(); + await drain(); + + expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.cwd).toBe(WT_A.path); + expect(changes.at(-1)!.worktreeEnabled).toBe(false); + expect(toggle(form).checked).toBe(false); + expect(form.querySelector(".wt-body")!.hidden).toBe(true); + }); + + it("hides the worktree step again when the operator takes the raw-input hatch", async () => { + const form = await mount([MAESTRO, WT_A]); + box(form, MAESTRO.path).click(); + await drain(); + expect(step(form).hidden).toBe(false); + + form.querySelector(".project-other")!.click(); + await drain(); + + expect(step(form).hidden).toBe(true); + expect(choices(form)).toEqual([]); + expect(form.querySelector(".project-box.is-active")).toBeNull(); + }); + + it("restores the picked worktree from a draft seed", async () => { + urls = stubFetch([MAESTRO, WT_A, WT_B]); + handle = createSpawnForm({ + onSuccess() {}, + seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false }, + }); + document.body.appendChild(handle.el); + await drain(); + const form = handle.el; + + expect(step(form).hidden).toBe(false); + expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path); + expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true); + expect(toggle(form).checked).toBe(false); + }); +});