feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- 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
This commit is contained in:
@@ -54,6 +54,11 @@
|
||||
* .gitignore (`ignore`, untracked only). While a merge is open the box is
|
||||
* hidden: a partial commit cannot finish one, Complete does.
|
||||
*
|
||||
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
|
||||
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
|
||||
* file list restores or deletes what was shelved — a git stash under the
|
||||
* hood, its recovery always a button here.
|
||||
*
|
||||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||||
@@ -92,6 +97,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -142,6 +148,8 @@ import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
@@ -876,6 +884,11 @@ export function LaneChangesPanel({
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
// Any git write on this workspace — from this panel's bar, another repo's
|
||||
// panel or the session view — re-reads everything here (`rereadAll`, set
|
||||
// once it is defined below).
|
||||
const rereadRef = useRef<(() => void) | null>(null);
|
||||
useGitChanged(laneId, () => rereadRef.current?.());
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
@@ -979,6 +992,9 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// Shelve (n) opened its note row; the note typed there.
|
||||
const [shelving, setShelving] = useState(false);
|
||||
const [shelfNote, setShelfNote] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
@@ -1514,6 +1530,36 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
|
||||
// aside with the note; all of them when every file is ticked.
|
||||
const shelveTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all = tickedPaths.length >= files.length;
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "shelve", {
|
||||
...(all ? {} : { paths: tickedPaths }),
|
||||
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
|
||||
...(confirm ? { confirm } : {}),
|
||||
})
|
||||
);
|
||||
if (laneNow.current !== lane || !res) return;
|
||||
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
|
||||
setShelving(false);
|
||||
setShelfNote("");
|
||||
refreshGit();
|
||||
setReread((n) => n + 1);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -2020,6 +2066,7 @@ export function LaneChangesPanel({
|
||||
setReread((n) => n + 1);
|
||||
return load();
|
||||
};
|
||||
rereadRef.current = () => void rereadAll();
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
@@ -2679,6 +2726,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes set aside (git stash entries AgentHub made): Restore and
|
||||
Delete, never dropped on their own. Absent while there are none. */}
|
||||
{git?.available && mode !== "file" && (
|
||||
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
@@ -2893,7 +2946,54 @@ export function LaneChangesPanel({
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-shelve"
|
||||
aria-expanded={shelving}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => setShelving((v) => !v)}
|
||||
title={t("shelves.shelveHint")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.shelveTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
{shelving && (
|
||||
<div
|
||||
data-testid="lane-changes-shelve-row"
|
||||
className="flex w-full flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={shelfNote}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onChange={(e) => setShelfNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void shelveTicked();
|
||||
if (e.key === "Escape") setShelving(false);
|
||||
}}
|
||||
placeholder={t("shelves.notePlaceholder")}
|
||||
aria-label={t("shelves.notePlaceholder")}
|
||||
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void shelveTicked()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("shelves.shelveApply", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShelving(false)}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("shelves.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The work range splits like JetBrains' Commit window: tracked
|
||||
|
||||
@@ -15,7 +15,16 @@
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* --ff-only, switch refuses a dirty tree unless told what to do with the
|
||||
* changes), and this only reports what it said. With uncommitted changes,
|
||||
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
|
||||
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
|
||||
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
|
||||
* — and the new-branch row offers **Create & switch** (`switch: true`, the
|
||||
* changes come along; the default on Enter while the tree is dirty) beside
|
||||
* plain Create. A switch onto a branch that has shelves asks whether to
|
||||
* restore the newest (never automatically), and a write the server answers
|
||||
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
@@ -45,6 +54,7 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -128,6 +138,9 @@ export function LaneGitBar({
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// A switch with uncommitted changes asks how to take them: set to the
|
||||
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
|
||||
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -150,6 +163,7 @@ export function LaneGitBar({
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
setChoice(null);
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -168,6 +182,9 @@ export function LaneGitBar({
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
|
||||
// refused as a second action in flight.
|
||||
let askAgent = false;
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
@@ -190,20 +207,50 @@ export function LaneGitBar({
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setChoice(null);
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
|
||||
onChanged();
|
||||
// The branch switched to has changes shelved on it: offer them back —
|
||||
// asking, never restoring on its own.
|
||||
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
|
||||
if (offer) {
|
||||
const yes = await confirmDialog({
|
||||
title: t("gitBar.restoreOfferTitle"),
|
||||
message: t("gitBar.restoreOffer", {
|
||||
branch: offer.branch,
|
||||
count: res?.shelves?.length ?? 1,
|
||||
files: offer.files.length,
|
||||
}),
|
||||
confirmLabel: t("shelves.restore"),
|
||||
destructive: false,
|
||||
});
|
||||
if (yes && laneNow.current === lane) {
|
||||
await restoreShelf(gitTarget(lane, session, source), offer);
|
||||
if (laneNow.current === lane) onChanged();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
|
||||
askAgent = true;
|
||||
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
|
||||
// git would overwrite these files: show them and offer the shelf.
|
||||
setChoice({
|
||||
branch: body.branch,
|
||||
conflict: (err as { files?: string[] }).files ?? [],
|
||||
});
|
||||
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
@@ -221,6 +268,15 @@ export function LaneGitBar({
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
if (askAgent) {
|
||||
const ok = await confirmDialog({
|
||||
title: t("shelves.agentTitle"),
|
||||
message: t("shelves.agentMessage"),
|
||||
confirmLabel: t("shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
@@ -306,6 +362,20 @@ export function LaneGitBar({
|
||||
if (ok) void run("merge", { branch });
|
||||
};
|
||||
|
||||
// Switch branch: straight away on a clean tree; with changes, ask how.
|
||||
const switchTo = (branch: string) => {
|
||||
if (dirty > 0) {
|
||||
setChoice({ branch });
|
||||
setError(null);
|
||||
} else void run("switch", { branch });
|
||||
};
|
||||
// Enter in the new-branch box: with changes, Create & switch (they come
|
||||
// along); on a clean tree, plain Create as before.
|
||||
const createBranch = (andSwitch: boolean) => {
|
||||
const branch = newBranch.trim();
|
||||
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -424,13 +494,13 @@ export function LaneGitBar({
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
// With changes, Switch branch asks whether to bring or shelve
|
||||
// them; the hint says so before the click.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
? t("gitBar.switchDirtyHint", { count: dirty })
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
@@ -473,9 +543,11 @@ export function LaneGitBar({
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={
|
||||
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
|
||||
}
|
||||
onClick={() => switchTo(picked)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
@@ -521,34 +593,83 @@ export function LaneGitBar({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && choice && (
|
||||
<div
|
||||
data-testid="lane-git-switch-choice"
|
||||
role="group"
|
||||
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
>
|
||||
<p className="break-words text-fg-primary">
|
||||
{choice.conflict
|
||||
? t("gitBar.carryConflict", { branch: choice.branch })
|
||||
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
|
||||
</p>
|
||||
{choice.conflict && choice.conflict.length > 0 && (
|
||||
<ul
|
||||
data-testid="lane-git-carry-conflicts"
|
||||
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
|
||||
>
|
||||
{choice.conflict.map((f) => (
|
||||
<li key={f} className="truncate" title={f}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{!choice.conflict && (
|
||||
<Btn
|
||||
label={t("gitBar.choiceCarry")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceCarryHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.choiceShelve")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceShelveHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
|
||||
/>
|
||||
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
if (isSubmitEnter(e)) createBranch(dirty > 0);
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreateSwitch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// The working tree stays as it is: uncommitted changes come along.
|
||||
title={t("gitBar.branchCreateSwitchHint")}
|
||||
onClick={() => createBranch(true)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
// Plain Create does not switch: the point is usually a sibling
|
||||
// lane, and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
onClick={() => createBranch(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* owned by the panel) and refresh each other after every write. Every other
|
||||
* reader of the facts (the rail counts, a second panel) hears a write through
|
||||
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, source, nonce]);
|
||||
// A git write from ANY view of this workspace (another panel, the session
|
||||
// view, a second tab of the same page) moves these facts too.
|
||||
useGitChanged(laneId, () => setNonce((n) => n + 1));
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @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;
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -238,6 +239,17 @@ function HomeBody({
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
// A branch moved from the git bar: the main worktree's branch shown here is
|
||||
// the server's cached list, so it is refreshed rather than re-read.
|
||||
useGitChanged(lane.id, (action) => {
|
||||
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
|
||||
api.projects
|
||||
.refresh()
|
||||
.then(({ projects }) =>
|
||||
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
|
||||
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
@@ -445,6 +446,84 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
|
||||
// The repo as the server sees it: which branch, which changes.
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
vi.mocked(api.lanes.changes).mockImplementation(async () =>
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
|
||||
if (action === "switch") branch = (body as { branch: string }).branch;
|
||||
return { action, ok: true, output: "", shelves: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
||||
branch = "other";
|
||||
act(() => emitGitChanged(7, "switch"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
|
||||
// Another workspace's write is not this panel's business.
|
||||
const now = vi.mocked(api.lanes.git).mock.calls.length;
|
||||
act(() => emitGitChanged(8, "switch"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
|
||||
});
|
||||
|
||||
it("re-reads history too when Refresh is pressed there", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), that branches only the remote has are offered (and the list
|
||||
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
|
||||
* whether to bring the changes or shelve them (and offers the shelf when git
|
||||
* refuses to carry them), Create & switch, the offer to restore a branch's
|
||||
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
|
||||
* is re-read after a fetch), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -202,16 +204,114 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
// Nothing is sent before the user picks what happens to the changes.
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
const choice = screen.getByTestId("lane-git-switch-choice");
|
||||
expect(choice).toHaveTextContent("3 uncommitted file(s)");
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "carry",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Shelve and switch when git refuses to carry the changes", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("would be overwritten"), {
|
||||
code: "ECARRYCONFLICT",
|
||||
files: ["src/a-very-long-path/that/should/truncate.ts"],
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||
"src/a-very-long-path/that/should/truncate.ts"
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before moving files under a working agent, then resends with confirm", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "An agent is working here" })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def",
|
||||
branch: "development",
|
||||
note: "",
|
||||
createdAt: "2026-11-02T00:00:00.000Z",
|
||||
files: [{ path: "a.txt", status: "M" }],
|
||||
};
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
|
||||
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||
);
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Restore shelved changes?" })
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and switches in one step — the default on Enter while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "fix/ac-515",
|
||||
switch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @file LaneShelves.test.tsx
|
||||
* @description Pins the Changes panel's Shelves list: hidden while there are
|
||||
* none, one collapsed line otherwise, each shelf's branch, note and files,
|
||||
* Restore sending `unshelve` by sha (and the conflict outcome told in a
|
||||
* toast), Delete needing a second click before `shelf-drop` with
|
||||
* `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneShelves } from "../LaneShelves";
|
||||
import { api } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
|
||||
}));
|
||||
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def5678",
|
||||
branch: "fix/ac-515-a-rather-long-branch-name",
|
||||
note: "half way through the login fix",
|
||||
createdAt: "2026-11-02T03:04:05.000Z",
|
||||
files: [
|
||||
{ path: "src/login.ts", status: "M" },
|
||||
{ path: "src/new.ts", status: "??" },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitShelves)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
shelves: [shelf],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneShelves", () => {
|
||||
it("renders nothing while there are no shelves", async () => {
|
||||
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
|
||||
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lists a shelf with its branch, note and files", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
|
||||
const row = screen.getByTestId("lane-shelf");
|
||||
expect(row).toHaveTextContent(shelf.branch);
|
||||
expect(row).toHaveTextContent(shelf.note);
|
||||
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
|
||||
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
|
||||
});
|
||||
|
||||
it("restores by sha and reports a conflicted restore", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["src/login.ts"],
|
||||
kept: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
|
||||
});
|
||||
|
||||
it("deletes only on the second click, with confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
|
||||
sha: shelf.sha,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user