948 lines
35 KiB
TypeScript
948 lines
35 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 { ProjectGroup } from "./projectList.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<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">Project</span>
|
|
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
|
autocomplete="off" />
|
|
<div class="picker projects"></div>
|
|
<button type="button" class="project-other">Other directory…</button>
|
|
<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 wt-step" hidden>
|
|
<span class="field-label">Worktree</span>
|
|
<div class="picker wt-choices"></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">
|
|
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
|
<span class="chevron">▾</span>
|
|
<span class="folder-name">Advanced options</span>
|
|
<span class="wt-summary"></span>
|
|
</button>
|
|
<div class="wt-collapsible" hidden>
|
|
<label class="row-inline">
|
|
<input type="checkbox" name="worktree_enabled" />
|
|
Create git worktree
|
|
</label>
|
|
<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">
|
|
New branch name
|
|
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
|
</label>
|
|
<label class="wt-exist-wrap" hidden>
|
|
Pick a branch
|
|
<select class="branch-select"></select>
|
|
</label>
|
|
</div>
|
|
</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 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<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 (#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;
|
|
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();
|
|
// 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);
|
|
}
|
|
|
|
/** 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<void> {
|
|
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.
|
|
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
|
} 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 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 = `
|
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
|
<span class="project-path">${escapeHtml(project.path)}</span>
|
|
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
|
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 = `
|
|
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
|
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
|
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<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;
|
|
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<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", () => {
|
|
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();
|
|
},
|
|
};
|
|
}
|