mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Improve workspace diff viewer UX
This commit is contained in:
1 parent
58feaa9c0b
commit
53f3425cd4
3 files changed
+136
-16
No files matched your search
@@ -10,6 +10,7 @@ export type DiffRenderMode = "unified" | "split";
|
||||
export interface DiffPatchViewModel {
|
||||
kind: WorkspaceDiffFilePatch["kind"];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
@@ -31,6 +32,8 @@ export interface DiffFileViewModel {
|
||||
patchKinds: WorkspaceDiffFilePatch["kind"][];
|
||||
patches: DiffPatchViewModel[];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
longDiff: boolean;
|
||||
}
|
||||
|
||||
export interface DiffSummaryViewModel {
|
||||
@@ -51,6 +54,8 @@ const STATUS_LABELS: Record<WorkspaceDiffFile["status"], string> = {
|
||||
unknown: "Changed",
|
||||
};
|
||||
|
||||
export const LONG_DIFF_LINE_THRESHOLD = 400;
|
||||
|
||||
export function statusLabel(status: WorkspaceDiffFile["status"]) {
|
||||
return STATUS_LABELS[status] ?? "Changed";
|
||||
}
|
||||
@@ -62,9 +67,11 @@ export function fileName(filePath: string) {
|
||||
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
|
||||
return file.patches.map((patch) => {
|
||||
const textPatch = patch.patch?.trimEnd() ?? null;
|
||||
const lineCount = textPatch ? textPatch.split("\n").length : 0;
|
||||
return {
|
||||
kind: patch.kind,
|
||||
patch: textPatch && textPatch.length > 0 ? textPatch : null,
|
||||
lineCount,
|
||||
additions: patch.additions,
|
||||
deletions: patch.deletions,
|
||||
binary: patch.binary,
|
||||
@@ -79,10 +86,15 @@ export function buildFilePatch(file: WorkspaceDiffFile): string | null {
|
||||
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
|
||||
}
|
||||
|
||||
export function isLongDiffFile(file: Pick<DiffFileViewModel, "lineCount">) {
|
||||
return file.lineCount > LONG_DIFF_LINE_THRESHOLD;
|
||||
}
|
||||
|
||||
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);
|
||||
const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0);
|
||||
return {
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
@@ -96,6 +108,8 @@ export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined)
|
||||
patchKinds: file.patches.map((patch) => patch.kind),
|
||||
patches,
|
||||
patch: patches.find((patch) => patch.patch)?.patch ?? null,
|
||||
lineCount,
|
||||
longDiff: isLongDiffFile({ lineCount }),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -127,3 +141,7 @@ export function nextExpandedFileSet(
|
||||
else next.add(filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set<string> {
|
||||
return new Set(files.filter((file) => !file.longDiff).map((file) => file.path));
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||
import { useFileDiffInstance } from "@pierre/diffs/react";
|
||||
import { createElement, useEffect, useMemo, useState } from "react";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
diffSummary,
|
||||
fileName,
|
||||
initialExpandedFileSet,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
@@ -236,12 +237,45 @@ function FileDiffPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedFilePanel({
|
||||
file,
|
||||
onExpand,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const title = file.longDiff ? "Large diff folded" : "Diff folded";
|
||||
const details = file.lineCount > 0
|
||||
? `${file.lineCount.toLocaleString()} lines`
|
||||
: statusLabel(file.status);
|
||||
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{title}</div>
|
||||
<div className="mt-1 font-mono text-xs">{details}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(false)}
|
||||
onClick={onExpand}
|
||||
aria-label={`Show diff for ${file.path}`}
|
||||
>
|
||||
Show file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const toast = usePluginToast();
|
||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||
const [includeUntracked, setIncludeUntracked] = useState(true);
|
||||
const [includeUntracked, setIncludeUntracked] = useState(false);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
|
||||
const params = useMemo(() => ({
|
||||
workspaceId: context.entityId,
|
||||
@@ -256,13 +290,28 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
|
||||
const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`;
|
||||
|
||||
const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => {
|
||||
if (node) fileSectionRefs.current.set(filePath, node);
|
||||
else fileSectionRefs.current.delete(filePath);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback((filePath: string) => {
|
||||
setSelectedPath(filePath);
|
||||
window.requestAnimationFrame(() => {
|
||||
fileSectionRefs.current.get(filePath)?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0) {
|
||||
setExpandedFiles(new Set());
|
||||
setSelectedPath(null);
|
||||
return;
|
||||
}
|
||||
setExpandedFiles((current) => current.size > 0 ? current : new Set(files.map((file) => file.path)));
|
||||
setExpandedFiles(initialExpandedFileSet(files));
|
||||
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
|
||||
}, [files]);
|
||||
|
||||
@@ -309,7 +358,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
className={buttonClass(includeUntracked)}
|
||||
onClick={() => setIncludeUntracked((value) => !value)}
|
||||
>
|
||||
Untracked
|
||||
{includeUntracked ? "Untracked shown" : "Show untracked"}
|
||||
</button>
|
||||
<button key="refresh" type="button" className={buttonClass(false)} onClick={() => refresh()}>
|
||||
Refresh
|
||||
@@ -324,19 +373,19 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div key="content" className="grid min-h-[560px] gap-3 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside key="files" className="min-w-0 border border-border bg-background">
|
||||
<div key="content" className="grid gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside key="files" className="flex min-w-0 flex-col border border-border bg-background lg:h-full lg:overflow-hidden">
|
||||
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
<div key="list" className="max-h-[70vh] overflow-auto">
|
||||
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
|
||||
{files.map((file) => (
|
||||
<FileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onSelect={() => setSelectedPath(file.path)}
|
||||
onSelect={() => selectFile(file.path)}
|
||||
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
onCopy={() => void copyPath(file.path)}
|
||||
/>
|
||||
@@ -344,20 +393,20 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main key="diffs" className="min-w-0 space-y-3">
|
||||
<main key="diffs" className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1">
|
||||
{files
|
||||
.filter((file) => expandedFiles.has(file.path))
|
||||
.map((file) => (
|
||||
<section
|
||||
key={file.path}
|
||||
className={file.path === selectedFile?.path ? "scroll-mt-20" : undefined}
|
||||
ref={setFileSectionRef(file.path)}
|
||||
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
|
||||
>
|
||||
<div key="header" className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
|
||||
<button
|
||||
key="select"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => setSelectedPath(file.path)}
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
|
||||
{file.oldPath ? (
|
||||
@@ -381,15 +430,23 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
key="collapse"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
title="Collapse file"
|
||||
aria-label={`Collapse ${file.path}`}
|
||||
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
|
||||
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
|
||||
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
>
|
||||
−
|
||||
{expandedFiles.has(file.path) ? "−" : "+"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FileDiffPanel key="diff" file={file} mode={mode} />
|
||||
{expandedFiles.has(file.path) ? (
|
||||
<FileDiffPanel key="diff" file={file} mode={mode} />
|
||||
) : (
|
||||
<CollapsedFilePanel
|
||||
key="collapsed"
|
||||
file={file}
|
||||
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
buildFilePatch,
|
||||
buildFilePatches,
|
||||
diffSummary,
|
||||
initialExpandedFileSet,
|
||||
LONG_DIFF_LINE_THRESHOLD,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
@@ -23,6 +25,8 @@ describe("workspace diff UI model", () => {
|
||||
path: "src/app.ts",
|
||||
status: "modified",
|
||||
patchKinds: ["unstaged"],
|
||||
lineCount: 7,
|
||||
longDiff: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +110,47 @@ describe("workspace diff UI model", () => {
|
||||
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
|
||||
});
|
||||
|
||||
it("marks long text diffs so the UI can fold them by default", () => {
|
||||
const longPatch = [
|
||||
"diff --git a/src/large.ts b/src/large.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/large.ts",
|
||||
"+++ b/src/large.ts",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`),
|
||||
"",
|
||||
].join("\n");
|
||||
const files = toFileViewModels(diffResponse({
|
||||
files: [
|
||||
changedFile({ path: "src/small.ts" }),
|
||||
changedFile({
|
||||
path: "src/large.ts",
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: longPatch,
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const longFile = files.find((file) => file.path === "src/large.ts");
|
||||
const defaultExpanded = initialExpandedFileSet(files);
|
||||
|
||||
expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD);
|
||||
expect(longFile?.longDiff).toBe(true);
|
||||
expect(defaultExpanded.has("src/small.ts")).toBe(true);
|
||||
expect(defaultExpanded.has("src/large.ts")).toBe(false);
|
||||
});
|
||||
|
||||
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