f6eb39eb73
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails) - branch-create switch: true runs git switch -c, the changes come along - shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it - 409 EAGENTRUNNING until confirm: true while the workspace's agent is working - git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list - fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
/**
|
|
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
|
|
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
|
|
* branch, note, time and files, and the two buttons that make a shelf safe to
|
|
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
|
|
* when it applied cleanly; a conflicted restore keeps it and leaves the files
|
|
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
|
|
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
|
|
* and absent while there are none. Also exports `withAgentConfirm`, the
|
|
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
|
|
* with `confirm: true`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
|
|
import i18n from "../../i18n";
|
|
import { api, type GitShelf, type GitTarget } from "../../lib/api";
|
|
import { confirmDialog } from "../../lib/confirm";
|
|
import { toast } from "../../lib/toast";
|
|
import { useArmed } from "../../hooks/useArmed";
|
|
|
|
/**
|
|
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
|
|
* workspace is working and the write would move files under it), ask, and
|
|
* resend it with `confirm: true`. Resolves null when the user declined.
|
|
*/
|
|
export async function withAgentConfirm<T>(
|
|
send: (confirm: boolean) => Promise<T>
|
|
): Promise<T | null> {
|
|
try {
|
|
return await send(false);
|
|
} catch (err) {
|
|
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
|
|
const ok = await confirmDialog({
|
|
title: i18n.t("lanes:shelves.agentTitle"),
|
|
message: i18n.t("lanes:shelves.agentMessage"),
|
|
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
|
|
destructive: false,
|
|
});
|
|
return ok ? send(true) : null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
|
|
* the list below and the git bar's "restore what you shelved here" prompt.
|
|
* @returns true when anything changed in the working tree.
|
|
*/
|
|
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
|
|
try {
|
|
const res = await withAgentConfirm((confirm) =>
|
|
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
|
|
);
|
|
if (!res) return false;
|
|
if (res.conflicts?.length) {
|
|
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
|
|
} else {
|
|
toast.success(i18n.t("lanes:shelves.restored"));
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(
|
|
(err as { code?: string })?.code === "EDIRTY"
|
|
? i18n.t("lanes:shelves.restoreDirty")
|
|
: err instanceof Error
|
|
? err.message
|
|
: String(err)
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
|
|
function statusTone(status: string): string {
|
|
if (status === "??" || status.startsWith("A")) return "text-status-success";
|
|
if (status.startsWith("D")) return "text-status-danger";
|
|
return "text-status-warning";
|
|
}
|
|
|
|
export function LaneShelves({
|
|
target,
|
|
reread,
|
|
onChanged,
|
|
}: {
|
|
target: GitTarget;
|
|
/** Bumped by the panel after every git write: the list is read again. */
|
|
reread: number;
|
|
/** Called after a restore or a delete changed the tree or the list. */
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [shelves, setShelves] = useState<GitShelf[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [filesOf, setFilesOf] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const del = useArmed();
|
|
const key = JSON.stringify(target);
|
|
const keyNow = useRef(key);
|
|
keyNow.current = key;
|
|
|
|
const load = useCallback(async () => {
|
|
const asked = key;
|
|
try {
|
|
const r = await api.lanes.gitShelves(target);
|
|
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
|
|
} catch {
|
|
if (keyNow.current === asked) setShelves([]);
|
|
}
|
|
// `target` is described by `key`; a new object with the same content is
|
|
// the same tree.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load, reread]);
|
|
|
|
if (!shelves.length) return null;
|
|
|
|
const restore = async (s: GitShelf) => {
|
|
setBusy(s.sha);
|
|
try {
|
|
if (await restoreShelf(target, s)) onChanged();
|
|
await load();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const drop = async (s: GitShelf) => {
|
|
if (!del.isArmed(s.sha)) {
|
|
del.arm(s.sha);
|
|
return;
|
|
}
|
|
del.disarm();
|
|
setBusy(s.sha);
|
|
try {
|
|
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
|
|
toast.success(t("shelves.deleted"));
|
|
onChanged();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(null);
|
|
await load();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
|
|
>
|
|
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
|
|
{t("shelves.title", { count: shelves.length })}
|
|
</button>
|
|
{open && (
|
|
<ul className="divide-y divide-border-light border-t border-border-light">
|
|
{shelves.map((s) => {
|
|
const when = s.createdAt ? new Date(s.createdAt) : null;
|
|
const showFiles = filesOf === s.sha;
|
|
return (
|
|
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span
|
|
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
|
|
title={s.branch}
|
|
>
|
|
{s.branch}
|
|
</span>
|
|
{when && !Number.isNaN(when.getTime()) && (
|
|
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
|
|
{when.toLocaleString()}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
aria-expanded={showFiles}
|
|
onClick={() => setFilesOf(showFiles ? null : s.sha)}
|
|
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
|
>
|
|
{t("shelves.files", { count: s.files.length })}
|
|
</button>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
disabled={!!busy}
|
|
onClick={() => void restore(s)}
|
|
title={t("shelves.restoreHint")}
|
|
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<RotateCcw className="h-3 w-3" aria-hidden />
|
|
{t("shelves.restore")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!!busy}
|
|
onClick={() => void drop(s)}
|
|
title={t("shelves.deleteHint")}
|
|
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<Trash2 className="h-3 w-3" aria-hidden />
|
|
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{s.note && (
|
|
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
|
|
{s.note}
|
|
</p>
|
|
)}
|
|
{showFiles && (
|
|
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
|
|
{s.files.map((f) => (
|
|
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
|
|
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
|
|
{f.status === "??" ? "U" : f.status.slice(0, 1)}
|
|
</span>
|
|
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
|
|
{f.path}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default LaneShelves;
|