import { escapeHtml } from "./html.js"; import { createSession, fetchBranches, fetchNames, getDaemonInfo, projects as fetchProjects, gitRoot, } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; import { filterProjects, groupProjects, isStale, sortProjects } 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). */ export type WorktreeBranchMode = "new" | "existing"; /** Seed values for the spawn form — used by "clone" to open a draft with the * same cwd, command, and name as an existing session, and by draft panes to * restore their own half-filled state after a re-render. */ export interface SpawnFormSeed { command?: string; cwd?: string; name?: string; worktreeEnabled?: boolean; worktreeBranch?: string; worktreeBranchMode?: WorktreeBranchMode; cwdRawVisible?: boolean; } /** The form's current values, reported on every change so a caller (the draft * pane) can hang them off its layout leaf. */ export interface SpawnFormValues { command: string; cwd: string; name: string; worktreeEnabled: boolean; worktreeBranch: string; /** "new" creates the branch; "existing" checks out a picked one (#61). */ worktreeBranchMode: WorktreeBranchMode; /** Whether the cwd lives in the raw text input (vs a picked recent row). */ cwdRawVisible?: boolean; } export function buildSpawnRequest( values: SpawnFormValues, baseRepo: string | null, ): { req: CreateSessionRequest } | { error: string } { if (!values.cwd) return { error: "Pick a working directory." }; if (!values.command) return { error: "Pick a command (or fill the custom command)." }; const metadata: Record = {}; const name = values.name.trim(); if (name) metadata.name = name; const req: CreateSessionRequest = { cwd: values.cwd, command: values.command, metadata, }; if (values.worktreeEnabled) { const mode = values.worktreeBranchMode ?? "new"; const branch = values.worktreeBranch.trim(); if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." }; if (!branch) { return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." }; } req.worktree = { base_repo: baseRepo, branch, // The daemon always cleans up, guarded against unsaved work (#56); // the wire field stays for older daemons. cleanup_on_kill: true, // Existing-mode picks must check out as-is; the daemon creates // anything else with `worktree add -b` (#56/#61). create_branch: mode !== "existing", }; } return { req }; } /** Built-ins used when the daemon's `/api/info` presets can't be fetched * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. * Exported for the fast-forward spawn's preset fallback (#67). */ export const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, { id: "zsh", label: "Shell", command: "zsh" }, { id: "custom", label: "Custom…", command: "" }, ]; export interface SpawnFormHandle { el: HTMLFormElement; /** Remove global listeners (dir autocomplete) — call when unmounting. */ destroy(): void; } export interface SpawnFormOptions { seed?: SpawnFormSeed; /** Extra class(es) for the
root (e.g. `mv-draft` in a draft pane). */ class?: string; /** Fired whenever any value changes (input, tile pick, checkbox). */ onChange?: (values: SpawnFormValues) => void; /** Called with the created session after a successful submit. */ onSuccess: (session: Session) => void; } /** The spawn form itself — preset tiles, project picker, name, worktree. * Mounted inline by draft panes (issue #57). */ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const prefill = opts.seed ?? {}; const form = document.createElement("form"); form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`; form.innerHTML = `
Command
Working directory
Name
`; // Field queries are scoped to this form instance — several forms can be // mounted at once (one per draft pane), so ids would collide. 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 filterInput = form.querySelector(".project-filter") as HTMLInputElement; 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; const wtHint = form.querySelector(".wt-hint") as HTMLElement; const wtBody = form.querySelector(".wt-body") as HTMLElement; const wtRepo = form.querySelector(".wt-repo") as HTMLElement; const modeRadios = [ ...form.querySelectorAll("input[name=worktree_branch_mode]"), ]; const newWrap = form.querySelector(".wt-new-wrap") as HTMLElement; const existWrap = form.querySelector(".wt-exist-wrap") as HTMLElement; const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement; const branchSelect = form.querySelector(".branch-select") as HTMLSelectElement; const nameInput = form.querySelector("input[name=name]") as HTMLInputElement; // ---- form state ------------------------------------------------------ let homeDir = ""; let presets: SpawnPreset[] = FALLBACK_PRESETS; let selectedPresetId = ""; let command = ""; // resolved command (preset's, or the raw custom input) let cwd = ""; // chosen working directory let inferredBaseRepo: string | null = null; // from the gitroot probe or the project scan let probeToken = 0; // guards async gitroot probes against races // A seed's cwd, applied once the (async) projects load for the initial host // settles — so the fetch can't race past and clobber the seeded directory. let seedCwd: string | null = prefill.cwd ?? null; let branchMode: WorktreeBranchMode = prefill.worktreeBranchMode ?? "new"; let branchToken = 0; // guards async branch-list loads against races // Branch list for the current cwd — refetched whenever the target changes. let branchCache: { cwd: string; branches: BranchInfo[] } | null = null; // `emit` stays silent until the initial load settles and goes silent again // once the form is destroyed. Construction runs with `cwd === ""` and the // seed is only restored two fetches later, so emitting in between would // blank the leaf's draft; and `multiview.render()` rebuilds forms while the // old one's fetches are still in flight, so a destroyed form emitting would // clobber the live one's leaf (#57 review). let ready = false; let destroyed = false; const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value); const emit = () => { if (!ready || destroyed) return; opts.onChange?.({ command, cwd, name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchValue(), worktreeBranchMode: branchMode, cwdRawVisible: !cwdRawWrap.hidden, }); }; // ---- name (also seeds the worktree branch) --------------------------- // The worktree branch defaults to the session name — including a clone's // prefilled name — and tracks it on reroll/edit until the user types their // own branch. Only in "new" mode: an existing-branch pick is not a name. let branchEdited = Boolean(prefill.worktreeBranch); const syncBranchToName = () => { if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value; }; // Handles come from `GET /api/names` in batches (#53): the first one seeds // the field, rerolls consume the rest locally so they stay instant, and a // new batch is fetched when (and only when) the queue runs dry. One refill // at a time keeps rapid rerolls from storming the daemon. const NAME_BATCH_SIZE = 20; let nameQueue: string[] = []; let nameFetch: Promise | null = null; // Guards against a late batch clobbering something the operator already // typed or rerolled. let nameTouched = Boolean(prefill.name); const refillNames = (): Promise => { if (!nameFetch) { nameFetch = fetchNames(NAME_BATCH_SIZE) .then((batch) => { if (!destroyed) nameQueue.push(...batch.names); }) .catch(() => { /* reroll just stays on the current name */ }) .finally(() => { nameFetch = null; }); } return nameFetch; }; nameInput.value = prefill.name ?? ""; if (!nameTouched) { void refillNames().then(() => { if (destroyed || nameTouched || nameInput.value) return; const next = nameQueue.shift(); if (next !== undefined) { nameInput.value = next; syncBranchToName(); emit(); } }); } nameInput.addEventListener("input", () => { nameTouched = true; syncBranchToName(); emit(); }); branchInput.addEventListener("input", () => { branchEdited = true; emit(); }); (form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => { nameTouched = true; const apply = () => { if (destroyed) return; const next = nameQueue.shift(); if (next === undefined) return; nameInput.value = next; syncBranchToName(); emit(); }; // Only hit the daemon when the local batch is exhausted. if (nameQueue.length === 0) { void refillNames().then(apply); } else { apply(); } }); syncBranchToName(); // seed the branch from the initial name // ---- command tiles --------------------------------------------------- // `focus` is false for the two initial applications (construction, and again // when `/api/info` resolves): a draft pane must not grab the caret out from // under the operator seconds after it mounts. function selectPreset(id: string, focus = true): void { selectedPresetId = id; const preset = presets.find((p) => p.id === id); const isCustom = !preset || preset.command === ""; cmdRaw.hidden = !isCustom; if (isCustom) { command = cmdRaw.value.trim(); if (focus) cmdRaw.focus(); } else { command = preset!.command; } for (const tile of cmdTiles.querySelectorAll(".tile")) { tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); } emit(); } function renderCommandTiles(): void { cmdTiles.replaceChildren(); for (const p of presets) { const tile = document.createElement("button"); tile.type = "button"; tile.className = "tile"; tile.dataset.id = p.id; tile.innerHTML = `${escapeHtml(p.label)}`; tile.addEventListener("click", () => selectPreset(p.id)); cmdTiles.appendChild(tile); } } cmdRaw.addEventListener("input", () => { if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { command = cmdRaw.value.trim(); emit(); } }); // Presets ride `/api/info`; render fallbacks immediately, then refine. renderCommandTiles(); applyInitialCommand(); const infoLoad = getDaemonInfo() .then((info) => { homeDir = info.home_dir ?? ""; if (info.presets?.length) { presets = info.presets; renderCommandTiles(); applyInitialCommand(); } }) .catch(() => { /* keep fallbacks */ }); // Pick a sensible starting command: a seed's command (matched to a preset or // dropped into custom), else the first non-custom preset. function applyInitialCommand(): void { const wanted = prefill.command; if (wanted) { const match = presets.find((p) => p.command === wanted && p.command !== ""); if (match) { selectPreset(match.id, false); } else { const custom = presets.find((p) => p.command === ""); cmdRaw.value = wanted; selectPreset(custom ? custom.id : "custom", false); command = wanted; } return; } const first = presets.find((p) => p.command !== ""); if (first) selectPreset(first.id, false); } // ---- working-directory picker --------------------------------------- function showRawCwd(prefillValue?: string, focus = true): void { cwdRawWrap.hidden = false; if (prefillValue != null) cwd = prefillValue; cwdRaw.value = cwd; markActiveProject(null); if (focus) { cwdRaw.focus(); cwdRaw.select(); } // Both the visibility and the directory are draft state: without this a // render before the operator types snaps back to the project picker. emit(); } cwdRaw.addEventListener("input", () => { cwd = cwdRaw.value.trim(); branchCache = null; emit(); if (wtEnabled.checked) probeGitRoot(); }); 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, ); } } function selectProject(project: Project): void { cwd = project.path; 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); 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 !== ""); 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) { const empty = document.createElement("p"); empty.className = "picker-empty"; empty.textContent = "No projects match."; recentsEl.appendChild(empty); } recentsEl.appendChild(otherDirRow()); markActiveProject(cwdRawWrap.hidden ? cwd : null); } filterInput.addEventListener("input", renderProjects); async function loadProjects(): Promise { recentsEl.replaceChildren(loadingRow()); try { discovered = await fetchProjects(); } catch { discovered = []; } if (destroyed) return; renderProjects(); 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 // focused in the raw input (clone / no-match flows). if (prefill.cwdRawVisible === false) { cwd = seedCwd; cwdRaw.value = seedCwd; markActiveProject(cwd); } else { showRawCwd(seedCwd, false); } seedCwd = null; } else if (discovered.length === 0) { // Nothing discovered yet: default to raw input prefilled with home. showRawCwd(homeDir, false); } else { cwdRawWrap.hidden = true; } // Everything seeded: the form may publish from here on. ready = true; resolveBaseRepo(); 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; } 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 loadingRow(): HTMLElement { const p = document.createElement("p"); p.className = "picker-loading"; p.textContent = "Loading projects…"; return p; } // ---- worktree (branch-only; base_repo inferred via scan or /gitroot) -- // The project scan already reports repo membership, so a picked project // resolves its base repo locally (#61); only the free-text escape hatch // pays for a /api/gitroot probe. function applyProjectRepo(repo: string): void { inferredBaseRepo = repo; branchCache = null; wtHint.hidden = true; wtBody.hidden = false; wtRepo.textContent = `repo: ${repo}`; if (branchMode === "existing") void loadBranches(); } /** Decide how the worktree section describes the current target. Called * once the initial load has settled `cwd`. */ function resolveBaseRepo(): void { if (!cwd) { inferredBaseRepo = null; wtBody.hidden = true; wtHint.hidden = false; wtHint.textContent = "Choose a working directory first."; return; } const project = discovered.find((p) => p.path === cwd); if (project) { applyProjectRepo(project.base_repo ?? project.path); return; } void probeGitRoot(); } async function probeGitRoot(): Promise { const token = ++probeToken; if (!cwd) { inferredBaseRepo = null; wtBody.hidden = true; wtHint.hidden = false; wtHint.textContent = "Choose a working directory first."; return; } wtHint.hidden = false; wtHint.textContent = "Checking for a git repository…"; wtBody.hidden = true; let root: string | undefined; try { root = (await gitRoot(cwd)).root; } catch { root = undefined; } if (token !== probeToken) return; // a newer probe superseded us if (root) { applyProjectRepo(root); } else { inferredBaseRepo = null; // Publish the untick too, or the next render seeds the box checked with // no inferred repo and submitting fails. wtEnabled.checked = false; emit(); wtBody.hidden = true; wtHint.hidden = false; wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree."; } } // ---- existing-branch picker (#61) ------------------------------------- function setBranchMode(mode: WorktreeBranchMode): void { branchMode = mode; for (const radio of modeRadios) radio.checked = radio.value === mode; newWrap.hidden = mode !== "new"; existWrap.hidden = mode !== "existing"; if (mode === "existing") void loadBranches(); emit(); } /** Fetch the target repo's branches once per cwd. Like every async load in * this form: token-guarded, and nothing touches the DOM (or emits) after * destroy() — a late response into a rebuilt form would corrupt that * form's draft (#57 review). */ async function loadBranches(): Promise { if (!cwd || !inferredBaseRepo) return; const token = ++branchToken; if (branchCache?.cwd !== cwd) { const target = cwd; let branches: BranchInfo[]; try { branches = await fetchBranches(target); } catch { return; // picker just stays empty; "new" mode still works } if (destroyed || token !== branchToken || cwd !== target) return; branchCache = { cwd: target, branches }; } renderBranchOptions(); } function renderBranchOptions(): void { if (destroyed) return; const branches = branchCache?.cwd === cwd ? branchCache.branches : []; branchSelect.replaceChildren(); const opt = (label: string, value: string) => { const o = document.createElement("option"); o.textContent = label; o.value = value; return o; }; if (branches.length === 0) { branchSelect.appendChild(opt("No local branches found.", "")); return; } for (const b of branches) { // A branch another worktree holds stays visible but unpickable — git // would refuse to check it out twice; the path says who holds it. const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name); o.disabled = Boolean(b.worktree); o.title = b.worktree ?? ""; branchSelect.appendChild(o); } } for (const radio of modeRadios) { radio.addEventListener("change", () => { if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode); }); } branchSelect.addEventListener("change", () => emit()); wtEnabled.addEventListener("change", () => { emit(); if (wtEnabled.checked) { resolveBaseRepo(); } else { inferredBaseRepo = null; wtBody.hidden = true; wtHint.hidden = true; } }); // The toggle defaults ON (#61): the operator almost always wants a // worktree, and every non-repo target unticks it via the gitroot probe. wtEnabled.checked = prefill.worktreeEnabled ?? true; if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch; setBranchMode(branchMode); // Seed ordering: the raw-cwd application races the async info load (which // provides homeDir for the projects' abbreviations), so projects wait for it. void infoLoad.then(() => loadProjects()); // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ const autocomplete = attachDirAutocomplete(cwdRaw); // ---- submit ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); errEl.textContent = ""; const built = buildSpawnRequest( { command, cwd, name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchValue(), worktreeBranchMode: branchMode, }, inferredBaseRepo, ); if ("error" in built) { errEl.textContent = built.error; return; } const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement; submitBtn.disabled = true; try { const session = await createSession(built.req); opts.onSuccess(session); } catch (err) { errEl.textContent = (err as Error).message; submitBtn.disabled = false; } }); return { el: form, destroy() { destroyed = true; autocomplete.destroy(); form.remove(); }, }; }