Files
backnotprop__plannotator/packages/review-editor/components/LazyFileDiff.tsx
T
Michael Ramos 0e4d301925 Review UI refinements: separator styling and header layout (#683)
* Collapse viewed files in all-files review

* Style diff hunk separators to be slimmer and subtler

Reduce separator height from 32px to 24px, use theme-integrated
semi-transparent backgrounds via --diffs-bg-separator-override,
and fade text/buttons with opacity for a less visually heavy bar.

* Refine review header layout

Move sidebar toggles to far right, options menu to their left.
Add divider between file tree button and repo label.
Remove worktree pill from header (still accessible via menu).

* Fix gutter drag selecting only one line

Switch from deprecated enableHoverUtility + renderHoverUtility to
enableGutterUtility + onGutterUtilityClick. The old API never entered
Pierre's gutterSelecting mode, so dragging the gutter button always
reported a single line. The new API receives the full drag range.

Closes #679
2026-05-07 15:54:06 -07:00

119 lines
3.8 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
import type { FileDiffMetadata } from '@pierre/diffs';
import { processFile } from '@pierre/diffs';
import type { DiffAnnotationMetadata, SelectedLineRange } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
interface LazyFileDiffProps {
file: DiffFile;
baseDiff: FileDiffMetadata;
forceMount?: boolean;
scrollRoot: HTMLElement | null;
reviewBase?: string;
diffStyle: 'split' | 'unified';
options: Record<string, unknown>;
annotations: DiffLineAnnotation<DiffAnnotationMetadata>[];
selectedLines: SelectedLineRange | undefined;
renderAnnotation: (annotation: DiffLineAnnotation<DiffAnnotationMetadata>) => React.ReactNode;
}
function estimateHeight(fileDiff: FileDiffMetadata, diffStyle: 'split' | 'unified'): number {
const lineCount = diffStyle === 'split' ? fileDiff.splitLineCount : fileDiff.unifiedLineCount;
return (lineCount * 20) + (fileDiff.hunks.length * 32) + 8;
}
export const LazyFileDiff: React.FC<LazyFileDiffProps> = ({
file,
baseDiff,
forceMount = false,
scrollRoot,
reviewBase,
diffStyle,
options,
annotations,
selectedLines,
renderAnnotation,
}) => {
const [mounted, setMounted] = useState(forceMount);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (forceMount && !mounted) setMounted(true);
}, [forceMount, mounted]);
useEffect(() => {
if (mounted) return;
const el = sentinelRef.current;
if (!el || !scrollRoot) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setMounted(true);
observer.disconnect();
}
},
{ root: scrollRoot, rootMargin: '100% 0px 100% 0px', threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [mounted, scrollRoot]);
// Per-file content fetching (same pattern as DiffViewer.tsx)
const [fileContents, setFileContents] = useState<{ old: string | null; new: string | null } | null>(null);
useEffect(() => {
if (!mounted) return;
setFileContents(null);
const controller = new AbortController();
const params = new URLSearchParams({ path: file.path });
if (file.oldPath) params.set('oldPath', file.oldPath);
if (reviewBase) params.set('base', reviewBase);
fetch(`/api/file-content?${params}`, { signal: controller.signal })
.then(res => res.ok ? res.json() : null)
.then((data: { oldContent: string | null; newContent: string | null } | null) => {
if (data && (data.oldContent != null || data.newContent != null)) {
setFileContents({ old: data.oldContent, new: data.newContent });
}
})
.catch(() => {});
return () => controller.abort();
}, [mounted, file.path, file.oldPath, file.patch, reviewBase]);
const fileDiff = useMemo(() => {
if (!fileContents) return baseDiff;
try {
const result = processFile(file.patch, {
oldFile: fileContents.old != null ? { name: file.oldPath || file.path, contents: fileContents.old } : undefined,
newFile: fileContents.new != null ? { name: file.path, contents: fileContents.new } : undefined,
});
return result || baseDiff;
} catch {
return baseDiff;
}
}, [file.patch, file.path, file.oldPath, fileContents, baseDiff]);
if (!mounted) {
return (
<div
ref={sentinelRef}
style={{ height: estimateHeight(baseDiff, diffStyle) }}
className="pb-2"
/>
);
}
return (
<div className="pb-2">
<FileDiff
key={file.path}
fileDiff={fileDiff}
options={options}
lineAnnotations={annotations}
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
/>
</div>
);
};