feat(maestro): worktree on by default with new-or-existing branch picker (#61)
- toggle defaults on for any target the project scan reports (repo or worktree); only the free-text escape hatch pays for /api/gitroot - branch mode control switches between creating a new branch (named after the session name, as before) and picking an existing one from GET /api/branches; branches held by another worktree stay visible but disabled, labelled with that worktree's path - emits create_branch: false on the wire for existing-mode picks - branch-list load follows the form's ready/destroyed discipline so a late response cannot corrupt a rebuilt draft
This commit is contained in:
+163
-27
@@ -1,10 +1,21 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.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 { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.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 reopen 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. */
|
||||
@@ -14,6 +25,7 @@ export interface SpawnFormSeed {
|
||||
name?: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
worktreeBranchMode?: WorktreeBranchMode;
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +37,8 @@ export interface SpawnFormValues {
|
||||
name: string;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
/** "new" creates the branch; "existing" checks out a picked one (#61). */
|
||||
worktreeBranchMode: WorktreeBranchMode;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
@@ -47,15 +61,21 @@ export function buildSpawnRequest(
|
||||
};
|
||||
|
||||
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: "Worktree requires a branch name." };
|
||||
if (!branch) {
|
||||
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
|
||||
}
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
branch,
|
||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||
// the wire field stays for older daemons.
|
||||
cleanup_on_kill: true,
|
||||
// Existing-mode picks must check out as-is; the daemon creates
|
||||
// anything else with `worktree add -b` (#56/#61).
|
||||
create_branch: mode !== "existing",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,10 +148,24 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<label>
|
||||
<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>
|
||||
@@ -153,7 +187,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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 ------------------------------------------------------
|
||||
@@ -162,11 +202,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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
|
||||
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
|
||||
@@ -176,6 +220,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// 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?.({
|
||||
@@ -183,7 +228,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchMode: branchMode,
|
||||
cwdRawVisible: !cwdRawWrap.hidden,
|
||||
});
|
||||
};
|
||||
@@ -191,10 +237,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// ---- 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.
|
||||
// own branch. Only in "new" mode: an existing-branch pick is not a name.
|
||||
let branchEdited = Boolean(prefill.worktreeBranch);
|
||||
const syncBranchToName = () => {
|
||||
if (!branchEdited) branchInput.value = nameInput.value;
|
||||
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
|
||||
@@ -357,6 +403,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
cwdRaw.addEventListener("input", () => {
|
||||
cwd = cwdRaw.value.trim();
|
||||
branchCache = null;
|
||||
emit();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
});
|
||||
@@ -374,11 +421,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd = project.path;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
emit();
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
}
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
@@ -432,7 +482,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
// Everything seeded: the form may publish from here on.
|
||||
ready = true;
|
||||
if (wtEnabled.checked) void probeGitRoot();
|
||||
resolveBaseRepo();
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -473,7 +523,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||
// The project scan already reports repo membership, so a picked project
|
||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||
// pays for a /api/gitroot probe.
|
||||
function applyProjectRepo(repo: string): void {
|
||||
inferredBaseRepo = repo;
|
||||
branchCache = null;
|
||||
wtHint.hidden = true;
|
||||
wtBody.hidden = false;
|
||||
wtRepo.textContent = `repo: ${repo}`;
|
||||
if (branchMode === "existing") void loadBranches();
|
||||
}
|
||||
|
||||
/** Decide how the worktree section describes the current target. Called
|
||||
* once the initial load has settled `cwd`. */
|
||||
function resolveBaseRepo(): void {
|
||||
if (!cwd) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Choose a working directory first.";
|
||||
return;
|
||||
}
|
||||
const project = discovered.find((p) => p.path === cwd);
|
||||
if (project) {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
return;
|
||||
}
|
||||
void probeGitRoot();
|
||||
}
|
||||
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
const token = ++probeToken;
|
||||
if (!cwd) {
|
||||
@@ -494,10 +574,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
if (token !== probeToken) return; // a newer probe superseded us
|
||||
if (root) {
|
||||
inferredBaseRepo = root;
|
||||
wtHint.hidden = true;
|
||||
wtBody.hidden = false;
|
||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
||||
applyProjectRepo(root);
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
@@ -510,10 +587,72 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 another worktree holds stays visible but unpickable — git
|
||||
// would refuse to check it out twice; the path says who holds it.
|
||||
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
|
||||
o.disabled = Boolean(b.worktree);
|
||||
o.title = b.worktree ?? "";
|
||||
branchSelect.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
for (const radio of modeRadios) {
|
||||
radio.addEventListener("change", () => {
|
||||
if (radio.checked) setBranchMode(radio.value as WorktreeBranchMode);
|
||||
});
|
||||
}
|
||||
branchSelect.addEventListener("change", () => emit());
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
emit();
|
||||
if (wtEnabled.checked) {
|
||||
probeGitRoot();
|
||||
resolveBaseRepo();
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
@@ -521,15 +660,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
}
|
||||
});
|
||||
|
||||
// A seeded worktree restores its checkbox/body up front; its gitroot probe
|
||||
// waits for loadRecents to apply the seeded cwd.
|
||||
if (prefill.worktreeEnabled || prefill.worktreeBranch) {
|
||||
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
wtBody.hidden = !wtEnabled.checked;
|
||||
wtHint.hidden = true;
|
||||
emit();
|
||||
}
|
||||
// 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.
|
||||
@@ -548,8 +683,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchInput.value,
|
||||
},
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchMode: branchMode,
|
||||
},
|
||||
inferredBaseRepo,
|
||||
);
|
||||
if ("error" in built) {
|
||||
|
||||
Reference in New Issue
Block a user