167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
// Retained-worktrees view (#84): every worktree the daemon is holding that no
|
|
// session uses, listed from `GET /api/worktrees` with a reclaim action.
|
|
//
|
|
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
|
|
// overlay: same reason labels, same in-the-way phrasing via
|
|
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
|
|
// as what it is — destructive — naming what will be lost before it happens
|
|
// and saying out loud that the branch survives (docs/WORKTREES.md).
|
|
|
|
import { escapeHtml } from "./html.js";
|
|
import {
|
|
abbreviateCwd,
|
|
abbreviateFolderName,
|
|
formatInTheWay,
|
|
reasonLabel,
|
|
type RetainedWorktree,
|
|
type Uuid,
|
|
} from "./types.js";
|
|
|
|
export interface WorktreesDeps {
|
|
/** `GET /api/worktrees`. */
|
|
load(): Promise<RetainedWorktree[]>;
|
|
/** `DELETE /api/worktrees/:id`. */
|
|
reclaim(id: Uuid): Promise<void>;
|
|
/** The daemon's home dir, for `~` abbreviation of paths. */
|
|
homeDir(): string | undefined;
|
|
}
|
|
|
|
export interface WorktreesViewHandle {
|
|
el: HTMLElement;
|
|
/** Re-fetch the store and re-render. Safe to call repeatedly. */
|
|
refresh(): Promise<void>;
|
|
dispose(): void;
|
|
}
|
|
|
|
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
|
|
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
|
|
return [...items].sort(
|
|
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
|
|
);
|
|
}
|
|
|
|
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
|
|
const el = document.createElement("section");
|
|
el.className = "worktrees";
|
|
el.innerHTML = `
|
|
<header class="wt-head">
|
|
<h2 class="wt-title">retained worktrees</h2>
|
|
<p class="wt-sub">
|
|
Kept when a session ended with unsaved work. Reclaiming removes the
|
|
directory only; branches are never deleted.
|
|
</p>
|
|
</header>
|
|
<div class="wt-list"></div>
|
|
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
|
|
`;
|
|
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
|
|
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
|
|
|
|
let pendingId: string | null = null;
|
|
|
|
const render = (items: RetainedWorktree[]) => {
|
|
listHost.replaceChildren();
|
|
if (items.length === 0) {
|
|
// A drained store is the normal state — say so plainly, not like
|
|
// something went wrong.
|
|
const empty = document.createElement("div");
|
|
empty.className = "empty";
|
|
empty.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">⌂</div>
|
|
<div class="empty-title">no retained worktrees</div>
|
|
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
|
|
listHost.appendChild(empty);
|
|
return;
|
|
}
|
|
const home = deps.homeDir();
|
|
for (const w of sorted(items)) {
|
|
listHost.appendChild(renderRow(w, home));
|
|
}
|
|
};
|
|
|
|
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
|
|
const row = document.createElement("div");
|
|
row.className = "wt-row";
|
|
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
|
|
row.innerHTML = `
|
|
<span class="wt-branch">${escapeHtml(w.branch)}</span>
|
|
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
|
|
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
|
|
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
|
|
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
|
|
`;
|
|
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
|
|
showConfirm(w);
|
|
});
|
|
return row;
|
|
};
|
|
|
|
const showConfirm = (w: RetainedWorktree) => {
|
|
pendingId = w.id;
|
|
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
|
|
// in the way right now, and that the branch survives reclaim.
|
|
confirmEl.innerHTML = `
|
|
<div class="wt-confirm-panel">
|
|
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
|
|
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
|
|
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
|
|
<div class="wt-confirm-actions">
|
|
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
|
|
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
|
|
</div>
|
|
</div>`;
|
|
confirmEl.hidden = false;
|
|
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
|
|
};
|
|
|
|
const hideConfirm = () => {
|
|
pendingId = null;
|
|
confirmEl.hidden = true;
|
|
confirmEl.replaceChildren();
|
|
};
|
|
|
|
confirmEl.addEventListener("click", async (e) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest(".wt-confirm-cancel")) {
|
|
hideConfirm();
|
|
return;
|
|
}
|
|
if (!target.closest(".wt-confirm-go") || !pendingId) return;
|
|
const id = pendingId;
|
|
hideConfirm();
|
|
try {
|
|
await deps.reclaim(id);
|
|
} catch (err) {
|
|
alert(`Failed to reclaim: ${(err as Error).message}`);
|
|
}
|
|
await refresh();
|
|
});
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
render(await deps.load());
|
|
} catch (err) {
|
|
listHost.replaceChildren();
|
|
const fail = document.createElement("div");
|
|
fail.className = "empty";
|
|
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
|
|
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
|
|
listHost.appendChild(fail);
|
|
}
|
|
};
|
|
|
|
return {
|
|
el,
|
|
refresh,
|
|
dispose() {
|
|
document.removeEventListener("keydown", onKey);
|
|
el.remove();
|
|
},
|
|
};
|
|
}
|