feat(maestro): project list sort/group/filter helpers (#59)
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user