diff --git a/apps/docs/app/(diffs)/docs/CodeView/constants.ts b/apps/docs/app/(diffs)/docs/CodeView/constants.ts index a1000fd24..594eccb2d 100644 --- a/apps/docs/app/(diffs)/docs/CodeView/constants.ts +++ b/apps/docs/app/(diffs)/docs/CodeView/constants.ts @@ -35,12 +35,17 @@ const readmeFile = { contents: '# Docs\n\nThis file is rendered inline with the diff list.', }; +const changelogFile = { + name: 'CHANGELOG.md', + contents: '# Changelog\n\n- Added personalized greetings.', +}; + export function ReviewSurface() { const viewerRef = useRef(null); const [selectedLines, setSelectedLines] = useState(null); - const items = useMemo( + const initialItems = useMemo( () => [ { id: 'diff:src/app.ts', @@ -74,9 +79,48 @@ export function ReviewSurface() { Jump to change + + + + - item.id === 'diff:src/app.ts' && item.type === 'diff' - ? { - ...item, - version: 2, - annotations: [{ side: 'additions', lineNumber: 2 }], - } - : item -); +const appItem = viewer.getItem('diff:src/app.ts'); +if (appItem?.type === 'diff') { + viewer.updateItem({ + ...appItem, + version: 2, + annotations: [{ side: 'additions', lineNumber: 2 }], + }); +} -viewer.setItems(nextItems); +viewer.addItems([ + { + id: 'file:CHANGELOG.md', + type: 'file', + file: { + name: 'CHANGELOG.md', + contents: '# Changelog\n\n- Added personalized greetings.', + }, + }, +]); window.addEventListener('beforeunload', () => { viewer.cleanUp(); diff --git a/apps/docs/app/(diffs)/docs/CodeView/content.mdx b/apps/docs/app/(diffs)/docs/CodeView/content.mdx index b33e9e232..09720f690 100644 --- a/apps/docs/app/(diffs)/docs/CodeView/content.mdx +++ b/apps/docs/app/(diffs)/docs/CodeView/content.mdx @@ -5,9 +5,10 @@ can contain files, diffs, or a mix of both. -`CodeView` takes an ordered `CodeViewItem[]` list and manages the hard parts for -you: virtualization, measured layout reconciliation, sticky headers, selection -across items, and `scrollTo` targeting by item, line, or absolute position. +`CodeView` renders an ordered `CodeViewItem[]` list and manages the hard parts +for you: virtualization, measured layout reconciliation, sticky headers, +selection across items, and `scrollTo` targeting by item, line, or absolute +position. Use it when the amount of content is large, unbounded, or hard to predict. It is meant to handle anything from a single file up to very large multi-file diff @@ -26,7 +27,7 @@ yourself. ### Core Model - Every item needs a stable unique `id`. That id is how `scrollTo`, selection, - and reconciliation find the correct record. + `getItem`, `updateItem`, and reconciliation find the correct record. - Items are either `{ type: 'file', file }` or `{ type: 'diff', fileDiff }`. - If you keep the same item id but change its content or annotations, publish a new `version` so `CodeView` refreshes that item in place without rebuilding @@ -48,13 +49,35 @@ yourself. vanillaExample={codeViewVanillaExample} /> +### React Item Ownership + +React `CodeView` supports two item ownership models. Use one per mounted viewer; +do not switch between them without remounting with a new `key`. + +| Mode | Use | Item prop | Item updates | +| ---------- | -------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | +| Controlled | React state owns the complete item list | `items` | Publish a new `items` array. Append-only changes are optimized; other changes reconcile the list. | +| Imperative | The viewer instance owns the item list after mount | optional `initialItems` | Use the ref APIs: `addItems`, `getItem`, and `updateItem`. | + +Use controlled mode when item data already lives naturally in React state and +the list is small enough that mutating arrays or items is cheap. Use imperative +mode for very large or streaming surfaces where routing every item update +through React would be expensive. In imperative mode, omit `items`, optionally +seed the viewer with `initialItems`, and use the `CodeViewHandle` to add new +items or update existing ones. + ### Usage Notes -- In React, `CodeView` is controlled through props plus an imperative ref. +- In React, pass `items` for controlled item ownership. +- In React, pass `initialItems` instead of `items` for imperative item + ownership. `initialItems` seeds the viewer once; later item changes should go + through the ref. +- In React, `addItems` and `updateItem` require imperative item ownership and + throw if the viewer is controlled with `items`. - In React, use `selectedLines` and `onSelectedLinesChange` when selection needs to live in component state. -- In React, use the ref for `scrollTo`, `setSelectedLines`, - `clearSelectedLines`, and `getWindowSpecs`. +- In React, use the ref for `scrollTo`, `setSelectedLines`, `getSelectedLines`, + `clearSelectedLines`, `getItem`, `updateItem`, `addItems`, and `getInstance`. - `renderCustomHeader`, `renderHeaderPrefix`, `renderHeaderMetadata`, `renderAnnotation`, and `renderGutterUtility` receive the whole `CodeViewItem`, which makes it easy to branch on `item.type`. @@ -62,7 +85,7 @@ yourself. update over time. - In Vanilla JS, call `setup(root)` once with the scrollable container. - In Vanilla JS, use `setItems`, `addItem`, or `addItems` to populate the - viewer. + viewer, and `getItem` / `updateItem` for item-level imperative changes. - Shared callbacks receive the normal file/diff payload plus a `context` argument containing the current viewer item and instance. - By default, `CodeView` temporarily disables pointer events on rendered content diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts index 82b0d4faf..fb0215aca 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts @@ -643,6 +643,9 @@ const readmeFile = { }; export function ReviewSurface() { + // Pass \`items\` when React owns the full item list. Use \`initialItems\` plus a + // ref instead when item updates should be imperative; omit both item props to + // start empty and append later. const items = useMemo( () => [ { diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx index 24b4d510e..2eed9d9aa 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx @@ -34,7 +34,8 @@ The React API exposes six main components: input and remount (for example, with a changing `key`) when you want to reset. The `CodeView` tab above is the quick-start version. For the full guide on -items, ids, `version`, selection, and `scrollTo`, see [CodeView](#code-view). +controlled `items`, imperative `initialItems`, ids, `version`, selection, and +`scrollTo`, see [CodeView](#code-view). ### Shared Props @@ -44,8 +45,9 @@ component has similar props, but uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). `CodeView` reuses many of the same option names internally, but it has its own -`items`, viewer ref, and mixed-item render props. See [CodeView](#code-view) for -the dedicated guide. +controlled `items` mode, imperative mode with optional `initialItems`, viewer +ref, and mixed-item render props. See [CodeView](#code-view) for the dedicated +guide. Header customization and collapsing behavior: diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts index 86c5a5cdf..96ac8ed8d 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts @@ -188,6 +188,26 @@ const items: CodeViewItem[] = [ viewer.setItems(items); +const appItem = viewer.getItem('diff:src/app.ts'); +if (appItem?.type === 'diff') { + viewer.updateItem({ + ...appItem, + version: 2, + annotations: [{ side: 'additions', lineNumber: 2 }], + }); +} + +viewer.addItems([ + { + id: 'file:CHANGELOG.md', + type: 'file', + file: { + name: 'CHANGELOG.md', + contents: '# Changelog\n\n- Added personalized greetings.', + }, + }, +]); + window.addEventListener('beforeunload', () => { viewer.cleanUp(); });`, diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx index 45b292301..016e41f44 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx @@ -29,8 +29,8 @@ theming, and interactivity for you. (`onMergeConflictResolve` / `onMergeConflictAction`). The `CodeView` tab above is the quick-start version. For the deeper guide on -`setup`, `setItems`, `version`, selection, and `scrollTo`, see -[CodeView](#code-view). +`setup`, `setItems`, `addItems`, `getItem`, `updateItem`, selection, and +`scrollTo`, see [CodeView](#code-view). ### Props @@ -40,8 +40,9 @@ uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). `CodeView` forwards many of those same options to each rendered item, while adding viewer-specific controls like `viewerMetrics`, `itemMetrics`, -`stickyHeaders`, `pointerEventsOnScroll`, and `smoothScrollSettings`. See -[CodeView](#code-view) for the dedicated guide. +`stickyHeaders`, `pointerEventsOnScroll`, and `smoothScrollSettings`. Its class +instance also exposes item-level methods such as `addItems`, `getItem`, and +`updateItem`. See [CodeView](#code-view) for the dedicated guide. Header customization and collapsing behavior: diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx index fe5c256e9..fe963db4e 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewDiffStats.tsx @@ -8,10 +8,12 @@ import { StatItem, StatusRow } from './WorkerPoolStatus'; interface CodeViewDiffStatsProps { stats: CodeViewDiffStatsData | null; + streaming: boolean; } export const CodeViewDiffStats = memo(function CodeViewDiffStats({ stats, + streaming, }: CodeViewDiffStatsProps) { const [showStats, setShowStats] = useState(true); @@ -40,6 +42,7 @@ export const CodeViewDiffStats = memo(function CodeViewDiffStats({ aria-expanded={showStats} > Diff Stats + {streaming && } (F2) @@ -70,3 +73,11 @@ export const CodeViewDiffStats = memo(function CodeViewDiffStats({ ); }); + +function StreamingIndicator() { + return ( + + streaming + + ); +} diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx index 595a4f70b..a4d8b6a23 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewFileTree.tsx @@ -22,9 +22,9 @@ interface CodeViewFileTreeProps { // Callback invoked with the underlying tree model once it's mounted, and // again with `null` on unmount. Lets parents drive imperative APIs like // search open/close without owning the model creation. - onModelReady?(model: FileTreeModel | null): void; - onSelectItem?(itemId: string): void; - source: CodeViewFileTreeSource | null; + onModelReady(model: FileTreeModel | null): void; + onSelectItem(itemId: string): void; + source: CodeViewFileTreeSource; } export const CodeViewFileTree = memo(function CodeViewFileTree({ @@ -33,43 +33,12 @@ export const CodeViewFileTree = memo(function CodeViewFileTree({ onSelectItem, source, }: CodeViewFileTreeProps) { - const previousSourceRef = useRef(null); - const sourceVersionRef = useRef(0); - - if (source == null) { - previousSourceRef.current = null; - return null; - } - - if (source !== previousSourceRef.current) { - previousSourceRef.current = source; - sourceVersionRef.current += 1; - } - - return ( - + const sourceRef = useRef(source); + const previousSourceRef = useRef(source); + sourceRef.current = source; + const sort = useStableCallback( + (left, right) => sourceRef.current.sort(left, right) ); -}); - -interface CodeViewFileTreeContentProps extends Omit< - CodeViewFileTreeProps, - 'source' -> { - source: CodeViewFileTreeSource; -} - -function CodeViewFileTreeContent({ - className, - onModelReady, - onSelectItem, - source, -}: CodeViewFileTreeContentProps) { const onSelectionChange = useStableCallback( (selectedPaths: readonly FileTreePublicId[]) => { if (selectedPaths.length !== 1 || onSelectItem == null) { @@ -87,11 +56,21 @@ function CodeViewFileTreeContent({ ...BASE_FILE_TREE_OPTIONS, gitStatus: source.gitStatus, paths: source.paths, - sort: source.sort, + sort, onSelectionChange, itemHeight: 24, }); + useEffect(() => { + if (previousSourceRef.current === source) { + return; + } + + previousSourceRef.current = source; + model.resetPaths(source.paths); + model.setGitStatus(source.gitStatus); + }, [model, source]); + useEffect(() => { onModelReady?.(model); return () => { @@ -109,4 +88,4 @@ function CodeViewFileTreeContent({ style={DENSITY_OVERRIDE_STYLES} /> ); -} +}); diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx index 1b66a8db7..fe27c2909 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewSidebar.tsx @@ -26,11 +26,12 @@ interface CodeViewSidebarProps { commentSections: readonly CodeViewSavedCommentItem[]; diffStats: CodeViewDiffStatsData | null; mobileOverlayOpen?: boolean; - onMobileClose?(): void; - onSelectComment?(comment: CodeViewSavedCommentEntry): void; - onSelectItem?(itemId: string): void; + onMobileClose(): void; + onSelectComment(comment: CodeViewSavedCommentEntry): void; + onSelectItem(itemId: string): void; scrollRef: RefObject; source: CodeViewFileTreeSource | null; + streaming: boolean; } export const CodeViewSidebar = memo(function CodeViewSidebar({ @@ -43,6 +44,7 @@ export const CodeViewSidebar = memo(function CodeViewSidebar({ onSelectItem, scrollRef, source, + streaming, }: CodeViewSidebarProps) { const [activeTab, setActiveTab] = useState('files'); const [fileTreeModel, setFileTreeModel] = useState(null); @@ -116,12 +118,14 @@ export const CodeViewSidebar = memo(function CodeViewSidebar({ hidden={activeTab !== 'files'} className="h-full min-h-0" > - + {source != null && ( + + )}
- {source != null && } + {source != null && ( + + )} {source != null && } diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx index 6d668d8a5..039e1065e 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewStatusPanel.tsx @@ -19,12 +19,16 @@ export function CodeViewStatusPanel({ ? 'Couldn’t load diff' : state === 'parsing' ? 'Preparing diff' - : 'Fetching diff'; + : state === 'streaming' + ? 'Streaming diff' + : 'Fetching diff'; const message = isError ? (errorMessage ?? 'Failed to fetch the diff, please try again.') : state === 'parsing' ? 'Parsing the patch and building the file tree…' - : 'Fetching the patch from GitHub…'; + : state === 'streaming' + ? 'Reading the patch and showing files as they arrive…' + : 'Fetching the patch from GitHub…'; return (
diff --git a/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx index 36a681283..cab2f8e50 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx @@ -16,15 +16,7 @@ import { useStableCallback, } from '@pierre/diffs/react'; import { IconChevronSm } from '@pierre/icons'; -import { - type Dispatch, - memo, - type RefObject, - type SetStateAction, - useMemo, - useRef, - useState, -} from 'react'; +import { memo, type RefObject, useMemo, useRef, useState } from 'react'; import { CODE_VIEW_CUSTOM_CSS, @@ -39,7 +31,6 @@ import type { CommentMetadata, } from './types'; import { - incrementItemVersion, isDiffItem, isDraftAnnotation, isDraftMetadata, @@ -53,6 +44,33 @@ const VIEWER_METRICS = { paddingTop: CODE_VIEW_PADDING_BLOCK + CODE_VIEW_MARGIN_OFFSET, }; +function getNextItemVersion(item: CodeViewItem): number { + return typeof item.version === 'number' ? item.version + 1 : 1; +} + +function updateViewerDiffItem( + viewer: CodeViewHandle, + itemId: string, + updateItem: (item: CodeViewDiffItem) => boolean +): CodeViewDiffItem | undefined { + const item = viewer.getItem(itemId); + if (item == null || !isDiffItem(item)) { + return undefined; + } + + if (!updateItem(item)) { + return undefined; + } + + item.version = getNextItemVersion(item); + return viewer.updateItem(item) ? item : undefined; +} + +interface ActiveDraftComment { + itemId: string; + key: string; +} + interface CodeViewWrapperProps { className?: string; diffStyle: 'split' | 'unified'; @@ -64,8 +82,7 @@ interface CodeViewWrapperProps { lineNumbers: boolean; scrollRef: RefObject; viewerRef: RefObject | null>; - items: CodeViewItem[]; - setItems: Dispatch[]>>; + initialItems: CodeViewItem[]; } export const CodeViewWrapper = memo(function CodeViewWrapper({ @@ -79,10 +96,10 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ lineNumbers, scrollRef, viewerRef, - items, - setItems, + initialItems, }: CodeViewWrapperProps) { const nextCommentKeyRef = useRef(0); + const activeDraftRef = useRef(null); const [selectedLines, setSelectedLines] = useState(null); @@ -112,74 +129,72 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ const lineNumber = range.end; const commentKey = `draft-${nextCommentKeyRef.current++}`; - setItems((prev) => { - const next = [...prev]; - let changed = false; + const { current: viewer } = viewerRef; + if (viewer == null) { + return; + } - for (const item of next) { - if (item.type !== 'diff' || item.annotations == null) { - continue; + const draftAnnotation: DiffLineAnnotation = { + side, + lineNumber, + metadata: { + kind: 'draft', + key: commentKey, + message: '', + range, + }, + }; + + const { current: activeDraft } = activeDraftRef; + if (activeDraft != null && activeDraft.itemId !== itemId) { + updateViewerDiffItem(viewer, activeDraft.itemId, (item) => { + if (item.annotations == null) { + return false; } const nextAnnotations = item.annotations.filter( - (annotation) => !isDraftMetadata(annotation.metadata) + (annotation) => annotation.metadata.key !== activeDraft.key ); - if (nextAnnotations.length === item.annotations.length) { - continue; + return false; } item.annotations = nextAnnotations; - incrementItemVersion(item); - changed = true; - } + return true; + }); + } - const item = next.find( - (candidate): candidate is CodeViewDiffItem => - candidate.id === itemId && isDiffItem(candidate) + const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => { + const nonDraftAnnotations = (item.annotations ?? []).filter( + (annotation) => !isDraftMetadata(annotation.metadata) ); - - if (item == null) { - return changed ? next : prev; - } - - const nextAnnotations = [...(item.annotations ?? [])]; - nextAnnotations.push({ - side, - lineNumber, - metadata: { - kind: 'draft', - key: commentKey, - message: '', - range, - }, - }); - item.annotations = nextAnnotations; - incrementItemVersion(item); - return next; + item.annotations = [...nonDraftAnnotations, draftAnnotation]; + return true; }); + + if (updatedItem != null) { + activeDraftRef.current = { itemId, key: commentKey }; + } } ); const handleRemoveComment = useStableCallback( (itemId: string, key: string) => { - const item = items.find( - (candidate): candidate is CodeViewDiffItem => - candidate.id === itemId && isDiffItem(candidate) - ); - const removedAnnotation = item?.annotations?.find( - (annotation) => annotation.metadata.key === key - ); - - setItems((prev) => { - const next = [...prev]; - const item = next.find( - (candidate): candidate is CodeViewDiffItem => - candidate.id === itemId && isDiffItem(candidate) - ); - - if (item == null || item.annotations == null) { - return prev; + const { current: viewer } = viewerRef; + if (viewer == null) { + return; + } + const item = viewer.getItem(itemId); + const removedAnnotation = + item != null && isDiffItem(item) + ? item.annotations?.find( + (annotation) => annotation.metadata.key === key + ) + : undefined; + + updateViewerDiffItem(viewer, itemId, (item) => { + if (item.annotations == null) { + return false; } const nextAnnotations = item.annotations.filter( @@ -187,14 +202,18 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ ); if (nextAnnotations.length === item.annotations.length) { - return prev; + return false; } item.annotations = nextAnnotations; - incrementItemVersion(item); - return next; + return true; }); + const { current: activeDraft } = activeDraftRef; + if (activeDraft?.itemId === itemId && activeDraft.key === key) { + activeDraftRef.current = null; + } + setSelectedLines(null); if (removedAnnotation != null && isSavedAnnotation(removedAnnotation)) { onCommentDeleted?.({ itemId, key }); @@ -205,14 +224,16 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ const handleSaveDraftComment = useStableCallback( (itemId: string, key: string, message: string) => { const trimmedMessage = message.trim(); - if (trimmedMessage.length === 0) { + const { current: viewer } = viewerRef; + if (trimmedMessage.length === 0 || viewer == null) { + return; + } + + const item = viewer.getItem(itemId); + if (item == null || !isDiffItem(item)) { return; } - const item = items.find( - (candidate): candidate is CodeViewDiffItem => - candidate.id === itemId && isDiffItem(candidate) - ); const draftAnnotation = item?.annotations?.find( (annotation) => annotation.metadata.key === key ); @@ -220,15 +241,9 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ return; } - setItems((prev) => { - const next = [...prev]; - const item = next.find( - (candidate): candidate is CodeViewDiffItem => - candidate.id === itemId && isDiffItem(candidate) - ); - - if (item == null || item.annotations == null) { - return prev; + const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => { + if (item.annotations == null) { + return false; } const nextAnnotations: DiffLineAnnotation[] = @@ -261,14 +276,22 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ } if (!didChange) { - return prev; + return false; } item.annotations = nextAnnotations; - incrementItemVersion(item); - return next; + return true; }); + if (updatedItem == null) { + return; + } + + const { current: activeDraft } = activeDraftRef; + if (activeDraft?.itemId === itemId && activeDraft.key === key) { + activeDraftRef.current = null; + } + setSelectedLines(null); onCommentSaved?.({ author: 'you', @@ -283,39 +306,34 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ ); const handleToggleItemCollapsed = useStableCallback((itemId: string) => { - setItems((prev) => { - const viewer = viewerRef.current?.getInstance(); - const itemIndex = prev.findIndex( - (candidate) => candidate.id === itemId && isDiffItem(candidate) - ); - const item = prev[itemIndex]; - if (item == null || viewer == null) { - return prev; - } + const { current: viewerHandle } = viewerRef; + const viewer = viewerHandle?.getInstance(); + const item = viewerHandle?.getItem(itemId); + if (viewerHandle == null || viewer == null || item == null) { + return; + } - const next = [...prev]; - next[itemIndex] = { - ...item, - collapsed: item.collapsed !== true, - version: typeof item.version === 'number' ? item.version + 1 : 1, - }; - // NOTE(amadeus): If the top of the item is before the scrollTop, then - // we'll want to apply a scroll fix on the next render to ensure we - // keep the collapsed file in view and anchored - const itemTop = viewer.getTopForItem(itemId); - if ( - itemTop != null && - itemTop < viewer.getScrollTop() + CODE_VIEW_MARGIN_OFFSET - ) { - viewer.scrollTo({ - type: 'item', - id: item.id, - align: 'start', - offset: CODE_VIEW_MARGIN_OFFSET, - }); - } - return next; - }); + // NOTE(amadeus): If the top of the item is before the scrollTop, then + // we'll want to apply a scroll fix on the next render to ensure we + // keep the collapsed file in view and anchored. + const itemTop = viewer.getTopForItem(itemId); + item.collapsed = item.collapsed !== true; + item.version = getNextItemVersion(item); + if (!viewerHandle.updateItem(item)) { + return; + } + + if ( + itemTop != null && + itemTop < viewer.getScrollTop() + CODE_VIEW_MARGIN_OFFSET + ) { + viewer.scrollTo({ + type: 'item', + id: item.id, + align: 'start', + offset: CODE_VIEW_MARGIN_OFFSET, + }); + } }); const renderCommentAnnotation = useStableCallback( @@ -409,7 +427,7 @@ export const CodeViewWrapper = memo(function CodeViewWrapper({ ref={viewerRef} containerRef={scrollRef} - items={items} + initialItems={initialItems} className={cn( 'gh-code-view-scrollbar-y mt-[-12px] h-[calc(100%_+_12px)] pr-[1px] relative min-h-0 min-w-0 flex-1 px-[1px] overflow-auto overscroll-contain w-full [contain:strict] [overflow-anchor:none] [will-change:scroll-position] [&_diffs-container]:overflow-clip [&_diffs-container]:rounded-lg [&_diffs-container]:shadow-[0_0_0_1px_var(--color-border)] [&_diffs-container]:[contain:layout_paint_style]', className diff --git a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx index 72085e394..380f98cfe 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx @@ -1,12 +1,7 @@ 'use client'; -import { - type CodeViewItem, - type DiffIndicators, - parsePatchFiles, -} from '@pierre/diffs'; +import { type DiffIndicators } from '@pierre/diffs'; import { type CodeViewHandle } from '@pierre/diffs/react'; -import type { GitStatusEntry } from '@pierre/trees'; import { type ReactNode, useCallback, @@ -19,61 +14,25 @@ import { CodeViewHeader } from './CodeViewHeader'; import { CodeViewSidebar } from './CodeViewSidebar'; import { CodeViewStatusPanel } from './CodeViewStatusPanel'; import { CodeViewWrapper } from './CodeViewWrapper'; -import { - CODE_VIEW_MARGIN_OFFSET, - CODE_VIEW_PADDING_BLOCK, - type ViewerLoadState, -} from './constants'; +import { CODE_VIEW_MARGIN_OFFSET, CODE_VIEW_PADDING_BLOCK } from './constants'; import type { - CodeViewCommentFileByItemId, - CodeViewCommentSidebarFile, CodeViewDeletedCommentEvent, - CodeViewDiffStats, - CodeViewFileTreeSource, CodeViewSavedCommentEntry, CodeViewSavedCommentEvent, - CodeViewSavedCommentItem, CommentMetadata, } from './types'; +import { usePatchLoader } from './usePatchLoader'; import { - createCodeViewFileTreeSource, - getGitHubPath, - mapChangeTypeToGitStatus, removeSavedCommentSidebarEntry, upsertSavedCommentSidebarEntry, } from './utils'; -const COMMIT_HASH_METADATA_PATTERN = /^From\s+([a-f0-9]+)\s/im; - -interface LoadedCodeViewData { - itemIdToFile: CodeViewCommentFileByItemId; - diffStats: CodeViewDiffStats; - items: CodeViewItem[]; - treeSource: CodeViewFileTreeSource; -} - interface ReviewUIProps { initialUrl: string; } export function ReviewUI({ initialUrl }: ReviewUIProps) { const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split'); - const [items, setItems] = useState[]>([]); - // Tree data is intentionally stored separately from items so annotation - // updates do not cascade into the file tree and trigger needless rebuilds. - // It is rebuilt once per fetch in this viewer route. - const [treeSource, setTreeSource] = useState( - null - ); - const [diffStats, setDiffStats] = useState(null); - const [commentFileByItemId, setCommentFileByItemId] = - useState(null); - const [commentSections, setCommentSections] = useState< - CodeViewSavedCommentItem[] - >([]); - const [loadState, setLoadState] = useState('fetching'); - const [errorMessage, setErrorMessage] = useState(null); - const [loadAttempt, setLoadAttempt] = useState(0); const [fileTreeOverlayOpen, setFileTreeOverlayOpen] = useState(false); const [overflow, setOverflow] = useState<'wrap' | 'scroll'>('scroll'); const [showBackgrounds, setShowBackgrounds] = useState(true); @@ -81,92 +40,25 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { const [lineNumbers, setLineNumbers] = useState(true); const scrollRef = useRef(null); const viewerRef = useRef | null>(null); - const requestIdRef = useRef(0); - - useEffect(() => { - const githubPath = getGitHubPath(initialUrl); - if (githubPath == null) { - setItems([]); - setTreeSource(null); - setDiffStats(null); - setCommentFileByItemId(null); - setCommentSections([]); - setErrorMessage('Enter a valid GitHub URL.'); - setLoadState('error'); - return; - } - const resolvedGitHubPath = githubPath; - - const controller = new AbortController(); - const requestId = ++requestIdRef.current; - const isCurrentRequest = () => - requestIdRef.current === requestId && !controller.signal.aborted; - - setItems([]); - setTreeSource(null); - setDiffStats(null); - setCommentFileByItemId(null); - setCommentSections([]); + const handlePatchLoadStart = useCallback(() => { setFileTreeOverlayOpen(false); - setErrorMessage(null); - setLoadState('fetching'); - - async function loadPatch() { - try { - console.time('-- request time'); - const response = await fetch( - `/api/fetch-pr-patch?path=${encodeURIComponent(resolvedGitHubPath)}`, - { signal: controller.signal } - ); - console.timeEnd('-- request time'); - - if (!response.ok) { - const detail = (await response.text()).trim(); - throw new Error( - detail.length > 0 ? detail : `Request failed (${response.status}).` - ); - } - - console.time('-- reading patch'); - const patchContent = await response.text(); - console.timeEnd('-- reading patch'); - if (!isCurrentRequest()) { - return; - } - setLoadState('parsing'); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - - if (!isCurrentRequest()) { - return; - } - const loadedData = buildCodeViewData(patchContent, resolvedGitHubPath); - if (!isCurrentRequest()) { - return; - } - - setTreeSource(loadedData.treeSource); - setCommentFileByItemId(loadedData.itemIdToFile); - setCommentSections([]); - setDiffStats(loadedData.diffStats); - setItems(loadedData.items); - setLoadState('ready'); - } catch (error) { - if (!isCurrentRequest()) { - return; - } - setErrorMessage( - error instanceof Error ? error.message : 'Failed to fetch the diff.' - ); - setLoadState('error'); - } - } - - void loadPatch(); - - return () => { - controller.abort(); - }; - }, [initialUrl, loadAttempt]); + }, []); + const { + commentFileByItemId, + commentSections, + diffStats, + errorMessage, + initialItems, + loadState, + retryLoad, + setCommentSections, + treeSource, + viewerKey, + } = usePatchLoader({ + initialUrl, + onLoadStart: handlePatchLoadStart, + viewerRef, + }); useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 767px)'); @@ -197,7 +89,7 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { upsertSavedCommentSidebarEntry(prev, commentFileByItemId, comment) ); }, - [commentFileByItemId] + [commentFileByItemId, setCommentSections] ); const handleCommentDeleted = useCallback( (comment: CodeViewDeletedCommentEvent) => { @@ -205,14 +97,11 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { removeSavedCommentSidebarEntry(prev, comment) ); }, - [] + [setCommentSections] ); const handleToggleFileTreeOverlay = useCallback(() => { setFileTreeOverlayOpen((open) => !open); }, []); - const handleRetryLoad = useCallback(() => { - setLoadAttempt((attempt) => attempt + 1); - }, []); const handleCloseFileTreeOverlay = useCallback(() => { setFileTreeOverlayOpen(false); }, []); @@ -234,6 +123,9 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { }, [] ); + const viewerAvailable = + loadState === 'ready' || + (loadState === 'streaming' && initialItems.length > 0); return ( @@ -241,7 +133,7 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { className="[grid-area:header]" diffStyle={diffStyle} initialUrl={initialUrl} - loading={loadState === 'fetching' || loadState === 'parsing'} + loading={loadState !== 'ready' && loadState !== 'error'} fileTreeOverlayOpen={fileTreeOverlayOpen} fileTreeAvailable={treeSource != null} overflow={overflow} @@ -255,7 +147,7 @@ export function ReviewUI({ initialUrl }: ReviewUIProps) { setLineNumbers={setLineNumbers} setDiffStyle={setDiffStyle} /> - {loadState === 'ready' ? ( + {viewerAvailable ? ( <> ) : ( )} @@ -305,92 +198,3 @@ function ReviewGrid({ children }: ReviewGridProps) {
); } - -function getPatchTreePathPrefix( - patchMetadata: string | undefined, - patchIndex: number -): string { - const commitHash = patchMetadata?.match(COMMIT_HASH_METADATA_PATTERN)?.[1]; - return commitHash != null - ? commitHash.slice(0, 5) - : `Commit ${patchIndex + 1}`; -} - -// Converts raw patch text into the exact state slices consumed by the diff -// viewer, sidebar tree, stats panel, and comment index in one linear pass. -function buildCodeViewData( - patchContent: string, - githubPath: string -): LoadedCodeViewData { - console.time('-- parsing patches'); - const parsedPatches = parsePatchFiles( - patchContent, - // Use the url as a cache key - encodeURIComponent(githubPath) - ); - console.timeEnd('-- parsing patches'); - - console.time('-- computing layout'); - let fileIndex = 0; - const items: CodeViewItem[] = []; - // Build the tree's path list, id map, and git-status entries in the same - // pass that constructs items so large patches do not pay for a second walk. - const paths: string[] = []; - const pathToItemId = new Map(); - const itemIdToFile = new Map(); - const gitStatus: GitStatusEntry[] = []; - const diffStats: CodeViewDiffStats = { - addedLines: 0, - deletedLines: 0, - fileCount: 0, - totalLinesOfCode: 0, - }; - const shouldPrefixTreePaths = parsedPatches.length > 1; - for (const [patchIndex, patch] of parsedPatches.entries()) { - const treePathPrefix = shouldPrefixTreePaths - ? getPatchTreePathPrefix(patch.patchMetadata, patchIndex) - : undefined; - for (const fileDiff of patch.files) { - diffStats.fileCount++; - diffStats.totalLinesOfCode += fileDiff.unifiedLineCount; - for (const hunk of fileDiff.hunks) { - diffStats.addedLines += hunk.additionLines; - diffStats.deletedLines += hunk.deletionLines; - } - - const id = `${fileIndex++}:${fileDiff.name}`; - const fileOrder = items.length; - - items.push({ - id, - type: 'diff', - fileDiff, - version: 0, - }); - - const path = fileDiff.name; - itemIdToFile.set(id, { fileOrder, path }); - const treePath = - treePathPrefix == null ? path : `${treePathPrefix}/${path}`; - if (path.length === 0 || pathToItemId.has(treePath)) { - continue; - } - paths.push(treePath); - pathToItemId.set(treePath, id); - // Modified files are excluded so they render as the visual default. - // Only added, deleted, and renamed files retain status indicators. - const gitStatusEntry = mapChangeTypeToGitStatus(fileDiff.type); - if (gitStatusEntry !== 'modified') { - gitStatus.push({ path: treePath, status: gitStatusEntry }); - } - } - } - console.timeEnd('-- computing layout'); - - return { - itemIdToFile, - diffStats, - items, - treeSource: createCodeViewFileTreeSource(paths, pathToItemId, gitStatus), - }; -} diff --git a/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts b/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts new file mode 100644 index 000000000..e8fc83630 --- /dev/null +++ b/apps/docs/app/(diffshub)/(view)/_components/codeViewDataAccumulator.ts @@ -0,0 +1,157 @@ +import { + type CodeViewItem, + type FileDiffMetadata, + parsePatchFiles, +} from '@pierre/diffs'; +import type { GitStatusEntry } from '@pierre/trees'; + +import { getPatchTreePathPrefix } from './gitPatchMetadata'; +import type { + CodeViewCommentFileByItemId, + CodeViewCommentSidebarFile, + CodeViewDiffStats, + CodeViewFileTreeSource, + CommentMetadata, +} from './types'; +import { + createCodeViewFileTreeSource, + mapChangeTypeToGitStatus, +} from './utils'; + +export interface CodeViewDataAccumulator { + fileIndex: number; + gitStatus: GitStatusEntry[]; + itemIdToFile: Map; + items: CodeViewItem[]; + pendingItems: CodeViewItem[]; + pathToItemId: Map; + paths: string[]; + diffStats: CodeViewDiffStats; +} + +export interface LoadedCodeViewData { + itemIdToFile: CodeViewCommentFileByItemId; + diffStats: CodeViewDiffStats; + items: CodeViewItem[]; + treeSource: CodeViewFileTreeSource; +} + +export function createCodeViewDataAccumulator(): CodeViewDataAccumulator { + return { + fileIndex: 0, + gitStatus: [], + itemIdToFile: new Map(), + items: [], + pendingItems: [], + pathToItemId: new Map(), + paths: [], + diffStats: { + addedLines: 0, + deletedLines: 0, + fileCount: 0, + totalLinesOfCode: 0, + }, + }; +} + +export function appendFileDiffToCodeViewData( + accumulator: CodeViewDataAccumulator, + fileDiff: FileDiffMetadata, + treePathPrefix: string | undefined +): void { + const { diffStats } = accumulator; + diffStats.fileCount++; + diffStats.totalLinesOfCode += fileDiff.unifiedLineCount; + for (const hunk of fileDiff.hunks) { + diffStats.addedLines += hunk.additionLines; + diffStats.deletedLines += hunk.deletionLines; + } + + const id = `${accumulator.fileIndex++}:${fileDiff.name}`; + const fileOrder = accumulator.items.length; + + const item: CodeViewItem = { + id, + type: 'diff', + collapsed: fileDiff.type === 'deleted', + fileDiff, + version: 0, + }; + accumulator.items.push(item); + accumulator.pendingItems.push(item); + + const path = fileDiff.name; + accumulator.itemIdToFile.set(id, { fileOrder, path }); + const treePath = treePathPrefix == null ? path : `${treePathPrefix}/${path}`; + if (path.length === 0 || accumulator.pathToItemId.has(treePath)) { + return; + } + + accumulator.paths.push(treePath); + accumulator.pathToItemId.set(treePath, id); + // Modified files are excluded so they render as the visual default. Only + // added, deleted, and renamed files retain status indicators. + const gitStatusEntry = mapChangeTypeToGitStatus(fileDiff.type); + if (gitStatusEntry !== 'modified') { + accumulator.gitStatus.push({ path: treePath, status: gitStatusEntry }); + } +} + +export function takePendingCodeViewItems( + accumulator: CodeViewDataAccumulator +): CodeViewItem[] { + const { pendingItems } = accumulator; + accumulator.pendingItems = []; + return pendingItems; +} + +export function snapshotCodeViewTreeSource( + accumulator: CodeViewDataAccumulator +): CodeViewFileTreeSource { + return createCodeViewFileTreeSource( + accumulator.paths.slice(), + new Map(accumulator.pathToItemId), + accumulator.gitStatus.slice() + ); +} + +export function snapshotCodeViewData( + accumulator: CodeViewDataAccumulator +): LoadedCodeViewData { + return { + itemIdToFile: new Map(accumulator.itemIdToFile), + diffStats: { ...accumulator.diffStats }, + items: accumulator.items.slice(), + treeSource: snapshotCodeViewTreeSource(accumulator), + }; +} + +// Converts raw patch text into the exact state slices consumed by the diff +// viewer, sidebar tree, stats panel, and comment index in one linear pass. +export function buildCodeViewData( + patchContent: string, + githubPath: string +): LoadedCodeViewData { + console.time('-- parsing patches'); + const parsedPatches = parsePatchFiles( + patchContent, + // Use the url as a cache key + encodeURIComponent(githubPath) + ); + console.timeEnd('-- parsing patches'); + + console.time('-- computing layout'); + const accumulator = createCodeViewDataAccumulator(); + const shouldPrefixTreePaths = parsedPatches.length > 1; + for (const [patchIndex, patch] of parsedPatches.entries()) { + const treePathPrefix = shouldPrefixTreePaths + ? getPatchTreePathPrefix(patch.patchMetadata, patchIndex) + : undefined; + for (const fileDiff of patch.files) { + appendFileDiffToCodeViewData(accumulator, fileDiff, treePathPrefix); + } + } + console.timeEnd('-- computing layout'); + + return snapshotCodeViewData(accumulator); +} diff --git a/apps/docs/app/(diffshub)/(view)/_components/constants.ts b/apps/docs/app/(diffshub)/(view)/_components/constants.ts index 4115128d5..1027a7559 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/constants.ts +++ b/apps/docs/app/(diffshub)/(view)/_components/constants.ts @@ -1,6 +1,11 @@ import type { FileTreeOptions } from '@pierre/trees'; -export type ViewerLoadState = 'fetching' | 'parsing' | 'ready' | 'error'; +export type ViewerLoadState = + | 'fetching' + | 'streaming' + | 'parsing' + | 'ready' + | 'error'; export const CODE_VIEW_MARGIN_OFFSET = 12; diff --git a/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts b/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts new file mode 100644 index 000000000..8ac0e267b --- /dev/null +++ b/apps/docs/app/(diffshub)/(view)/_components/gitPatchMetadata.ts @@ -0,0 +1,11 @@ +export const COMMIT_HASH_METADATA_PATTERN = /^From\s+([a-f0-9]+)\s/im; + +export function getPatchTreePathPrefix( + patchMetadata: string | undefined, + patchIndex: number +): string { + const commitHash = patchMetadata?.match(COMMIT_HASH_METADATA_PATTERN)?.[1]; + return commitHash != null + ? commitHash.slice(0, 5) + : `Commit ${patchIndex + 1}`; +} diff --git a/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts new file mode 100644 index 000000000..c5872d46d --- /dev/null +++ b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts @@ -0,0 +1,243 @@ +import { COMMIT_HASH_METADATA_PATTERN } from './gitPatchMetadata'; + +const GIT_FILE_BOUNDARY = 'diff --git '; +const GIT_FILE_BOUNDARY_WITH_NEWLINE = `\n${GIT_FILE_BOUNDARY}`; +const GIT_FILE_BOUNDARY_SCAN_OVERLAP = + GIT_FILE_BOUNDARY_WITH_NEWLINE.length - 1; +const NON_WHITESPACE_PATTERN = /\S/; + +export async function streamGitPatchFiles( + body: ReadableStream, + onFileText: (fileText: string) => Promise +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const parser = createGitPatchFileStreamParser(); + + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + if (result.value.byteLength > 0) { + parser.push(decoder.decode(result.value, { stream: true })); + await consumeAvailableStreamedFiles(parser, onFileText); + } + } + + const finalText = decoder.decode(); + if (finalText.length > 0) { + parser.push(finalText); + await consumeAvailableStreamedFiles(parser, onFileText); + } + const result = parser.finish(); + if (result.fileText != null) { + await onFileText(result.fileText); + } + let fileText: string | undefined; + while ((fileText = parser.takeAvailableFile()) != null) { + await onFileText(fileText); + } + return result.fallbackPatchContent; + } finally { + reader.releaseLock(); + } +} + +export function getStreamedPatchMetadata(fileText: string): string | undefined { + const diffBoundaryIndex = findNextGitFileBoundary(fileText, 0); + if (diffBoundaryIndex == null || diffBoundaryIndex <= 0) { + return undefined; + } + + const metadata = fileText.slice(0, diffBoundaryIndex); + return COMMIT_HASH_METADATA_PATTERN.test(metadata) ? metadata : undefined; +} + +interface GitPatchFileStreamFinishResult { + fallbackPatchContent?: string; + fileText?: string; +} + +interface GitPatchFileStreamParser { + finish(): GitPatchFileStreamFinishResult; + push(chunk: string): void; + takeAvailableFile(): string | undefined; +} + +async function consumeAvailableStreamedFiles( + parser: GitPatchFileStreamParser, + onFileText: (fileText: string) => Promise +): Promise { + let fileText: string | undefined; + while ((fileText = parser.takeAvailableFile()) != null) { + await onFileText(fileText); + } +} + +// Buffers the current file until the following `diff --git` header arrives so +// each parsed file is complete before it is appended to the viewer. +function createGitPatchFileStreamParser(): GitPatchFileStreamParser { + let buffer = ''; + let currentFileBoundaryIndex: number | undefined; + let nextBoundarySearchIndex = 0; + let sawFileBoundary = false; + + function takeAvailableFile(): string | undefined { + if (currentFileBoundaryIndex == null) { + currentFileBoundaryIndex = findNextGitFileBoundary( + buffer, + nextBoundarySearchIndex + ); + if (currentFileBoundaryIndex == null) { + nextBoundarySearchIndex = getNextBoundarySearchIndex(buffer, 0); + return undefined; + } + + sawFileBoundary = true; + nextBoundarySearchIndex = currentFileBoundaryIndex + 1; + } + + for (;;) { + const fileBoundaryIndex = currentFileBoundaryIndex; + if (fileBoundaryIndex == null) { + return undefined; + } + + const nextBoundaryIndex = findNextGitFileBoundary( + buffer, + nextBoundarySearchIndex + ); + if (nextBoundaryIndex == null) { + nextBoundarySearchIndex = getNextBoundarySearchIndex( + buffer, + fileBoundaryIndex + 1 + ); + return undefined; + } + + const splitIndex = getStreamedFileSplitIndex( + buffer, + fileBoundaryIndex, + nextBoundaryIndex + ); + const fileText = buffer.slice(0, splitIndex); + + buffer = buffer.slice(splitIndex); + currentFileBoundaryIndex = findNextGitFileBoundary(buffer, 0); + nextBoundarySearchIndex = + currentFileBoundaryIndex == null ? 0 : currentFileBoundaryIndex + 1; + if (NON_WHITESPACE_PATTERN.test(fileText)) { + return fileText; + } + } + } + + return { + push(chunk: string) { + if (chunk.length === 0) { + return; + } + buffer += chunk; + }, + takeAvailableFile, + finish() { + const fileText = takeAvailableFile(); + if (fileText != null) { + return { fileText }; + } + + if (!NON_WHITESPACE_PATTERN.test(buffer)) { + buffer = ''; + return {}; + } + if (!sawFileBoundary) { + const fullPatchText = buffer; + buffer = ''; + return { fallbackPatchContent: fullPatchText }; + } + + const finalFileText = buffer; + buffer = ''; + return { fileText: finalFileText }; + }, + }; +} + +function getNextBoundarySearchIndex( + text: string, + minimumIndex: number +): number { + return Math.max(minimumIndex, text.length - GIT_FILE_BOUNDARY_SCAN_OVERLAP); +} + +function findNextGitFileBoundary( + text: string, + fromIndex: number +): number | undefined { + const startIndex = Math.max(fromIndex, 0); + if (startIndex === 0 && text.startsWith(GIT_FILE_BOUNDARY)) { + return 0; + } + + const boundaryIndex = text.indexOf( + GIT_FILE_BOUNDARY_WITH_NEWLINE, + startIndex + ); + return boundaryIndex === -1 ? undefined : boundaryIndex + 1; +} + +function getStreamedFileSplitIndex( + text: string, + firstBoundaryIndex: number, + nextBoundaryIndex: number +): number { + return ( + findLastCommitMetadataBoundary( + text, + firstBoundaryIndex + 1, + nextBoundaryIndex + ) ?? nextBoundaryIndex + ); +} + +function findLastCommitMetadataBoundary( + text: string, + startIndex: number, + endIndex: number +): number | undefined { + const minimumBoundaryIndex = Math.max(startIndex, 0); + const maximumBoundaryIndex = Math.min(endIndex, text.length); + if (minimumBoundaryIndex >= maximumBoundaryIndex) { + return undefined; + } + + let newlineIndex = text.lastIndexOf('\nFrom ', maximumBoundaryIndex - 1); + for (;;) { + if (newlineIndex === -1) { + return undefined; + } + + const boundaryIndex = newlineIndex + 1; + if (boundaryIndex < minimumBoundaryIndex) { + return undefined; + } + if (boundaryIndex >= maximumBoundaryIndex) { + newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1); + continue; + } + + const lineEndIndex = text.indexOf('\n', boundaryIndex + 1); + const line = text.slice( + boundaryIndex, + lineEndIndex === -1 || lineEndIndex > maximumBoundaryIndex + ? maximumBoundaryIndex + : lineEndIndex + ); + if (COMMIT_HASH_METADATA_PATTERN.test(line)) { + return boundaryIndex; + } + newlineIndex = text.lastIndexOf('\nFrom ', newlineIndex - 1); + } +} diff --git a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts new file mode 100644 index 000000000..afd79a704 --- /dev/null +++ b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts @@ -0,0 +1,366 @@ +'use client'; + +import { type CodeViewItem, processFile } from '@pierre/diffs'; +import type { CodeViewHandle } from '@pierre/diffs/react'; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { + appendFileDiffToCodeViewData, + buildCodeViewData, + createCodeViewDataAccumulator, + snapshotCodeViewTreeSource, + takePendingCodeViewItems, +} from './codeViewDataAccumulator'; +import type { ViewerLoadState } from './constants'; +import { getPatchTreePathPrefix } from './gitPatchMetadata'; +import { + getStreamedPatchMetadata, + streamGitPatchFiles, +} from './streamGitPatchFiles'; +import type { + CodeViewCommentFileByItemId, + CodeViewDiffStats, + CodeViewFileTreeSource, + CodeViewSavedCommentItem, + CommentMetadata, +} from './types'; +import { getGitHubPath } from './utils'; + +const STREAM_PUBLISH_FILE_BATCH_SIZE = 25; +const STREAM_PUBLISH_INTERVAL_MS = 100; +const STREAM_WORK_BUDGET_MS = 8; +const STREAM_TREE_PUBLISH_FILE_BATCH_SIZE = 1_000; +const STREAM_TREE_PUBLISH_INTERVAL_MS = 1_000; + +interface UsePatchLoaderOptions { + initialUrl: string; + onLoadStart?(): void; + viewerRef: RefObject | null>; +} + +interface UsePatchLoaderResult { + commentFileByItemId: CodeViewCommentFileByItemId | null; + commentSections: CodeViewSavedCommentItem[]; + diffStats: CodeViewDiffStats | null; + errorMessage: string | null; + initialItems: CodeViewItem[]; + loadState: ViewerLoadState; + retryLoad(): void; + setCommentSections: Dispatch>; + treeSource: CodeViewFileTreeSource | null; + viewerKey: number; +} + +export function usePatchLoader({ + initialUrl, + onLoadStart, + viewerRef, +}: UsePatchLoaderOptions): UsePatchLoaderResult { + const [initialItems, setInitialItems] = useState< + CodeViewItem[] + >([]); + // Tree data is intentionally stored separately from items so annotation + // updates do not cascade into the file tree and trigger needless rebuilds. + // It is updated by fetch/stream batches in this viewer route. + const [treeSource, setTreeSource] = useState( + null + ); + const [diffStats, setDiffStats] = useState(null); + const [commentFileByItemId, setCommentFileByItemId] = + useState(null); + const [commentSections, setCommentSections] = useState< + CodeViewSavedCommentItem[] + >([]); + const [loadState, setLoadState] = useState('fetching'); + const [errorMessage, setErrorMessage] = useState(null); + const [loadAttempt, setLoadAttempt] = useState(0); + const [viewerKey, setViewerKey] = useState(0); + const requestIdRef = useRef(0); + + useEffect(() => { + const githubPath = getGitHubPath(initialUrl); + if (githubPath == null) { + setInitialItems([]); + setTreeSource(null); + setDiffStats(null); + setCommentFileByItemId(null); + setCommentSections([]); + setErrorMessage('Enter a valid GitHub URL.'); + setLoadState('error'); + return; + } + const resolvedGitHubPath = githubPath; + + const controller = new AbortController(); + const requestId = ++requestIdRef.current; + const isCurrentRequest = () => + requestIdRef.current === requestId && !controller.signal.aborted; + + setViewerKey(requestId); + setInitialItems([]); + setTreeSource(null); + setDiffStats(null); + setCommentFileByItemId(null); + setCommentSections([]); + onLoadStart?.(); + setErrorMessage(null); + setLoadState('fetching'); + + async function loadPatch() { + try { + const cacheKeyPrefix = encodeURIComponent(resolvedGitHubPath); + async function commitFullPatch(patchContent: string) { + if (!isCurrentRequest()) { + return; + } + setLoadState('parsing'); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + if (!isCurrentRequest()) { + return; + } + const loadedData = buildCodeViewData( + patchContent, + resolvedGitHubPath + ); + if (!isCurrentRequest()) { + return; + } + + setTreeSource(loadedData.treeSource); + setCommentFileByItemId(loadedData.itemIdToFile); + setCommentSections([]); + setDiffStats(loadedData.diffStats); + setInitialItems(loadedData.items); + setLoadState('ready'); + } + + console.time('-- request time'); + const response = await fetch( + `/api/fetch-pr-patch?path=${encodeURIComponent(resolvedGitHubPath)}`, + { cache: 'no-store', signal: controller.signal } + ); + console.timeEnd('-- request time'); + + // This only catches route setup errors. GitHub fetch failures are + // delivered while consuming the stream so the UI can enter the + // streaming state as soon as the local transport opens. + if (!response.ok) { + const detail = (await response.text()).trim(); + throw new Error( + detail.length > 0 ? detail : `Request failed (${response.status}).` + ); + } + + if (response.body == null) { + console.time('-- reading patch'); + const patchContent = await response.text(); + console.timeEnd('-- reading patch'); + await commitFullPatch(patchContent); + return; + } + + setLoadState('streaming'); + await yieldToBrowser(); + if (!isCurrentRequest()) { + return; + } + + const accumulator = createCodeViewDataAccumulator(); + let streamPatchIndex = 0; + let streamTreePathPrefix: string | undefined; + let pendingPublishFileCount = 0; + let pendingTreePublishFileCount = 0; + let hasPublishedItems = false; + let hasPublishedTree = false; + let hasPublishedInitialItems = false; + let hasReceivedFirstStreamedFile = false; + let lastPublishTime = performance.now(); + let lastWorkYieldTime = lastPublishTime; + let lastTreePublishTime = lastPublishTime; + + const publishPendingData = async () => { + if (pendingPublishFileCount === 0 || !isCurrentRequest()) { + return; + } + + pendingPublishFileCount = 0; + hasPublishedItems = true; + lastPublishTime = performance.now(); + const pendingItems = takePendingCodeViewItems(accumulator); + const viewer = viewerRef.current; + if (!hasPublishedInitialItems) { + hasPublishedInitialItems = true; + setInitialItems(pendingItems); + } else if (viewer != null) { + viewer.addItems(pendingItems); + } else { + setInitialItems((prev) => [...prev, ...pendingItems]); + } + await yieldToBrowser(); + lastWorkYieldTime = performance.now(); + }; + + const publishPendingDataIfNeeded = async () => { + if (pendingPublishFileCount === 0) { + return; + } + + const elapsed = performance.now() - lastPublishTime; + if ( + hasPublishedItems && + pendingPublishFileCount < STREAM_PUBLISH_FILE_BATCH_SIZE && + elapsed < STREAM_PUBLISH_INTERVAL_MS + ) { + return; + } + + await publishPendingData(); + }; + const publishTreeSource = () => { + if (pendingTreePublishFileCount === 0 || !isCurrentRequest()) { + return; + } + + pendingTreePublishFileCount = 0; + hasPublishedTree = true; + lastTreePublishTime = performance.now(); + setCommentFileByItemId(accumulator.itemIdToFile); + setDiffStats({ ...accumulator.diffStats }); + setTreeSource(snapshotCodeViewTreeSource(accumulator)); + }; + const publishTreeSourceIfNeeded = () => { + if (pendingTreePublishFileCount === 0) { + return; + } + + const elapsed = performance.now() - lastTreePublishTime; + if ( + hasPublishedTree && + pendingTreePublishFileCount < STREAM_TREE_PUBLISH_FILE_BATCH_SIZE && + elapsed < STREAM_TREE_PUBLISH_INTERVAL_MS + ) { + return; + } + + publishTreeSource(); + }; + const appendStreamedFile = async (fileText: string) => { + if (!hasReceivedFirstStreamedFile) { + hasReceivedFirstStreamedFile = true; + console.timeEnd('-- first streamed file'); + } + + const patchMetadata = getStreamedPatchMetadata(fileText); + if (patchMetadata != null) { + streamTreePathPrefix = getPatchTreePathPrefix( + patchMetadata, + streamPatchIndex++ + ); + } + + const fileDiff = processFile(fileText, { + cacheKey: `${cacheKeyPrefix}-0-${accumulator.fileIndex}`, + isGitDiff: true, + }); + if (fileDiff == null) { + return; + } + + appendFileDiffToCodeViewData( + accumulator, + fileDiff, + streamTreePathPrefix + ); + pendingPublishFileCount++; + pendingTreePublishFileCount++; + const elapsedWork = performance.now() - lastWorkYieldTime; + if (elapsedWork >= STREAM_WORK_BUDGET_MS) { + await publishPendingData(); + } else { + await publishPendingDataIfNeeded(); + } + publishTreeSourceIfNeeded(); + }; + + console.time('-- first streamed file'); + console.time('-- reading patch stream'); + const fallbackPatchContent = await streamGitPatchFiles( + response.body, + appendStreamedFile + ); + console.timeEnd('-- reading patch stream'); + if (!isCurrentRequest()) { + return; + } + + await publishPendingData(); + publishTreeSource(); + if (fallbackPatchContent != null) { + await commitFullPatch(fallbackPatchContent); + return; + } + + setCommentFileByItemId(new Map(accumulator.itemIdToFile)); + setDiffStats({ ...accumulator.diffStats }); + setLoadState('ready'); + } catch (error) { + if (!isCurrentRequest()) { + return; + } + setErrorMessage( + error instanceof Error ? error.message : 'Failed to fetch the diff.' + ); + setLoadState('error'); + } + } + + void loadPatch(); + + return () => { + controller.abort(); + }; + }, [initialUrl, loadAttempt, onLoadStart, viewerRef]); + + const retryLoad = useCallback(() => { + setLoadAttempt((attempt) => attempt + 1); + }, []); + + return { + commentFileByItemId, + commentSections, + diffStats, + errorMessage, + initialItems, + loadState, + retryLoad, + setCommentSections, + treeSource, + viewerKey, + }; +} + +function yieldToBrowser(): Promise { + return new Promise((resolve) => { + let didResolve = false; + const resolveOnce = () => { + if (didResolve) { + return; + } + + didResolve = true; + window.clearTimeout(timeout); + resolve(); + }; + const timeout = window.setTimeout(resolveOnce, 50); + window.requestAnimationFrame(resolveOnce); + }); +} diff --git a/apps/docs/app/api/fetch-pr-patch/route.ts b/apps/docs/app/api/fetch-pr-patch/route.ts index 74b8d1175..5f438fcad 100644 --- a/apps/docs/app/api/fetch-pr-patch/route.ts +++ b/apps/docs/app/api/fetch-pr-patch/route.ts @@ -2,12 +2,14 @@ import { readFile } from 'fs/promises'; import { type NextRequest } from 'next/server'; import { join } from 'path'; -const SUCCESS_CACHE_CONTROL = 'private, max-age=60, stale-while-revalidate=300'; -const ERROR_CACHE_CONTROL = 'no-store'; +const CACHE_CONTROL = 'no-store'; const EMPTY_PATCH_MESSAGE = 'GitHub returned an empty diff.'; const NON_DIFF_RESPONSE_MESSAGE = 'GitHub did not return a diff for this URL.'; const NON_WHITESPACE_PATTERN = /\S/; +// Validates the GitHub-relative path, normalizes it to a raw diff URL, and +// returns a streaming proxy response so the client can render files as they +// arrive instead of waiting for the full patch text. export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const path = searchParams.get('path'); @@ -16,7 +18,8 @@ export async function GET(request: NextRequest) { return createTextResponse('Path parameter is required', { status: 400 }); } - // Dev override to fetch the monster patch without required GitHub + // Override to fetch default patch without requiring GitHub, to help avoid + // abuse and potential rate limits if (path === '/nodejs/node/pull/59805') { try { const localPatchPath = join( @@ -53,34 +56,7 @@ export async function GET(request: NextRequest) { // Construct the full GitHub URL server-side const patchURL = `https://github.com${patchPath}`; - // Fetch the patch from GitHub - const response = await fetch(patchURL, { - headers: { - 'User-Agent': 'pierre-js', - }, - }); - - if (!response.ok) { - return createTextResponse( - `Failed to fetch patch: ${response.statusText}`, - { - status: response.status, - } - ); - } - - const contentType = response.headers.get('Content-Type'); - if (contentType == null || !contentType.startsWith('text/plain')) { - return createTextResponse(NON_DIFF_RESPONSE_MESSAGE, { status: 422 }); - } - - if (response.body == null) { - return createPatchTextResponse(await response.text(), { - sourceURL: patchURL, - }); - } - - return await createPatchStreamResponse(response.body, { + return createPatchStreamResponse(patchURL, request.signal, { sourceURL: patchURL, }); } catch (error) { @@ -96,6 +72,9 @@ interface TextResponseOptions { sourceURL?: string; } +// Serves local patch fixtures through the same response path as GitHub data, +// while rejecting empty files so the viewer does not enter a silent no-op +// state. function createPatchTextResponse( patchText: string, options: Omit @@ -107,63 +86,112 @@ function createPatchTextResponse( return createTextResponse(patchText, options); } -async function createPatchStreamResponse( - body: ReadableStream, +// Opens the client-facing stream immediately. For this private transport, a +// 200 response means the local stream was accepted; upstream GitHub failures +// are reported later by erroring the response body while the client reads it. +function createPatchStreamResponse( + patchURL: string, + requestSignal: AbortSignal, options: Omit -): Promise { - const reader = body.getReader(); - let firstChunk: Uint8Array | undefined; - while (firstChunk == null) { - const result = await reader.read(); - if (result.done) { - return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); - } - if (result.value.byteLength > 0) { - firstChunk = result.value; - } - } +): Response { + const upstreamController = new AbortController(); + const abortUpstream = () => upstreamController.abort(); + requestSignal.addEventListener('abort', abortUpstream, { once: true }); const stream = new ReadableStream({ start(controller) { - controller.enqueue(firstChunk); - void pumpReader(reader, controller); + void pumpPatchURL( + patchURL, + upstreamController.signal, + controller + ).finally(() => { + requestSignal.removeEventListener('abort', abortUpstream); + }); }, - cancel(reason) { - return reader.cancel(reason); + cancel() { + abortUpstream(); + requestSignal.removeEventListener('abort', abortUpstream); }, }); + return createTextResponse(stream, options); } -async function pumpReader( - reader: ReadableStreamDefaultReader, +// Fetches the raw GitHub diff and forwards each upstream chunk into the client +// stream. `cache: 'no-store'` avoids Next/browser replay behavior that can +// turn a large streamed response back into a delayed full-response read. +async function pumpPatchURL( + patchURL: string, + signal: AbortSignal, controller: ReadableStreamDefaultController ): Promise { try { - for (;;) { - const result = await reader.read(); - if (result.done) { - controller.close(); - return; + const response = await fetch(patchURL, { + cache: 'no-store', + headers: { 'User-Agent': 'pierre-diffshub' }, + signal, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch patch: ${response.status} ${response.statusText}` + ); + } + + const contentType = response.headers.get('Content-Type'); + if (contentType == null || !contentType.startsWith('text/plain')) { + throw new Error(NON_DIFF_RESPONSE_MESSAGE); + } + + if (response.body == null) { + const patchText = await response.text(); + if (!NON_WHITESPACE_PATTERN.test(patchText)) { + throw new Error(EMPTY_PATCH_MESSAGE); } - if (result.value.byteLength > 0) { - controller.enqueue(result.value); + + controller.enqueue(new TextEncoder().encode(patchText)); + controller.close(); + return; + } + + const reader = response.body.getReader(); + let sawContent = false; + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + + if (result.value.byteLength > 0) { + sawContent = true; + controller.enqueue(result.value); + } } + } finally { + reader.releaseLock(); } + + if (!sawContent) { + throw new Error(EMPTY_PATCH_MESSAGE); + } + + controller.close(); } catch (error) { controller.error(error); } } +// Centralizes text response headers for both stream and error bodies. Diff +// responses are intentionally not cached in the browser because cached 100MB+ +// responses can replay poorly and delay the first useful diff bytes. function createTextResponse( body: string | ReadableStream, { status = 200, sourceURL }: TextResponseOptions = {} ): Response { - const cacheControl = - status >= 200 && status < 300 ? SUCCESS_CACHE_CONTROL : ERROR_CACHE_CONTROL; const headers = new Headers({ 'Content-Type': 'text/plain; charset=utf-8', - 'Cache-Control': cacheControl, + 'Cache-Control': CACHE_CONTROL, }); if (sourceURL != null) { headers.set('X-Patch-Source', sourceURL); diff --git a/apps/docs/app/gh/CodeViewHeader.tsx b/apps/docs/app/gh/CodeViewHeader.tsx index bfe0903da..2bfd3d95d 100644 --- a/apps/docs/app/gh/CodeViewHeader.tsx +++ b/apps/docs/app/gh/CodeViewHeader.tsx @@ -35,7 +35,6 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; const COMMIT_HASH_METADATA_PATTERN = /^From\s+([a-f0-9]+)\s/im; -const INITIAL_COLLAPSED_DIFF_LINE_THRESHOLD = 200_000; function getPatchTreePathPrefix( patchMetadata: string | undefined, @@ -103,6 +102,9 @@ export const CodeViewHeader = memo(function CodeViewHeader({ ); console.timeEnd('-- request time'); + // This endpoint opens a local stream before GitHub responds, so this + // check only covers route setup errors. Upstream failures surface + // while reading the response body below. if (!response.ok) { const error = await response.text(); console.error('Failed to fetch patch:', error); @@ -143,10 +145,7 @@ export const CodeViewHeader = memo(function CodeViewHeader({ items.push({ id, type: 'diff', - collapsed: - fileDiff.type === 'deleted' || - Math.max(fileDiff.splitLineCount, fileDiff.unifiedLineCount) > - INITIAL_COLLAPSED_DIFF_LINE_THRESHOLD, + collapsed: fileDiff.type === 'deleted', fileDiff, version: 0, }); diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 84f86826a..f3fa25b16 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -31,6 +31,9 @@ const nextConfig = { ...(process.env.NODE_ENV === 'development' && { distDir: `.next/${site}`, }), + // Lets just disable strict mode in the diffshub project to avoid github + // request thrash in dev... + reactStrictMode: !isDiffshub, reactCompiler: true, devIndicators: false, experimental: { diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 6eb42c3ec..2dadb3b0f 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -759,6 +759,28 @@ export class CodeView { this.applySelectedLines(null, options); } + public getItem(itemId: string): CodeViewItem | undefined { + return this.idToItem.get(itemId)?.item; + } + + public updateItem(input: CodeViewItem): boolean { + const item = this.idToItem.get(input.id); + if (item == null) { + console.error(`CodeView.updateItem: unknown item id "${input.id}"`); + return false; + } + + if (!this.syncItemRecord(item, input)) { + return false; + } + + this.markItemLayoutDirty(item); + this.scrollDirty = true; + this.render(); + this.syncSelection(); + return true; + } + public addItem(input: CodeViewItem): void { this.addItems([input]); this.syncSelection(); @@ -797,6 +819,7 @@ export class CodeView { const viewerMetrics = this.getViewerMetrics(); let nextTop = this.items.length === 0 ? 0 : this.scrollHeight + viewerMetrics.gap; + const appendedTop = nextTop; for (let index = 0; index < inputs.length; index++) { const input = inputs[index]; if (input == null) { @@ -817,10 +840,25 @@ export class CodeView { this.scrollHeight = nextTop - viewerMetrics.gap; this.scrollDirty = true; if (render) { - this.render(); + if (this.canSkipRenderForAppend(appendedTop)) { + this.syncContainerHeight(); + } else { + this.render(); + } } } + private canSkipRenderForAppend(appendedTop: number): boolean { + return ( + this.container != null && + this.renderState.firstIndex !== -1 && + this.pendingScrollTarget == null && + this.scrollAnimation == null && + this.layoutDirtyIndex == null && + appendedTop > this.windowSpecs.bottom + ); + } + public setOptions(options: CodeViewOptions | undefined): void { if (options == null) { return; diff --git a/packages/diffs/src/react/CodeView.tsx b/packages/diffs/src/react/CodeView.tsx index 52bcb9e89..42eaef8d0 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -75,11 +75,10 @@ export interface ControlledCodeViewProps< export interface UncontrolledCodeViewProps< LAnnotation, > extends CodeViewBaseProps { - // FIXME(amadeus): Replace this with a data structure that can do - // mutation-like changes for super massive diffs - // initialItems?: readonly CodeViewItem[]; - // items?: never; - items: readonly CodeViewItem[]; + // Seeds the imperative CodeView instance once. Later item changes should go + // through the ref API instead of being reconciled from React props. + initialItems?: readonly CodeViewItem[]; + items?: never; } export type CodeViewProps = @@ -87,6 +86,9 @@ export type CodeViewProps = | UncontrolledCodeViewProps; export interface CodeViewHandle { + addItems(items: readonly CodeViewItem[]): void; + getItem(id: string): CodeViewItem | undefined; + updateItem(item: CodeViewItem): boolean; scrollTo(target: CodeViewScrollTarget): void; setSelectedLines(selection: CodeViewLineSelection | null): void; getSelectedLines(): CodeViewLineSelection | null; @@ -113,25 +115,35 @@ interface ManagedContentStore { interface CachedDataRef { instance: CodeViewClass | undefined; items: readonly CodeViewItem[] | undefined; + controlled: boolean; managedOptions: CodeViewOptions | undefined; disableFlushSync: boolean; slotCoordinator: CodeViewCoordinator | undefined; } -const DEFAULT_CACHE = { - instance: undefined, - items: undefined, - managedOptions: undefined, - disableFlushSync: false, - slotCoordinator: undefined, -} as const; +function createDefaultCache( + controlled: boolean +): CachedDataRef { + return { + instance: undefined, + items: undefined, + controlled, + managedOptions: undefined, + disableFlushSync: false, + slotCoordinator: undefined, + }; +} function CodeViewInner( - { + props: CodeViewProps, + ref: React.ForwardedRef> +): React.JSX.Element { + const { className, containerRef, disableWorkerPool = false, - items, + initialItems, + items: controlledItems, onScroll, onSelectedLinesChange, options, @@ -142,13 +154,12 @@ function CodeViewInner( renderHeaderPrefix, selectedLines, style, - }: CodeViewProps, - ref: React.ForwardedRef> -): React.JSX.Element { + } = props; + const controlled = controlledItems !== undefined; const poolManager = useContext(WorkerPoolContext); - const cachedDataRef = useRef>({ - ...DEFAULT_CACHE, - }); + const cachedDataRef = useRef>( + createDefaultCache(controlled) + ); const hasCustomHeader = renderCustomHeader != null; const hasAnnotationRenderer = renderAnnotation != null; const hasGutterRenderer = renderGutterUtility != null; @@ -201,7 +212,7 @@ function CodeViewInner( ) { cachedDataRef.current.instance.cleanUp(); slotContentStore.publish(undefined); - cachedDataRef.current = { ...DEFAULT_CACHE }; + cachedDataRef.current = createDefaultCache(controlled); } // If our node matches the existing node then we should not attempt to @@ -267,6 +278,7 @@ function CodeViewInner( useIsometricEffect(() => { const { instance, + controlled: prevControlled, items: prevItems, managedOptions: prevManagedOptions, slotCoordinator: prevSlotCoordinator, @@ -285,10 +297,36 @@ function CodeViewInner( shouldRender = true; } - if (items !== prevItems) { - cachedDataRef.current.items = items; - instance.setItems(items); - shouldRender = true; + if (prevControlled !== controlled) { + console.error( + 'CodeView: cannot switch between controlled and uncontrolled modes. Remount with a new key instead.' + ); + return; + } + + if (controlled) { + if (controlledItems !== prevItems) { + if (areItemListsEqual(prevItems, controlledItems)) { + cachedDataRef.current.items = controlledItems; + } else if (isAppendOnlyItemUpdate(prevItems, controlledItems)) { + cachedDataRef.current.items = controlledItems; + instance.addItems(controlledItems.slice(prevItems.length)); + } else { + cachedDataRef.current.items = controlledItems; + instance.setItems(controlledItems); + shouldRender = true; + } + } + } + // If uncontrolled, we should only ever set items once, and just depend + // on imperative instance changes going forward + else if (prevItems == null) { + const seedItems = initialItems ?? []; + cachedDataRef.current.items = seedItems; + if (seedItems.length > 0) { + instance.setItems(seedItems); + shouldRender = true; + } } if (selectedLines !== undefined) { @@ -328,6 +366,40 @@ function CodeViewInner( useImperativeHandle( ref, (): CodeViewHandle => ({ + addItems(items) { + const { controlled, instance } = cachedDataRef.current; + assertUncontrolledCodeViewAction(controlled, 'addItems'); + if (instance == null) { + console.error( + 'CodeView.addItems: no valid instance to append items with', + items + ); + } else { + instance.addItems(items); + } + }, + getItem(id) { + const { instance } = cachedDataRef.current; + if (instance == null) { + console.error('CodeView.getItem: no valid instance exists', id); + return undefined; + } else { + return instance.getItem(id); + } + }, + updateItem(item) { + const { controlled, instance } = cachedDataRef.current; + assertUncontrolledCodeViewAction(controlled, 'updateItem'); + if (instance == null) { + console.error( + 'CodeView.updateItem: no valid instance to update item with', + item + ); + return false; + } + + return instance.updateItem(item); + }, scrollTo(target) { const { instance } = cachedDataRef.current; if (instance == null) { @@ -398,6 +470,57 @@ function CodeViewInner( // React was a mistake export const CodeView = forwardRef(CodeViewInner) as CodeViewComponent; +function isAppendOnlyItemUpdate( + previousItems: readonly CodeViewItem[] | undefined, + nextItems: readonly CodeViewItem[] +): previousItems is readonly CodeViewItem[] { + if (previousItems == null || nextItems.length <= previousItems.length) { + return false; + } + + if (previousItems.length === 0) { + return true; + } + + for (let index = 0; index < previousItems.length; index++) { + if (nextItems[index] !== previousItems[index]) { + return false; + } + } + + return true; +} + +function areItemListsEqual( + previousItems: readonly CodeViewItem[] | undefined, + nextItems: readonly CodeViewItem[] +): boolean { + if (previousItems == null || previousItems.length !== nextItems.length) { + return false; + } + + for (let index = 0; index < previousItems.length; index++) { + if (previousItems[index] !== nextItems[index]) { + return false; + } + } + + return true; +} + +function assertUncontrolledCodeViewAction( + controlled: boolean, + action: string +): void { + if (!controlled) { + return; + } + + throw new Error( + `CodeView.${action} cannot be used when CodeView is controlled. Use initialItems for imperative item updates.` + ); +} + function createSlotContentStore< LAnnotation, >(): ManagedContentStore { diff --git a/packages/diffs/test/CodeView.collapsed.test.ts b/packages/diffs/test/CodeView.collapsed.test.ts index 1efec556a..94e14b97a 100644 --- a/packages/diffs/test/CodeView.collapsed.test.ts +++ b/packages/diffs/test/CodeView.collapsed.test.ts @@ -254,6 +254,58 @@ describe('CodeView item collapsed state', () => { } }); + test('updates one item without changing item order', async () => { + const { cleanup } = installDom(); + const viewer = new CodeView(); + const items: CodeViewItem[] = [ + { + id: 'file:first.txt', + type: 'file', + file: makeFile('first.txt'), + version: 0, + }, + { + id: 'file:middle.txt', + type: 'file', + file: makeFile('middle.txt'), + version: 0, + }, + { + id: 'file:last.txt', + type: 'file', + file: makeFile('last.txt'), + version: 0, + }, + ]; + try { + viewer.setup(createRoot()); + await renderItems(viewer, items); + + const middleItem = viewer.getItem('file:middle.txt'); + expect(middleItem).toBeDefined(); + middleItem!.collapsed = true; + middleItem!.version = 1; + + expect(viewer.updateItem(middleItem!)).toBe(true); + viewer.render(true); + await wait(0); + + const renderedItems = viewer.getRenderedItems(); + expect(renderedItems.map((item) => item.id)).toEqual([ + 'file:first.txt', + 'file:middle.txt', + 'file:last.txt', + ]); + const renderedMiddleItem = renderedItems[1]; + expect(renderedMiddleItem).toBeDefined(); + expect(hasRenderedCode(renderedMiddleItem)).toBe(false); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + test('keeps rendering after many collapsed items shrink the layout', async () => { const { cleanup } = installDom(); const viewer = new CodeView();