feat(maestro): project list sort/group/filter helpers (#59)

This commit is contained in:
Miguel Palhas
2026-08-23 21:02:16 +01:00
parent b5cbf94fa8
commit 9f582263b8
2 changed files with 202 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { Project } from "./types.js";
const NOW = new Date("2026-08-23T12:00:00Z");
function project(overrides: Partial<Project>): Project {
return {
path: `/home/user/${overrides.name ?? "proj"}`,
name: "proj",
is_worktree: false,
last_activity: undefined,
last_command: undefined,
...overrides,
} as Project;
}
describe("isStale", () => {
it("marks projects with no known activity stale", () => {
expect(isStale(project({ name: "never" }), NOW)).toBe(true);
});
it("is fresh within 7 days and stale beyond", () => {
const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" });
const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" });
expect(isStale(fresh, NOW)).toBe(false);
expect(isStale(stale, NOW)).toBe(true);
});
});
describe("sortProjects", () => {
it("sorts newest-activity first", () => {
const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" });
const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" });
expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]);
});
it("sorts stale rows after fresh ones, each block newest first", () => {
const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" });
const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" });
const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" });
const staleNone = project({ name: "stale-none" });
const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW);
expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]);
});
it("does not mutate the input", () => {
const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" });
const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" });
sortProjects([a, b], NOW);
expect(a.name).toBe("a");
});
});
describe("groupProjects", () => {
it("keeps plain repositories as their own single-entry group", () => {
const solo = project({ name: "solo" });
expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]);
});
it("nests worktrees under their base repository even when fresher than it", () => {
const main = project({ name: "maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
is_worktree: true,
base_repo: main.path,
last_activity: "2026-08-23T00:00:00Z",
});
const groups = groupProjects(sortProjects([wt, main], NOW));
expect(groups).toHaveLength(1);
expect(groups[0].main).toBe(main);
expect(groups[0].worktrees).toEqual([wt]);
});
it("orders groups by the freshest member and worktrees by activity", () => {
const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" });
const wtA = project({
name: "a-wt",
path: "/w/a-wt",
is_worktree: true,
base_repo: "/w/a",
last_activity: "2026-08-20T00:00:00Z",
});
const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" });
const wtB1 = project({
name: "b-wt1",
path: "/w/b-wt1",
is_worktree: true,
base_repo: "/w/b",
last_activity: "2026-08-19T00:00:00Z",
});
const wtB2 = project({
name: "b-wt2",
path: "/w/b-wt2",
is_worktree: true,
base_repo: "/w/b",
last_activity: "2026-08-21T00:00:00Z",
});
const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW));
expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]);
const gA = groups.find((g) => g.main.name === "a")!;
const gB = groups.find((g) => g.main.name === "b")!;
expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]);
expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]);
});
});
describe("filterProjects", () => {
const maestro = project({ name: "maestro", path: "/home/u/git/maestro" });
const tea = project({ name: "tea", path: "/home/u/work/tea" });
it("matches on name case-insensitively", () => {
expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]);
});
it("matches on the full path", () => {
expect(filterProjects([maestro, tea], "work")).toEqual([tea]);
});
it("returns everything unchanged for an empty query", () => {
expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]);
});
});
+76
View File
@@ -0,0 +1,76 @@
// Pure helpers behind the spawn form's project picker (#59): staleness,
// recency ordering, worktree grouping, and free-text filtering. The daemon
// reports the timestamps; what "stale" means is a client decision (#54).
import type { Project } from "./types.js";
/** Projects whose last activity is older than this are dimmed and sort after
* fresh ones — still reachable, visibly stale (#59). */
export const STALE_AFTER_DAYS = 7;
export function isStale(project: Project, now: Date): boolean {
if (!project.last_activity) return true;
const age = now.getTime() - Date.parse(project.last_activity);
return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
}
/** Fresh rows first, each block newest-activity-first; unknown or future
* timestamps sort as stalest. Ties fall back to name so the order is stable. */
export function sortProjects(projects: Project[], now: Date): Project[] {
return [...projects].sort((a, b) => {
const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0);
if (staleDiff !== 0) return staleDiff;
const ta = timeOf(a);
const tb = timeOf(b);
if (ta !== tb) return tb - ta;
return a.name.localeCompare(b.name) || a.path.localeCompare(b.path);
});
}
function timeOf(project: Project): number {
return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY;
}
/** One repository group: the main checkout followed by its linked worktrees.
* A group's position follows its freshest member, since a worktree worked on
* today should pull its (dormant) base repo up with it. */
export interface ProjectGroup {
main: Project;
worktrees: Project[];
}
/** Group `sorted` projects by their base repository. A worktree nests under
* its main checkout even when the main sorts later; a main checkout missing
* from the list doesn't drop its worktrees. */
export function groupProjects(sorted: Project[]): ProjectGroup[] {
const members = new Map<string, Project[]>();
const keys: string[] = [];
for (const p of sorted) {
const key = p.base_repo ?? p.path;
let list = members.get(key);
if (!list) {
list = [];
members.set(key, list);
keys.push(key);
}
list.push(p);
}
return keys.map((key) => {
const list = members.get(key)!;
const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0];
// Keep the daemon's newest-first order within the group.
const worktrees = list
.filter((p) => p !== main)
.sort((a, b) => timeOf(b) - timeOf(a));
return { main, worktrees };
});
}
/** Substring filter over project name and full path, case-insensitive. */
export function filterProjects(projects: Project[], query: string): Project[] {
const q = query.trim().toLowerCase();
if (!q) return [...projects];
return projects.filter(
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
);
}