77 lines
2.9 KiB
TypeScript
77 lines
2.9 KiB
TypeScript
// 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),
|
|
);
|
|
}
|