feat(maestro): project list replaces recents picker in spawn form (#59)

This commit is contained in:
Miguel Palhas
2026-08-23 21:06:10 +01:00
parent 9f582263b8
commit b9829bc56f
7 changed files with 336 additions and 65 deletions
+69 -44
View File
@@ -1,8 +1,9 @@
import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import { generateName } from "./names.js";
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Seed values for the spawn form — used by "clone" to reopen with the same
@@ -87,7 +88,7 @@ export interface SpawnFormOptions {
onSuccess: (session: Session) => void;
}
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
@@ -103,7 +104,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
<div class="field">
<span class="field-label">Working directory</span>
<div class="picker recents"></div>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
@@ -143,7 +146,8 @@ 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(".recents") as HTMLElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
@@ -161,7 +165,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
// A seed's cwd, applied once the (async) recents load for the initial host
// 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;
@@ -292,13 +296,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveRecent(null);
markActiveProject(null);
if (focus) {
cwdRaw.focus();
cwdRaw.select();
}
// Both the visibility and the directory are draft state: without this a
// render before the operator types snaps back to the recents picker.
// render before the operator types snaps back to the project picker.
emit();
}
@@ -308,8 +312,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wtEnabled.checked) probeGitRoot();
});
function markActiveRecent(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".recent-row")) {
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,
@@ -317,46 +321,62 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
}
function selectRecent(dir: RecentDir): void {
cwd = dir.cwd;
function selectProject(project: Project): void {
cwd = project.path;
cwdRawWrap.hidden = true;
markActiveRecent(dir.cwd);
markActiveProject(project.path);
emit();
// Picking a recent dir can pre-select the command it was last used with.
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
// 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();
}
async function loadRecents(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
function renderProjects(): void {
recentsEl.replaceChildren();
for (const dir of dirs) {
recentsEl.appendChild(recentRow(dir));
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
}
// Persistent escape hatch + empty-history fallback.
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
}
filterInput.addEventListener("input", renderProjects);
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
discovered = [];
}
if (destroyed) return;
renderProjects();
if (seedCwd) {
// A seed sets the directory verbatim, regardless of recorded history.
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked recent row: keep the picker and don't steal focus. Otherwise
// land focused in the raw input (clone / empty-history flows).
// picked row: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
markActiveProject(cwd);
} else {
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
} else if (discovered.length === 0) {
// Nothing discovered yet: default to raw input prefilled with home.
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
@@ -367,27 +387,32 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
function recentRow(dir: RecentDir): HTMLElement {
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
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(dir.cwd, homeDir));
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
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;
}
@@ -395,7 +420,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading recent directories…";
p.textContent = "Loading projects…";
return p;
}
@@ -458,8 +483,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
}
// Seed ordering: the raw-cwd application races the async info load (which
// provides homeDir for the recents' abbreviations), so recents wait for it.
void infoLoad.then(() => loadRecents());
// 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);