feat(maestro): project list replaces recents picker in spawn form (#59)
This commit is contained in:
+8
-2
@@ -5,6 +5,7 @@ import type {
|
||||
DaemonInfo,
|
||||
DirListing,
|
||||
GitRoot,
|
||||
Projects,
|
||||
RecentDirs,
|
||||
Session,
|
||||
Uuid,
|
||||
@@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise<DaemonInfo> {
|
||||
return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
|
||||
* spawn form's recents picker. */
|
||||
/** Recently-used working dirs, newest-first and deduped by dir (#26). */
|
||||
export async function recentDirs(): Promise<RecentDirs> {
|
||||
return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Every discovered git repository / worktree, newest-activity-first (#54).
|
||||
* Drives the spawn form's project picker. */
|
||||
export async function projects(): Promise<Projects> {
|
||||
return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
|
||||
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
|
||||
* it against its home dir. */
|
||||
|
||||
+35
-12
@@ -33,8 +33,8 @@ function sess(
|
||||
};
|
||||
}
|
||||
|
||||
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
|
||||
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
|
||||
/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the
|
||||
* full flow — presets, projects, gitroot — with no daemon running. Dev-only;
|
||||
* delegates every other request to the real `fetch`. */
|
||||
function stubSpawnApi(): void {
|
||||
const real = globalThis.fetch.bind(globalThis);
|
||||
@@ -61,16 +61,39 @@ function stubSpawnApi(): void {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.includes("/recent-dirs")) {
|
||||
if (path.includes("/projects")) {
|
||||
const now = Date.now();
|
||||
const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
|
||||
return Promise.resolve(
|
||||
json({
|
||||
dirs: [
|
||||
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
|
||||
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
|
||||
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
|
||||
],
|
||||
truncated: false,
|
||||
}),
|
||||
json([
|
||||
{
|
||||
path: "/home/dev/git/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: iso(0),
|
||||
last_command: "claude",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/worktrees/maestro-59",
|
||||
name: "maestro-59",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/dev/git/maestro",
|
||||
last_activity: iso(1),
|
||||
last_command: "opencode",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/git/dotfiles",
|
||||
name: "dotfiles",
|
||||
is_worktree: false,
|
||||
last_activity: iso(30),
|
||||
last_command: "zsh",
|
||||
},
|
||||
{
|
||||
path: "/home/dev/work/acme/api",
|
||||
name: "api",
|
||||
is_worktree: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (path.includes("/gitroot")) {
|
||||
@@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void {
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/recents/gitroot all render.
|
||||
// backend endpoints stubbed so presets/projects/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
void import("./spawn.js").then((m) => m.openSpawnModal());
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const NOW = "2026-08-23T12:00:00Z";
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: `/home/user/${overrides.name ?? "proj"}`,
|
||||
name: "proj",
|
||||
is_worktree: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Two fetches gate the form's initial load: /api/info then /api/projects. */
|
||||
function stubFetch(info: unknown, projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? info
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse(NOW) });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
||||
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
||||
handle = createSpawnForm({
|
||||
seed,
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
path: "/home/user/stale",
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
{
|
||||
hostname: "h",
|
||||
version: "0",
|
||||
home_dir: "/home/user",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
},
|
||||
[p],
|
||||
);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const form = (await mount([p])).el;
|
||||
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
||||
other.click();
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the raw input when no projects exist", async () => {
|
||||
const form = (await mount([])).el;
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
||||
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
||||
expect(raw.value).toBe("/home/user");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const SESSIONS = "/api/sessions";
|
||||
export const INFO = "/api/info";
|
||||
export const DIRS = "/api/dirs";
|
||||
export const RECENT_DIRS = "/api/recent-dirs";
|
||||
export const PROJECTS = "/api/projects";
|
||||
export const GITROOT = "/api/gitroot";
|
||||
export const EVENTS = "/api/events";
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
+40
-7
@@ -1448,7 +1448,8 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Recent-dirs picker rows. */
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1459,7 +1460,11 @@ textarea {
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
.project-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
@@ -1477,18 +1482,40 @@ textarea {
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
.project-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.recent-row.is-active {
|
||||
.project-row.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-14);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1501,12 +1528,18 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.recent-other .recent-dir {
|
||||
color: var(--muted);
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.picker-loading {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
|
||||
@@ -83,6 +83,27 @@ export interface GitRoot {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
/** One discovered git repository (or linked worktree) for `GET /api/projects`
|
||||
* (#54). Mirrors `maestro_protocol::Project`. */
|
||||
export interface Project {
|
||||
/** Absolute path of the repository/worktree toplevel. */
|
||||
path: string;
|
||||
/** Basename of `path`. */
|
||||
name: string;
|
||||
is_worktree?: boolean;
|
||||
/** The main checkout this worktree belongs to; absent on plain repos. */
|
||||
base_repo?: string;
|
||||
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||
* any directory inside it; absent when neither is known. */
|
||||
last_activity?: IsoTimestamp;
|
||||
/** Command last spawned in this project, from spawn history. */
|
||||
last_command?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/projects` — every discovered project, sorted by the daemon
|
||||
* newest-activity-first (#54). */
|
||||
export type Projects = Project[];
|
||||
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
|
||||
Reference in New Issue
Block a user