790 lines
29 KiB
TypeScript
790 lines
29 KiB
TypeScript
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;
|
|
/** 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<string, unknown> = {};
|
|
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 <form> 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 = `
|
|
<div class="field">
|
|
<span class="field-label">Command</span>
|
|
<div class="tile-row cmd-tiles"></div>
|
|
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
|
|
placeholder="bash -l" autocomplete="off" />
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Working directory</span>
|
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
|
autocomplete="off" />
|
|
<div class="picker projects"></div>
|
|
<div class="raw-wrap cwd-raw-wrap" hidden>
|
|
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
|
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<span class="field-label">Name</span>
|
|
<div class="name-row">
|
|
<input name="name" type="text" autocomplete="off" />
|
|
<button type="button" class="btn btn-icon reroll" aria-label="Generate a new name" title="New name">↻</button>
|
|
</div>
|
|
</div>
|
|
<fieldset class="worktree">
|
|
<legend>
|
|
<label class="row-inline">
|
|
<input type="checkbox" name="worktree_enabled" />
|
|
Create git worktree
|
|
</label>
|
|
</legend>
|
|
<p class="wt-hint" hidden></p>
|
|
<div class="wt-body" hidden>
|
|
<p class="wt-repo"></p>
|
|
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
|
<label class="row-inline">
|
|
<input type="radio" name="worktree_branch_mode" value="new" />
|
|
New branch
|
|
</label>
|
|
<label class="row-inline">
|
|
<input type="radio" name="worktree_branch_mode" value="existing" />
|
|
Existing branch
|
|
</label>
|
|
</div>
|
|
<label class="wt-new-wrap">
|
|
Branch
|
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
</label>
|
|
<label class="wt-exist-wrap" hidden>
|
|
Branch
|
|
<select class="branch-select"></select>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
<p class="form-error" role="alert" aria-live="polite"></p>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-primary">Spawn</button>
|
|
</div>
|
|
`;
|
|
// 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<HTMLInputElement>("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<void> | null = null;
|
|
// Guards against a late batch clobbering something the operator already
|
|
// typed or rerolled.
|
|
let nameTouched = Boolean(prefill.name);
|
|
const refillNames = (): Promise<void> => {
|
|
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 = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
|
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 ---------------------------------------
|
|
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) 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 —
|
|
// 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 !== "");
|
|
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<void> {
|
|
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 = `
|
|
<span class="project-info">
|
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
|
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
|
</span>
|
|
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
|
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 = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
|
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 {
|
|
// 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<void> {
|
|
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<void> {
|
|
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", () => {
|
|
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(),
|
|
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();
|
|
},
|
|
};
|
|
}
|