Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/93-collapse-worktree
# Conflicts: # web/src/spawnForm.ts
This commit is contained in:
+161
-68
@@ -9,8 +9,8 @@ import {
|
||||
} 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";
|
||||
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). */
|
||||
@@ -130,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<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">
|
||||
@@ -192,8 +197,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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 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;
|
||||
@@ -430,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (first) selectPreset(first.id, false);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
// ---- 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;
|
||||
markActiveProject(null);
|
||||
clearProjectSelection();
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
@@ -452,63 +484,91 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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,
|
||||
);
|
||||
/** 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 selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
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;
|
||||
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;
|
||||
updateWtSummary();
|
||||
if (project.is_worktree) {
|
||||
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;
|
||||
} 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 !== "");
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
const query = filterInput.value;
|
||||
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
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.";
|
||||
recentsEl.appendChild(empty);
|
||||
projectsEl.appendChild(empty);
|
||||
}
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
projectsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
@@ -519,12 +579,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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
|
||||
// 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;
|
||||
markActiveProject(cwd);
|
||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
@@ -541,34 +602,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
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;
|
||||
/** 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 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 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 {
|
||||
|
||||
Reference in New Issue
Block a user