feat(FN-3623): add stash diff viewing UI and backend contract

Merges FN-3623 stash diff inspection system (backend contract, GitManagerModal viewing UI with new API route and tests) and FN-3088 dependency graph hover/selection highlighting. The stash diff work dominates the commit count and touches the core GitManagerModal, while the graph plugin gains new hig

Fusion-Task-Id: FN-3623
This commit is contained in:
Fusion
2026-05-07 05:02:34 -07:00
committed by gsxdsm
parent 77d628594c
commit 7abfe7f9d1
8 changed files with 342 additions and 52 deletions

View File

@@ -2398,6 +2398,11 @@ export function dropStash(index: number, projectId?: string): Promise<{ message:
});
}
/** Fetch stash diff (stat + patch) */
export function fetchStashDiff(index: number, projectId?: string): Promise<{ stat: string; patch: string }> {
return api<{ stat: string; patch: string }>(withProjectId(`/git/stashes/${index}/diff`, projectId));
}
/** Fetch unstaged diff (working directory changes) */
export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> {
return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId));

View File

@@ -38,6 +38,7 @@ import {
createStash,
applyStash,
dropStash,
fetchStashDiff,
fetchFileChanges,
stageFiles,
unstageFiles,
@@ -253,6 +254,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [stashes, setStashes] = useState<GitStash[]>([]);
const [stashMessage, setStashMessage] = useState("");
const [stashLoading, setStashLoading] = useState<string | null>(null);
const [expandedStashIndex, setExpandedStashIndex] = useState<number | null>(null);
const [stashDiff, setStashDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingStashDiff, setLoadingStashDiff] = useState(false);
const [stashDiffError, setStashDiffError] = useState<string | null>(null);
const stashDiffRequestIdRef = useRef(0);
// ── Remotes state
const [remoteLoading, setRemoteLoading] = useState<string | null>(null);
@@ -300,6 +306,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
case "stashes": {
const stashesData = await fetchGitStashList(projectId);
setStashes(stashesData);
setExpandedStashIndex(null);
setStashDiff(null);
setStashDiffError(null);
stashDiffRequestIdRef.current += 1;
break;
}
case "remotes": {
@@ -658,9 +668,18 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
// ── Stash Handlers ──────────────────────────────────────────────
const resetStashDiffState = useCallback(() => {
stashDiffRequestIdRef.current += 1;
setExpandedStashIndex(null);
setStashDiff(null);
setStashDiffError(null);
setLoadingStashDiff(false);
}, []);
const handleCreateStash = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setStashLoading("create");
resetStashDiffState();
try {
await createStash(stashMessage.trim() || undefined, projectId);
addToast("Changes stashed", "success");
@@ -672,10 +691,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} finally {
setStashLoading(null);
}
}, [stashMessage, addToast, projectId]);
}, [stashMessage, addToast, projectId, resetStashDiffState]);
const handleApplyStash = useCallback(async (index: number, drop: boolean = false) => {
setStashLoading(`apply-${index}`);
resetStashDiffState();
try {
await applyStash(index, drop, projectId);
addToast(drop ? "Stash popped" : "Stash applied", "success");
@@ -686,7 +706,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} finally {
setStashLoading(null);
}
}, [addToast, projectId]);
}, [addToast, projectId, resetStashDiffState]);
const handleDropStash = useCallback(async (index: number) => {
const shouldDrop = await confirmContext.confirm({
@@ -696,6 +716,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
});
if (!shouldDrop) return;
setStashLoading(`drop-${index}`);
resetStashDiffState();
try {
await dropStash(index, projectId);
addToast("Stash dropped", "success");
@@ -706,7 +727,38 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} finally {
setStashLoading(null);
}
}, [addToast, projectId, confirmContext]);
}, [addToast, projectId, confirmContext, resetStashDiffState]);
const handleToggleStashDiff = useCallback(async (index: number) => {
if (expandedStashIndex === index) {
resetStashDiffState();
return;
}
const requestId = stashDiffRequestIdRef.current + 1;
stashDiffRequestIdRef.current = requestId;
setExpandedStashIndex(index);
setStashDiff(null);
setStashDiffError(null);
setLoadingStashDiff(true);
try {
const diff = await fetchStashDiff(index, projectId);
if (stashDiffRequestIdRef.current !== requestId) {
return;
}
setStashDiff(diff);
} catch (err) {
if (stashDiffRequestIdRef.current !== requestId) {
return;
}
setStashDiff(null);
setStashDiffError(getErrorMessage(err) || "Failed to load stash diff");
} finally {
if (stashDiffRequestIdRef.current === requestId) {
setLoadingStashDiff(false);
}
}
}, [expandedStashIndex, projectId, resetStashDiffState]);
// ── Remote Handlers ─────────────────────────────────────────────
@@ -918,7 +970,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
onCreateStash={handleCreateStash}
onApplyStash={handleApplyStash}
onDropStash={handleDropStash}
onToggleStashDiff={handleToggleStashDiff}
stashLoading={stashLoading}
expandedStashIndex={expandedStashIndex}
stashDiff={stashDiff}
loadingStashDiff={loadingStashDiff}
stashDiffError={stashDiffError}
/>
)}
@@ -1698,7 +1755,12 @@ function StashesPanel({
onCreateStash,
onApplyStash,
onDropStash,
onToggleStashDiff,
stashLoading,
expandedStashIndex,
stashDiff,
loadingStashDiff,
stashDiffError,
}: {
stashes: GitStash[];
stashMessage: string;
@@ -1706,7 +1768,12 @@ function StashesPanel({
onCreateStash: (e: React.FormEvent) => void;
onApplyStash: (index: number, drop?: boolean) => void;
onDropStash: (index: number) => void;
onToggleStashDiff: (index: number) => void;
stashLoading: string | null;
expandedStashIndex: number | null;
stashDiff: { stat: string; patch: string } | null;
loadingStashDiff: boolean;
stashDiffError: string | null;
}) {
return (
<div className="gm-panel" data-testid="stashes-panel">
@@ -1744,53 +1811,80 @@ function StashesPanel({
) : (
stashes.map((stash) => (
<div key={stash.index} className="gm-stash-item">
<div className="gm-stash-info">
<span className="gm-stash-ref">stash@{`{${stash.index}}`}</span>
<span className="gm-stash-message">{stash.message}</span>
<div className="gm-stash-meta">
{stash.branch && (
<span className="gm-stash-branch">
<GitBranchIcon size={12} />
{stash.branch}
</span>
)}
<span>{relativeDate(stash.date)}</span>
<div className="gm-stash-header">
<div className="gm-stash-info">
<span className="gm-stash-ref">stash@{`{${stash.index}}`}</span>
<span className="gm-stash-message">{stash.message}</span>
<div className="gm-stash-meta">
{stash.branch && (
<span className="gm-stash-branch">
<GitBranchIcon size={12} />
{stash.branch}
</span>
)}
<span>{relativeDate(stash.date)}</span>
</div>
</div>
<div className="gm-stash-actions">
<button
className="btn btn-sm"
onClick={() => onToggleStashDiff(stash.index)}
disabled={stashLoading !== null}
>
{expandedStashIndex === stash.index ? "Hide" : "View"}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() => onApplyStash(stash.index, false)}
disabled={stashLoading !== null}
title="Apply stash (keep)"
>
{stashLoading === `apply-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
"Apply"
)}
</button>
<button
className="btn btn-sm"
onClick={() => onApplyStash(stash.index, true)}
disabled={stashLoading !== null}
title="Pop stash (apply and drop)"
>
Pop
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => onDropStash(stash.index)}
disabled={stashLoading !== null}
title="Drop stash"
>
{stashLoading === `drop-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
</div>
<div className="gm-stash-actions">
<button
className="btn btn-sm btn-primary"
onClick={() => onApplyStash(stash.index, false)}
disabled={stashLoading !== null}
title="Apply stash (keep)"
>
{stashLoading === `apply-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
"Apply"
)}
</button>
<button
className="btn btn-sm"
onClick={() => onApplyStash(stash.index, true)}
disabled={stashLoading !== null}
title="Pop stash (apply and drop)"
>
Pop
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => onDropStash(stash.index)}
disabled={stashLoading !== null}
title="Drop stash"
>
{stashLoading === `drop-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
{expandedStashIndex === stash.index && (
<div className="gm-stash-diff">
{loadingStashDiff ? (
<div className="gm-diff-loading">
<Loader2 size={14} className="spin" />
Loading stash diff…
</div>
) : stashDiffError ? (
<div className="gm-diff-error">{stashDiffError}</div>
) : stashDiff ? (
<div className="gm-diff-viewer">
{stashDiff.stat && <pre className="gm-diff-stat">{stashDiff.stat}</pre>}
<pre className="gm-diff-patch">{stashDiff.patch}</pre>
</div>
) : null}
</div>
)}
</div>
))
)}

View File

@@ -2876,15 +2876,21 @@
.gm-stash-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.gm-stash-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.gm-stash-info {
display: flex;
flex-direction: column;
@@ -2924,10 +2930,16 @@
.gm-stash-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
flex-shrink: 0;
}
.gm-stash-diff {
border-top: 1px solid var(--border);
padding-top: var(--space-sm);
}
/* ── Remote Panel ── */
.gm-remote-status {
@@ -3559,8 +3571,13 @@
gap: var(--space-sm);
}
.gm-stash-item {
.gm-stash-header {
flex-direction: column;
gap: var(--space-sm);
}
.gm-stash-actions {
width: 100%;
}
.gm-remote-actions {

View File

@@ -43,6 +43,7 @@ vi.mock("../../api", async () => {
createStash: vi.fn(),
applyStash: vi.fn(),
dropStash: vi.fn(),
fetchStashDiff: vi.fn(),
fetchFileChanges: vi.fn(),
fetchGitFileDiff: vi.fn(),
stageFiles: vi.fn(),
@@ -82,6 +83,7 @@ import {
createStash,
applyStash,
dropStash,
fetchStashDiff,
fetchFileChanges,
fetchGitFileDiff,
stageFiles,
@@ -203,6 +205,10 @@ describe("GitManagerModal", () => {
(createStash as any).mockResolvedValue({ message: "Stash created" });
(applyStash as any).mockResolvedValue({ message: "Stash applied" });
(dropStash as any).mockResolvedValue({ message: "Stash dropped" });
(fetchStashDiff as any).mockResolvedValue({
stat: " README.md | 2 ++",
patch: "diff --git a/README.md b/README.md\n+stash diff",
});
(discardChanges as any).mockResolvedValue({ discarded: ["src/app.ts"] });
(createBranch as any).mockResolvedValue(undefined);
(checkoutBranch as any).mockResolvedValue(undefined);
@@ -1160,6 +1166,77 @@ describe("GitManagerModal", () => {
});
});
it("views stash contents", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "View" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "View" }));
await waitFor(() => {
expectLatestCallStartsWith(fetchStashDiff as any, 0);
expect(screen.getByText("Hide")).toBeInTheDocument();
expect(screen.getByText("README.md | 2 ++")).toBeInTheDocument();
expect(screen.getByText(/\+stash diff/)).toBeInTheDocument();
});
});
it("shows stash diff loading and error states", async () => {
const user = userEvent.setup();
let resolveDiff: ((value: { stat: string; patch: string }) => void) | null = null;
(fetchStashDiff as any).mockImplementationOnce(
() =>
new Promise<{ stat: string; patch: string }>((resolve) => {
resolveDiff = resolve;
})
);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await user.click(await screen.findByRole("button", { name: "View" }));
expect(screen.getByText("Loading stash diff…")).toBeInTheDocument();
resolveDiff?.({ stat: " README.md | 1 +", patch: "diff --git a/README.md b/README.md\n+ok" });
await waitFor(() => {
expect(screen.getByText("README.md | 1 +")).toBeInTheDocument();
});
(fetchStashDiff as any).mockRejectedValueOnce(new Error("stash diff failed"));
await user.click(screen.getByRole("button", { name: "Hide" }));
await user.click(screen.getByRole("button", { name: "View" }));
await waitFor(() => {
expect(screen.getByText("stash diff failed")).toBeInTheDocument();
});
});
it("keeps stash actions available when viewing stash contents", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await user.click(await screen.findByRole("button", { name: "View" }));
await user.click(screen.getByText("Apply"));
await user.click(screen.getByText("Pop"));
await waitFor(() => {
expect((applyStash as any).mock.calls).toContainEqual([0, false, undefined]);
expect((applyStash as any).mock.calls).toContainEqual([0, true, undefined]);
expect(screen.getByTitle("Drop stash")).toBeInTheDocument();
});
});
it("creates a stash", async () => {
const user = userEvent.setup();
render(

View File

@@ -440,6 +440,53 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/stashes/:index/diff", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
execFileSync("git", ["-C", gitRepoDir, "stash", "clear"], { stdio: "pipe" });
};
beforeEach(() => {
resetGitRepo();
});
afterEach(() => {
resetGitRepo();
});
it("returns 400 for invalid stash index", async () => {
const res = await GET(buildApp(), "/api/git/stashes/not-a-number/diff");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid stash index");
});
it("returns 404 for missing stash entry", async () => {
const res = await GET(buildApp(), "/api/git/stashes/0/diff");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Stash not found");
});
it("returns stash diff for an existing stash", async () => {
const readmePath = join(gitRepoDir, "README.md");
const original = readFileSync(readmePath, "utf-8");
const marker = `\nstash-diff-${Date.now()}\n`;
writeFileSync(readmePath, `${original}${marker}`);
execFileSync("git", ["-C", gitRepoDir, "stash", "push", "-m", "test stash diff"], { stdio: "pipe" });
const res = await GET(buildApp(), "/api/git/stashes/0/diff");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("stat");
expect(res.body).toHaveProperty("patch");
expect(res.body.patch).toContain("diff --git a/README.md b/README.md");
expect(res.body.patch).toContain(marker.trim());
});
});
describe("GET /git/changes", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();

View File

@@ -705,6 +705,23 @@ export async function dropGitStash(index: number, cwd?: string): Promise<string>
return output || "Stash dropped";
}
export async function getGitStashDiff(index: number, cwd?: string): Promise<{ stat: string; patch: string } | null> {
if (index < 0 || !Number.isInteger(index)) {
throw new Error("Invalid stash index");
}
const stashRef = `stash@{${index}}`;
try {
await runGitCommand(["rev-parse", "--verify", stashRef], cwd, 5000);
} catch {
return null;
}
const stat = (await runGitCommand(["stash", "show", "--stat", stashRef], cwd, 10000)).trim();
const patch = await runGitCommand(["stash", "show", "-p", stashRef], cwd, 10000);
return { stat, patch };
}
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
try {
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
@@ -1755,6 +1772,37 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/**
* GET /api/git/stashes/:index/diff
* Returns stash diff (stat + patch) for a stash entry.
*/
router.get("/git/stashes/:index/diff", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
const index = parseInt(req.params.index, 10);
if (isNaN(index) || index < 0) {
throw badRequest("Invalid stash index");
}
const diff = await getGitStashDiff(index, rootDir);
if (!diff) {
throw notFound("Stash not found");
}
res.json(diff);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* DELETE /api/git/stashes/:index
* Drop a stash entry.