diff --git a/web/src/api.ts b/web/src/api.ts index 89cec3f..b1cd03d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -9,6 +9,7 @@ import type { NameBatch, Projects, RecentDirs, + RetainedWorktree, Session, Uuid, } from "./types.js"; @@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise { return jsonOrError(await fetch(url, { credentials: "same-origin" })); } +/** The daemon's retained worktrees (#80): trees kept after a session ended + * with unsaved work, with what is currently in the way. Drives the + * retained-worktrees view (#84). */ +export async function listWorktrees(): Promise { + return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" })); +} + +/** Reclaim a retained worktree: removes its directory and drops the store + * entry. Never deletes the branch (docs/WORKTREES.md). */ +export async function reclaimWorktree(id: Uuid): Promise { + await jsonOrError( + await fetch(routes.worktree(id), { + method: "DELETE", + credentials: "same-origin", + }), + ); +} + export { eventsWsUrl } from "./routes.js"; diff --git a/web/src/routes.ts b/web/src/routes.ts index 812160a..299812c 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; export const NAMES = "/api/names"; export const BRANCHES = "/api/branches"; +export const WORKTREES = "/api/worktrees"; export const EVENTS = "/api/events"; /** Name of the query parameter `DIRS` and `GITROOT` both take. */ @@ -29,6 +30,11 @@ export function attach(id: Uuid): string { return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; } +/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */ +export function worktree(id: Uuid): string { + return `${WORKTREES}/${encodeURIComponent(id)}`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; diff --git a/web/src/types.ts b/web/src/types.ts index 11991c3..10a8e43 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -133,6 +133,54 @@ export interface BranchInfo { /** `GET /api/branches?path=` — the local branches of one repository (#55). */ export type Branches = BranchInfo[]; +/** Why a retained worktree outlived its session (#80). New values are + * additive; unknown reasons render generically. */ +export type RetainedReason = "force-killed" | "exited-with-unsaved-work"; + +/** What currently blocks an unforced kill of a worktree's session (#80), + * recomputed by the daemon on every read. All counts default to 0 on the + * wire for older daemons. */ +export interface InTheWay { + uncommitted_files?: number; + untracked_files?: number; + unpushed_commits?: number; +} + +/** One entry of `GET /api/worktrees` (#80): a retained worktree with its + * in-the-way state as of this read. Reclaiming it removes the directory but + * never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */ +export interface RetainedWorktree { + id: Uuid; + path: string; + base_repo: string; + branch: string; + reason: RetainedReason; + retained_at: IsoTimestamp; + in_the_way?: InTheWay; +} + +/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by + * the kill refusal, the TUI and this client, so no two clients can describe + * the same tree differently. Zero parts are omitted; all-zero renders + * "no unsaved work". */ +export function formatInTheWay(w: InTheWay): string { + const parts: string[] = []; + const push = (n: number | undefined, unit: string): void => { + if (!n || n <= 0) return; + parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); + }; + push(w.uncommitted_files, "uncommitted file"); + push(w.untracked_files, "untracked file"); + push(w.unpushed_commits, "unpushed commit"); + return parts.length > 0 ? parts.join(", ") : "no unsaved work"; +} + +/** Same reason labels the CLI table and the TUI overlay use; unknown future + * reasons render their raw wire value. */ +export function reasonLabel(reason: RetainedReason): string { + return reason; +} + export interface CreateSessionRequest { cwd: string; command: string;