import { escapeHtml } from "./html.js"; import { createSession, fetchBranches, fetchNames, getDaemonInfo, projects as fetchProjects, gitRoot, } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; import { parentDir } from "./paths.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import type { ProjectGroup } from "./projectList.js"; import { abbreviateCwd } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } 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; /** Whether the operator typed the branch themselves, rather than it having * been auto-synced from the session name. Carried separately because the * value alone cannot distinguish the two. */ worktreeBranchEdited?: boolean; 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; worktreeBranchEdited: boolean; /** "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, // 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: "" }, ]; /** 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 { 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
Project
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 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; 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 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(), worktreeBranchEdited: branchEdited, 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. // True only once the operator types their own branch. It cannot be inferred // from the seed: in "new" mode `emit()` publishes the auto-synced session // name, so seeding from it froze the branch after any form rebuild // (#67 review). `worktreeBranchEdited` is carried explicitly instead. let branchEdited = Boolean(prefill.worktreeBranchEdited); 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(); } /** 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) { const tile = document.createElement("button"); tile.type = "button"; tile.className = "tile"; tile.dataset.id = p.id; tile.innerHTML = `${escapeHtml(p.label)}`; 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(); } }); // 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(); if (commandTouched) { reapplyActiveTile(); } else { 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 (#95) ---------------------------------- // Two steps, and the TUI mirror (#96) must match these rules verbatim: // 1. Step one is one box per repository (name, then its parent directory). // 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. // 7. Neither step touches the command: the directory and what runs in it // are independent choices. /** "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; clearProjectSelection(); 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) scheduleGitRootProbe(); }); /** 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 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; 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; } emit(); } /** 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 { projectsEl.replaceChildren(); groups = groupProjects(sortProjects(discovered, new Date())); const visible = visibleGroups(filterInput.value); 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."; projectsEl.appendChild(empty); } markActiveBox(); } filterInput.addEventListener("input", renderProjects); otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir)); async function loadProjects(): Promise { projectsEl.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 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; // Restoring must not touch the worktree toggle — the seed carries it. // A path no box matches (the project is gone, or the scan never saw // it) falls back to the raw input showing that path: an empty picker // with an invisible cwd reads as a form that lost the directory. if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false); } 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(); } /** 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 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 parent directory only — the last segment is the name line above it. // Unabbreviated beyond `~`, since the stem is what tells two projects // sharing a basename apart (#95). const parent = abbreviateCwd(parentDir(project.path), homeDir); const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : ""; box.innerHTML = ` ${escapeHtml(project.name)} ${escapeHtml(parent)} ${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 { const p = document.createElement("p"); p.className = "picker-loading"; p.textContent = "Loading projects…"; 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) -- // 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 { // Nothing to resolve when the operator turned the worktree off — the // section would otherwise rebuild expanded under an unchecked box, and // fire a `/api/gitroot` probe for a worktree nobody asked for // (#67 review). if (!wtEnabled.checked) { wtBody.hidden = true; wtHint.hidden = true; inferredBaseRepo = null; return; } 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; } 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 { 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; updateWtSummary(); 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 that is already checked out anywhere stays visible but // unpickable — git refuses a second checkout. That includes the main // checkout's own HEAD, which is commonly `main` and would otherwise be // the default pick, making every existing-branch spawn fail (#54 review). const held = b.worktree ?? (b.is_head ? "the main checkout" : null); const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name); o.disabled = held !== null; o.title = held ?? ""; 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", () => { updateWtSummary(); 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; updateWtSummary(); 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); // ---- 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 ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); errEl.textContent = ""; const built = buildSpawnRequest( { command, cwd, name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchValue(), worktreeBranchEdited: branchEdited, 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; window.clearTimeout(probeTimer); autocomplete.destroy(); form.remove(); }, }; }