The picker now leads the form in both clients and takes focus on open, so Tab walks from it into the command tiles. Opening the form also preselects the project of the session you were last on — a worktree session seeds its base repository — so 'n' then Enter spawns another session there.
953 lines
36 KiB
TypeScript
953 lines
36 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 { 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 "Spawn similar" to open the modal
|
|
* with the same cwd, command, and name as an existing session, and by the
|
|
* fast-forward fallback to pre-fill whatever it did resolve. */
|
|
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. */
|
|
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;
|
|
/** 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 inside the spawn modal (spawnModal.ts). */
|
|
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|
const prefill = opts.seed ?? {};
|
|
|
|
const form = document.createElement("form");
|
|
form.className = "spawn-form";
|
|
form.innerHTML = `
|
|
<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">
|
|
<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">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>
|
|
<div class="field wt-step" hidden>
|
|
<span class="field-label">Worktree</span>
|
|
<div class="picker wt-choices"></div>
|
|
</div>
|
|
<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 rather than by id, so a
|
|
// second form mounted before the first is torn down cannot collide with it.
|
|
|
|
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
|
|
// report blank values as if the operator had cleared the form.
|
|
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): the form 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 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<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.
|
|
// 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 = `
|
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
|
<span class="project-path">${escapeHtml(parent)}</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 closed modal would throw. */
|
|
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) {
|
|
// Resolve the filter to a project only when the current pick isn't one
|
|
// of the matches: a filter typed past it means the operator moved on,
|
|
// while a pick that still matches (including the seeded one the form
|
|
// opens with) keeps its worktree choice.
|
|
const visible = visibleGroups(filterInput.value);
|
|
const stillMatches =
|
|
selectedGroup != null && visible.some((g) => g.main.path === selectedGroup?.main.path);
|
|
if (!stillMatches && visible[0]) selectProject(visible[0]);
|
|
}
|
|
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();
|
|
},
|
|
};
|
|
}
|