- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container. - Added lazy loading functionality to improve performance by loading file content as it approaches the viewport. - Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews. - Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation. - Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options. - Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
import { type RefObject, useCallback, useEffect, useRef } from 'react';
|
|
|
|
import type { FileChangeWithContent } from '@shared/types';
|
|
|
|
const MAX_CONCURRENT = 3;
|
|
const PRELOAD_COUNT = 5;
|
|
|
|
interface UseLazyFileContentOptions {
|
|
teamName: string;
|
|
memberName: string | undefined;
|
|
filePaths: string[];
|
|
scrollContainerRef: RefObject<HTMLElement | null>;
|
|
fileContents: Record<string, FileChangeWithContent>;
|
|
fileContentsLoading: Record<string, boolean>;
|
|
fetchFileContent: (
|
|
teamName: string,
|
|
memberName: string | undefined,
|
|
filePath: string
|
|
) => Promise<void>;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface UseLazyFileContentReturn {
|
|
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
|
|
}
|
|
|
|
export function useLazyFileContent(options: UseLazyFileContentOptions): UseLazyFileContentReturn {
|
|
const { enabled, scrollContainerRef } = options;
|
|
|
|
const activeLoads = useRef(new Set<string>());
|
|
const pendingQueue = useRef<string[]>([]);
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const elementRefs = useRef(new Map<string, HTMLElement>());
|
|
|
|
// Stable ref to avoid stale closures in observer/processQueue callbacks
|
|
const optionsRef = useRef(options);
|
|
useEffect(() => {
|
|
optionsRef.current = options;
|
|
});
|
|
|
|
const shouldLoad = useCallback((filePath: string): boolean => {
|
|
const opts = optionsRef.current;
|
|
if (opts.fileContents[filePath]) return false;
|
|
if (opts.fileContentsLoading[filePath]) return false;
|
|
if (activeLoads.current.has(filePath)) return false;
|
|
return true;
|
|
}, []);
|
|
|
|
// Refs for loadFile/processQueue to avoid circular useCallback deps
|
|
const loadFileRef = useRef<(fp: string) => Promise<void>>();
|
|
const processQueueRef = useRef<() => void>();
|
|
|
|
loadFileRef.current = async (filePath: string) => {
|
|
if (!shouldLoad(filePath)) return;
|
|
activeLoads.current.add(filePath);
|
|
try {
|
|
const opts = optionsRef.current;
|
|
await opts.fetchFileContent(opts.teamName, opts.memberName, filePath);
|
|
} finally {
|
|
activeLoads.current.delete(filePath);
|
|
processQueueRef.current?.();
|
|
}
|
|
};
|
|
|
|
processQueueRef.current = () => {
|
|
while (activeLoads.current.size < MAX_CONCURRENT && pendingQueue.current.length > 0) {
|
|
const nextPath = pendingQueue.current.shift()!;
|
|
if (shouldLoad(nextPath)) {
|
|
void loadFileRef.current?.(nextPath);
|
|
}
|
|
}
|
|
};
|
|
|
|
const enqueueLoad = useCallback(
|
|
(filePath: string) => {
|
|
if (!shouldLoad(filePath)) return;
|
|
|
|
if (activeLoads.current.size < MAX_CONCURRENT) {
|
|
void loadFileRef.current?.(filePath);
|
|
} else {
|
|
if (!pendingQueue.current.includes(filePath)) {
|
|
pendingQueue.current.push(filePath);
|
|
}
|
|
}
|
|
},
|
|
[shouldLoad]
|
|
);
|
|
|
|
// Preload first N files on mount
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const toPreload = optionsRef.current.filePaths.slice(0, PRELOAD_COUNT);
|
|
for (const fp of toPreload) {
|
|
enqueueLoad(fp);
|
|
}
|
|
}, [enabled, enqueueLoad]);
|
|
|
|
// IntersectionObserver for lazy loading
|
|
useEffect(() => {
|
|
if (!enabled || !scrollContainerRef.current) return;
|
|
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue;
|
|
const filePath = entry.target.getAttribute('data-lazy-file');
|
|
if (!filePath) continue;
|
|
enqueueLoad(filePath);
|
|
}
|
|
},
|
|
{
|
|
root: scrollContainerRef.current,
|
|
rootMargin: '200% 0px 200% 0px',
|
|
threshold: 0,
|
|
}
|
|
);
|
|
|
|
// Observe already mounted elements
|
|
for (const [, element] of elementRefs.current) {
|
|
observerRef.current.observe(element);
|
|
}
|
|
|
|
return () => {
|
|
observerRef.current?.disconnect();
|
|
observerRef.current = null;
|
|
};
|
|
}, [enabled, scrollContainerRef, enqueueLoad]);
|
|
|
|
const registerLazyRef = useCallback((filePath: string) => {
|
|
return (element: HTMLElement | null) => {
|
|
const observer = observerRef.current;
|
|
|
|
const prev = elementRefs.current.get(filePath);
|
|
if (prev && observer) {
|
|
observer.unobserve(prev);
|
|
}
|
|
elementRefs.current.delete(filePath);
|
|
|
|
if (element) {
|
|
element.setAttribute('data-lazy-file', filePath);
|
|
elementRefs.current.set(filePath, element);
|
|
if (observer) {
|
|
observer.observe(element);
|
|
}
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return { registerLazyRef };
|
|
}
|