Skip to content

Commit a64e883

Browse files
committed
Add collapsible diff file headers
- Collapse individual file diffs behind expandable headers - Show file status badges and accepted state in the diff list - Auto-collapse accepted files and keep the selected file visible
1 parent c1e7657 commit a64e883

1 file changed

Lines changed: 163 additions & 53 deletions

File tree

apps/web/src/components/DiffPanel.tsx

Lines changed: 163 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { parsePatchFiles } from "@pierre/diffs";
22
import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33
import { useQuery } from "@tanstack/react-query";
4-
import { Columns2Icon, Rows3Icon, TextWrapIcon, XIcon } from "lucide-react";
4+
import {
5+
CheckIcon,
6+
ChevronRightIcon,
7+
Columns2Icon,
8+
Rows3Icon,
9+
TextWrapIcon,
10+
XIcon,
11+
} from "lucide-react";
512
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
613

714
import { openInPreferredEditor } from "../editorPreferences";
@@ -57,10 +64,7 @@ const DIFF_PANEL_UNSAFE_CSS = `
5764
}
5865
5966
[data-file-info] {
60-
background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;
61-
border-block-color: var(--border) !important;
62-
color: var(--foreground) !important;
63-
padding-right: 5.75rem !important;
67+
display: none !important;
6468
}
6569
6670
[data-diffs-header] {
@@ -172,6 +176,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
172176
const [diffWordWrap, setDiffWordWrap] = useState(false);
173177
const [selectedCategory, setSelectedCategory] = useState<FileDiffCategory>("all");
174178
const [acceptedFileKeys, setAcceptedFileKeys] = useState<Set<string>>(() => new Set());
179+
const [collapsedFileKeys, setCollapsedFileKeys] = useState<Set<string>>(() => new Set());
175180
const patchViewportRef = useRef<HTMLDivElement>(null);
176181
const previousDiffOpenRef = useRef(false);
177182

@@ -285,17 +290,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
285290
deleted: 0,
286291
renamed: 0,
287292
};
288-
for (const fileDiff of remainingFiles) {
293+
for (const fileDiff of renderableFiles) {
289294
const category = categorizeFileDiff(fileDiff);
290295
counts[category]++;
291296
}
292-
return { all: remainingFiles.length, ...counts };
293-
}, [remainingFiles]);
297+
return { all: renderableFiles.length, ...counts };
298+
}, [renderableFiles]);
294299

295300
const filteredFiles = useMemo(() => {
296-
if (selectedCategory === "all") return remainingFiles;
297-
return remainingFiles.filter((fileDiff) => categorizeFileDiff(fileDiff) === selectedCategory);
298-
}, [remainingFiles, selectedCategory]);
301+
if (selectedCategory === "all") return renderableFiles;
302+
return renderableFiles.filter((fileDiff) => categorizeFileDiff(fileDiff) === selectedCategory);
303+
}, [renderableFiles, selectedCategory]);
299304

300305
useEffect(() => {
301306
if (diffOpen && !previousDiffOpenRef.current) {
@@ -307,16 +312,31 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
307312

308313
useEffect(() => {
309314
setAcceptedFileKeys(new Set());
315+
setCollapsedFileKeys(new Set());
310316
}, [selectedPatch]);
311317

312318
useEffect(() => {
313319
if (!selectedFilePath || !patchViewportRef.current) {
314320
return;
315321
}
316-
const target = Array.from(
317-
patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
318-
).find((element) => element.dataset.diffFilePath === selectedFilePath);
319-
target?.scrollIntoView({ block: "nearest" });
322+
const selectedFile = renderableFiles.find(
323+
(f) => resolveFileDiffPath(f) === selectedFilePath,
324+
);
325+
if (selectedFile) {
326+
const key = buildFileDiffRenderKey(selectedFile);
327+
setCollapsedFileKeys((current) => {
328+
if (!current.has(key)) return current;
329+
const next = new Set(current);
330+
next.delete(key);
331+
return next;
332+
});
333+
}
334+
requestAnimationFrame(() => {
335+
const target = Array.from(
336+
patchViewportRef.current?.querySelectorAll<HTMLElement>("[data-diff-file-path]") ?? [],
337+
).find((element) => element.dataset.diffFilePath === selectedFilePath);
338+
target?.scrollIntoView({ block: "nearest" });
339+
});
320340
}, [selectedFilePath, renderableFiles]);
321341

322342
const openDiffFileInEditor = useCallback(
@@ -333,6 +353,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
333353

334354
const acceptFile = useCallback((fileDiff: FileDiffMetadata) => {
335355
const fileKey = buildAcceptedDiffFileKey(fileDiff);
356+
const renderKey = buildFileDiffRenderKey(fileDiff);
336357
startTransition(() => {
337358
setAcceptedFileKeys((current) => {
338359
if (current.has(fileKey)) {
@@ -342,6 +363,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
342363
next.add(fileKey);
343364
return next;
344365
});
366+
setCollapsedFileKeys((current) => {
367+
if (current.has(renderKey)) return current;
368+
const next = new Set(current);
369+
next.add(renderKey);
370+
return next;
371+
});
345372
});
346373
}, []);
347374

@@ -357,11 +384,29 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
357384
}
358385
return next;
359386
});
387+
setCollapsedFileKeys((current) => {
388+
const next = new Set(current);
389+
for (const fileDiff of remainingFiles) {
390+
next.add(buildFileDiffRenderKey(fileDiff));
391+
}
392+
return next;
393+
});
360394
});
361395
}, [remainingFiles]);
362396

363-
const allFilesAccepted = renderableFiles.length > 0 && remainingFiles.length === 0;
364-
const noFilesInSelectedCategory = !allFilesAccepted && filteredFiles.length === 0;
397+
const toggleFileCollapse = useCallback((fileKey: string) => {
398+
setCollapsedFileKeys((current) => {
399+
const next = new Set(current);
400+
if (next.has(fileKey)) {
401+
next.delete(fileKey);
402+
} else {
403+
next.add(fileKey);
404+
}
405+
return next;
406+
});
407+
}, []);
408+
409+
const noFilesInSelectedCategory = filteredFiles.length === 0;
365410

366411
const headerRow = (
367412
<>
@@ -485,13 +530,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
485530
</div>
486531
)
487532
) : renderablePatch.kind === "files" ? (
488-
allFilesAccepted || noFilesInSelectedCategory ? (
533+
noFilesInSelectedCategory ? (
489534
<div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
490-
<p>
491-
{allFilesAccepted
492-
? "All file changes accepted."
493-
: `No remaining ${CATEGORY_LABELS[selectedCategory].toLowerCase()} changes.`}
494-
</p>
535+
<p>{`No ${CATEGORY_LABELS[selectedCategory].toLowerCase()} changes.`}</p>
495536
</div>
496537
) : (
497538
<Virtualizer
@@ -505,44 +546,113 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
505546
const filePath = resolveFileDiffPath(fileDiff);
506547
const fileKey = buildFileDiffRenderKey(fileDiff);
507548
const themedFileKey = `${fileKey}:${resolvedTheme}`;
549+
const isAccepted = acceptedFileKeys.has(
550+
buildAcceptedDiffFileKey(fileDiff),
551+
);
552+
const isCollapsed = collapsedFileKeys.has(fileKey);
553+
const changeType = categorizeFileDiff(fileDiff);
508554
return (
509555
<div
510556
key={themedFileKey}
511557
data-diff-file-path={filePath}
512-
className="diff-render-file relative mb-2 rounded-md first:mt-2 last:mb-0"
513-
onClickCapture={(event) => {
514-
const nativeEvent = event.nativeEvent as MouseEvent;
515-
const composedPath = nativeEvent.composedPath?.() ?? [];
516-
const clickedHeader = composedPath.some((node) => {
517-
if (!(node instanceof Element)) return false;
518-
return node.hasAttribute("data-title");
519-
});
520-
if (!clickedHeader) return;
521-
openDiffFileInEditor(filePath);
522-
}}
558+
className="diff-render-file mb-2 first:mt-2 last:mb-0"
523559
>
524-
<div className="pointer-events-none absolute right-2 top-2 z-10">
525-
<Button
560+
<div
561+
className={cn(
562+
"overflow-hidden rounded-md border transition-colors duration-150",
563+
isAccepted ? "border-border/40" : "border-border/70",
564+
)}
565+
>
566+
<button
526567
type="button"
527-
size="xs"
528-
variant="secondary"
529-
className="pointer-events-auto"
530-
onClick={() => acceptFile(fileDiff)}
568+
onClick={() => toggleFileCollapse(fileKey)}
569+
className={cn(
570+
"flex w-full items-center gap-2 px-3 py-2 text-left",
571+
"bg-[color-mix(in_srgb,var(--card)_94%,var(--foreground))]",
572+
"hover:bg-[color-mix(in_srgb,var(--card)_90%,var(--foreground))]",
573+
"transition-colors duration-150",
574+
!isCollapsed && "border-b border-border/50",
575+
isAccepted && "opacity-60 hover:opacity-80",
576+
)}
531577
>
532-
Accept
533-
</Button>
578+
<ChevronRightIcon
579+
className={cn(
580+
"size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-200",
581+
!isCollapsed && "rotate-90",
582+
)}
583+
/>
584+
<span
585+
role="link"
586+
tabIndex={0}
587+
className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground/90 hover:text-foreground hover:underline hover:underline-offset-2"
588+
onClick={(e) => {
589+
e.stopPropagation();
590+
openDiffFileInEditor(filePath);
591+
}}
592+
onKeyDown={(e) => {
593+
if (e.key === "Enter") {
594+
e.stopPropagation();
595+
openDiffFileInEditor(filePath);
596+
}
597+
}}
598+
>
599+
{filePath}
600+
</span>
601+
<span
602+
className={cn(
603+
"shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium leading-none",
604+
changeType === "added" &&
605+
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
606+
changeType === "deleted" &&
607+
"bg-red-500/15 text-red-600 dark:text-red-400",
608+
changeType === "renamed" &&
609+
"bg-blue-500/15 text-blue-600 dark:text-blue-400",
610+
changeType === "modified" &&
611+
"bg-amber-500/15 text-amber-600 dark:text-amber-400",
612+
)}
613+
>
614+
{changeType === "added"
615+
? "A"
616+
: changeType === "deleted"
617+
? "D"
618+
: changeType === "renamed"
619+
? "R"
620+
: "M"}
621+
</span>
622+
{isAccepted ? (
623+
<span className="flex shrink-0 items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-600 dark:text-emerald-400">
624+
<CheckIcon className="size-3" />
625+
Accepted
626+
</span>
627+
) : (
628+
<Button
629+
type="button"
630+
size="xs"
631+
variant="secondary"
632+
onClick={(e) => {
633+
e.stopPropagation();
634+
acceptFile(fileDiff);
635+
}}
636+
>
637+
Accept
638+
</Button>
639+
)}
640+
</button>
641+
{!isCollapsed && (
642+
<FileDiff
643+
fileDiff={fileDiff}
644+
options={{
645+
diffStyle:
646+
diffRenderMode === "split" ? "split" : "unified",
647+
lineDiffType: "none",
648+
overflow: diffWordWrap ? "wrap" : "scroll",
649+
theme: resolveDiffThemeName(resolvedTheme),
650+
themeType: resolvedTheme as DiffThemeType,
651+
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
652+
}}
653+
/>
654+
)}
534655
</div>
535-
<FileDiff
536-
fileDiff={fileDiff}
537-
options={{
538-
diffStyle: diffRenderMode === "split" ? "split" : "unified",
539-
lineDiffType: "none",
540-
overflow: diffWordWrap ? "wrap" : "scroll",
541-
theme: resolveDiffThemeName(resolvedTheme),
542-
themeType: resolvedTheme as DiffThemeType,
543-
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
544-
}}
545-
/>
546656
</div>
547657
);
548658
})}

0 commit comments

Comments
 (0)