feat(maestro): project list replaces recents picker in spawn form (#59)
This commit is contained in:
+69
-44
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user