mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
Fix workspace diff multi-patch rendering
This commit is contained in:
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");
|
||||
|
||||
Reference in new issue
Block a user