Fix workspace diff multi-patch rendering

This commit is contained in:
Dotta committed 2026-05-15 12:54:27 -05:00
1 parent d15fb5de01
commit af5840bc41
3 files changed
+144 -18

No files matched your search

@@ -7,6 +7,17 @@ import type {
export type DiffRenderMode = "unified" | "split";
export interface DiffPatchViewModel {
kind: WorkspaceDiffFilePatch["kind"];
patch: string | null;
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
}
export interface DiffFileViewModel {
path: string;
oldPath: string | null;
@@ -18,6 +29,7 @@ export interface DiffFileViewModel {
truncated: boolean;
warnings: WorkspaceDiffWarning[];
patchKinds: WorkspaceDiffFilePatch["kind"][];
patches: DiffPatchViewModel[];
patch: string | null;
}
@@ -47,17 +59,30 @@ export function fileName(filePath: string) {
return filePath.split("/").filter(Boolean).pop() ?? filePath;
}
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
return file.patches.map((patch) => {
const textPatch = patch.patch?.trimEnd() ?? null;
return {
kind: patch.kind,
patch: textPatch && textPatch.length > 0 ? textPatch : null,
additions: patch.additions,
deletions: patch.deletions,
binary: patch.binary,
oversized: patch.oversized,
truncated: patch.truncated,
warnings: patch.warnings,
};
});
}
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
const patches = file.patches
.map((patch) => patch.patch?.trimEnd() ?? "")
.filter(Boolean);
if (patches.length === 0) return null;
return patches.join("\n");
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
}
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
return (diff?.files ?? []).map((file) => {
const patchWarnings = file.patches.flatMap((patch) => patch.warnings);
const patches = buildFilePatches(file);
return {
path: file.path,
oldPath: file.oldPath,
@@ -69,7 +94,8 @@ export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined)
truncated: file.truncated,
warnings: [...file.warnings, ...patchWarnings],
patchKinds: file.patches.map((patch) => patch.kind),
patch: buildFilePatch(file),
patches,
patch: patches.find((patch) => patch.patch)?.patch ?? null,
};
});
}
@@ -10,6 +10,7 @@ import {
statusLabel,
toFileViewModels,
type DiffFileViewModel,
type DiffPatchViewModel,
type DiffRenderMode,
} from "../diff-model.js";
@@ -38,7 +39,27 @@ function warningText(file: DiffFileViewModel) {
if (file.oversized) return "Too large to render";
if (file.truncated) return "Patch truncated";
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
if (!file.patch) return "No text patch";
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
return null;
}
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
staged: "Staged",
unstaged: "Unstaged",
head: "Head",
untracked: "Untracked",
};
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
return PATCH_KIND_LABELS[kind] ?? "Patch";
}
function patchWarningText(patch: DiffPatchViewModel) {
if (patch.binary) return "Binary file";
if (patch.oversized) return "Too large to render";
if (patch.truncated) return "Patch truncated";
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
if (!patch.patch) return "No text patch";
return null;
}
@@ -139,7 +160,7 @@ function FileDiffPanel({
mode: DiffRenderMode;
}) {
const warning = warningText(file);
if (warning || !file.patch) {
if (warning) {
return (
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
{warning ?? "No renderable patch is available for this file."}
@@ -148,16 +169,36 @@ function FileDiffPanel({
}
return (
<div className="overflow-hidden border border-border bg-background">
<PatchDiff
patch={file.patch}
options={{
diffStyle: mode,
overflow: "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
<div className="space-y-3">
{file.patches.map((patch) => {
const patchWarning = patchWarningText(patch);
return (
<div key={patch.kind} className="overflow-hidden border border-border bg-background">
{file.patches.length > 1 ? (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
<span className="font-mono text-emerald-700 dark:text-emerald-300">+{patch.additions}</span>
<span className="font-mono text-red-700 dark:text-red-300">-{patch.deletions}</span>
</div>
) : null}
{patchWarning || !patch.patch ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
{patchWarning ?? "No renderable patch is available for this file."}
</div>
) : (
<PatchDiff
patch={patch.patch}
options={{
diffStyle: mode,
overflow: "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
)}
</div>
);
})}
</div>
);
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildFilePatch,
buildFilePatches,
diffSummary,
nextExpandedFileSet,
statusLabel,
@@ -47,6 +48,64 @@ describe("workspace diff UI model", () => {
});
});
it("keeps staged and unstaged patches renderable as separate single-file diffs", () => {
const stagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-export const value = 1;",
"+export const value = 2;",
"",
].join("\n");
const unstagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 2222222..3333333 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -3 +3 @@",
"-export const label = 'old';",
"+export const label = 'new';",
"",
].join("\n");
const file = changedFile({
staged: true,
unstaged: true,
patches: [
{
kind: "staged",
patch: stagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
{
kind: "unstaged",
patch: unstagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
});
const patches = buildFilePatches(file);
const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0];
expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd());
expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]);
expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]);
expect(viewModel?.patches).toHaveLength(2);
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
});
it("toggles expanded file state without mutating the current set", () => {
const current = new Set(["a.ts"]);
const collapsed = nextExpandedFileSet(current, "a.ts");