From af5840bc41a015ddab2ed58c5a997a4f0d7ccef2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 14 May 2026 16:43:03 -0500 Subject: [PATCH] Fix workspace diff multi-patch rendering --- .../plugin-workspace-diff/src/diff-model.ts | 38 +++++++++-- .../plugin-workspace-diff/src/ui/index.tsx | 65 +++++++++++++++---- .../tests/diff-model.spec.ts | 59 +++++++++++++++++ 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/packages/plugins/plugin-workspace-diff/src/diff-model.ts b/packages/plugins/plugin-workspace-diff/src/diff-model.ts index a410494a15b..ec43d56d767 100644 --- a/packages/plugins/plugin-workspace-diff/src/diff-model.ts +++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts @@ -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, }; }); } diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 021c775e920..ddcda75319a 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -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 = { + 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 (
{warning ?? "No renderable patch is available for this file."} @@ -148,16 +169,36 @@ function FileDiffPanel({ } return ( -
- +
+ {file.patches.map((patch) => { + const patchWarning = patchWarningText(patch); + return ( +
+ {file.patches.length > 1 ? ( +
+ {patchKindLabel(patch.kind)} + +{patch.additions} + -{patch.deletions} +
+ ) : null} + {patchWarning || !patch.patch ? ( +
+ {patchWarning ?? "No renderable patch is available for this file."} +
+ ) : ( + + )} +
+ ); + })}
); } diff --git a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts index f1d0cef78f2..a0056195d5b 100644 --- a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts @@ -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");