Files
backnotprop__plannotator/packages/review-editor/components/AllFilesCodeView.tsx
Michael Ramos 4465950f0c feat: add bounded annotation undo and redo (#1426)
* feat: add bounded undo and redo history

* fix: address undo redo review feedback

* chore: refresh guide viewer manifest

* fix undo history review regressions

* chore: refresh guide viewer manifest
2026-08-31 09:12:13 -07:00

2617 lines
121 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { getSingularPatch, processFile } from '@pierre/diffs';
import type {
CodeViewItem,
CodeViewLineSelection,
CodeViewOptions,
DiffLineAnnotation,
FileDiffMetadata,
LineAnnotation,
PostRenderPhase,
SelectedLineRange,
} from '@pierre/diffs';
import { CodeView, EditProvider, type CodeViewHandle, useStableCallback } from '@pierre/diffs/react';
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
import type {
CodeAnnotation,
CodeAnnotationType,
ConventionalDecoration,
ConventionalLabel,
DiffAnnotationMetadata,
TokenAnnotationMeta,
} from '@plannotator/ui/types';
import { CommentPopover } from '@plannotator/ui/components/CommentPopover';
import { usePierreTheme } from '../hooks/usePierreTheme';
import { useIsWorkerPoolReadyOrDisabled, useWorkerPoolThemeSync } from '../workerPool';
import type { DiffFile, AnnotationScrollTarget } from '../types';
import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree';
import { buildCodeNavRequest } from '../utils/buildCodeNavRequest';
import { getDiffSelection, getLineNumberFromNode, getSideFromNode } from '../utils/diffSelection';
import { isContentConsistentWithPatch } from '../utils/patchConsistency';
import { hashString } from '../utils/hashString';
import {
resolveLineSelectionBehavior,
type LineSelectionSource,
} from '../utils/lineSelectionBehavior';
import { isContentlessBinaryPatch, isOversizedReviewStubPatch } from '@plannotator/shared/diff-paths';
import { OversizedFileNotice } from './OversizedFileNotice';
import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost';
import { FileHeader } from './FileHeader';
import { BinaryFileNotice } from './BinaryFileNotice';
import { GeneratedFileNotice } from './GeneratedFileNotice';
import { EditSessionHud } from './EditSessionHud';
import { FileCommentBanner } from './FileCommentBanner';
import { annotationMatchesPrScope, isFileScopedAnnotation, lineRangeForAnnotation } from '../utils/annotationScope';
import { useEditSession } from '../edit/useEditSession';
import type { EditSelectionAnnotationRequest, EditSelectionComment } from '../edit/useEditSession';
import type { SuggestionHunk } from '../edit/deriveSuggestions';
import { lineAnnotationMetadata } from '../utils/annotationDisplay';
import { InlineAnnotation } from './InlineAnnotation';
import { InlineAIMarker } from './InlineAIMarker';
import { detectLanguage } from '../utils/detectLanguage';
import type { AIChatEntry } from '../hooks/useAIChat';
import type { ReviewSearchMatch } from '../utils/reviewSearch';
import {
applyItemSearchHighlights,
clearItemSearchHighlights,
swapActiveSearchHighlight,
} from '../utils/reviewSearchHighlight';
/**
* AllFilesCodeView (migration phases P1 + P2 + P3 + P4)
*
* Renders every changed file through ONE Pierre `CodeView` inside a single
* scroll container. This IS the all-files surface — the legacy per-file
* `FileDiff` list (`AllFilesDiffView` + `LazyFileDiff`) and its
* `allFilesCodeView` config flag were deleted once the migration completed.
*
* P1 established the static, uncontrolled `initialItems` skeleton. P2 locked
* down item identity and routed navigation + line selection through CodeView's
* own APIs. P3 moved collapse + the full Plannotator FileHeader INTO CodeView
* via the `renderCustomHeader` render slot.
*
* P4 (this phase) routes annotations through CodeView item state:
*
* - CodeView is typed with `<DiffAnnotationMetadata>` so each diff item's
* `annotations: DiffLineAnnotation<DiffAnnotationMetadata>[]` and
* `renderAnnotation(annotation, item)` are fully typed.
* - Annotations are grouped per file (the same projection AllFilesDiffView
* builds: side 'additions'/'deletions', lineNumber = ann.lineEnd, metadata =
* DiffAnnotationMetadata) and seeded onto each item at build time. When the
* `annotations` prop changes we rebuild ONLY the affected items' annotation
* arrays, bump `item.version`, and call `viewer.updateItem(item)` — so a
* single annotation add/edit/delete re-renders just its owning file.
* - `renderAnnotation` renders the existing `InlineAnnotation` from
* `annotation.metadata`, routing onSelect/onEdit/onDelete by the OWNING item
* (no active-file side channel). Edit routes through the ToolbarHost handle.
* - Selecting an annotation in the sidebar expands its owning file
* (item.collapsed=false + version bump + updateItem) and
* `scrollTo({ type: 'item' | 'range' })` to it.
* - The annotation toolbar already flows through CodeView's
* `onGutterUtilityClick` / `onLineSelectionEnd` callbacks (P2): file identity
* comes from `context.item.id`, and ToolbarHost is fed that file's patch so
* original-code extraction reads the correct file. Drafts-by-file/range and
* AI markers are preserved by ToolbarHost/useAnnotationToolbar unchanged.
*
* P5 (this phase) preserves lazy full-content hunk expansion through CodeView
* item updates instead of LazyFileDiff's per-mount IntersectionObserver fetch:
*
* - Initial items use `getSingularPatch` (raw-patch context only) — CodeView
* already virtualizes the visible window, so no full content is fetched up
* front.
* - When an item enters CodeView's rendered window (its `onPostRender` fires
* with phase 'mount'/'update', the direct analogue of LazyFileDiff's
* IntersectionObserver becoming visible), we fetch `/api/file-content` for
* that file (path/oldPath preserved — workspace prefixes intact — plus the
* review base), reparse with `processFile`, and swap `item.fileDiff` to the
* augmented `FileDiffMetadata`. The augmented diff gets a NEW `cacheKey`
* (contents changed!), `item.version++`, and `viewer.updateItem(item)`. This
* enables the gutter's expand-unchanged controls in place, without
* remounting the list.
* - CodeView's `updateItem` re-measures the grown item and resolves the
* captured scroll anchor, so the viewport stays put whether the augmented
* item is above OR below the fold.
* - Fetches are guarded (one per item per diff generation) and cancellable
* (AbortController per item, all aborted on unmount / diff switch), so there
* is no fetch storm and no double-fetch.
*
* P6 (this phase) makes search work over CodeView's recycled DOM:
*
* - The raw-patch search INDEX is unchanged (App still owns useReviewSearch).
* Only DOM application + navigation move here for the all-files surface.
* - Navigation: when an active match changes, expand its owning file (if
* collapsed) and `viewer.scrollTo({ type: 'line', id, lineNumber, side })` so
* the line lands in view — robust against virtualization (no DOM dependency).
* - Highlighting survives element recycling by re-applying `<mark>` per ITEM via
* `onPostRender`: on mount/update we (re)apply that item's matches; on unmount
* we clear its marks. CodeView reuses item elements from a pool, so a one-shot
* mutation would stick to a reused row or vanish — re-applying on every render
* keeps marks correct after scrolling far enough to recycle. A separate effect
* re-applies across all currently-rendered items when the query/matches change
* (no render is otherwise triggered), and an O(1) effect swaps just the active
* match's styling when stepping between matches.
*
* P7 finished the edges that made CodeView the sole all-files renderer:
*
* - No center split dragger: the legacy all-files view never had one, and a
* single global drag line across every file is noise on files where a
* split is meaningless (new/deleted files). Split columns use Pierre's
* default even 1fr/1fr layout; the single-file DiffViewer keeps its
* per-file dragger.
* - Token code navigation: Cmd/Ctrl-click a token routes through
* `onCodeNavRequest` (parity with the single-file DiffViewer and the legacy
* all-files view), with the `pn-token-nav` affordance (the hover-only
* `pn-token-hover` class is a single-file DiffViewer extra, here as in the
* legacy all-files view). File identity comes from the CodeView callback
* context's owning item, never an active-file side channel.
* - Safari scroll guardian: NOT carried forward. The old DiffViewer guardian
* targeted the OverlayScrollbars viewport wrapping many separate FileDiff
* shadow nodes and restored scrollTop on a ">200 -> 0" jump heuristic.
* CodeView owns its own scroll model and deliberately rebases its DOM
* scrollTop, so that heuristic would fight Pierre. CodeView therefore keeps
* its native bounded scroll viewport on every form factor. A page-scroll
* proxy was physically rejected on iPhone because the document could outrun
* Pierre's virtual window and expose a large blank tail.
*
* The worker pool remains a later phase.
*
* EXPERIMENTAL edit-to-suggestion (flag-gated, default OFF): the plain
* all-files panel can opt into Pierre's experimental edit mode. One file at a
* time enters an in-place editor (lazy-loaded chunk); on completion the net
* change is diffed against the pre-session content and becomes ordinary
* suggestion annotations. The item's pristine FileDiffMetadata is deep-cloned
* before the session and restored (version bump + updateItem) when it ends,
* because Pierre's editor mutates the metadata in place. See
* ../edit/useEditSession.ts and ../edit/pierreEditAdapter.ts.
*/
export interface AllFilesCodeViewProps {
files: DiffFile[];
diffStyle: 'split' | 'unified';
diffOverflow?: 'scroll' | 'wrap';
diffIndicators?: 'bars' | 'classic' | 'none';
lineDiffType?: 'word-alt' | 'word' | 'char' | 'none';
disableLineNumbers?: boolean;
disableBackground?: boolean;
expandUnchanged?: boolean;
fontFamily?: string;
fontSize?: string;
// Annotation state (P4). Mirrors AllFilesDiffView's annotation surface so
// line annotations render through CodeView item state.
annotations: CodeAnnotation[];
selectedAnnotationId: string | null;
scrollTargetAnnotation: AnnotationScrollTarget | null;
pendingSelection: SelectedLineRange | null;
reviewBase?: string;
reviewSnapshotId?: string;
/** Compact coarse-pointer shell. Adjusts custom-header chrome and Pierre's
* matching virtualization metric without changing desktop geometry. */
compactTouchLayout?: boolean;
// Annotation / toolbar wiring (P2). Mirrors AllFilesDiffView's surface so the
// toolbar opens against the file CodeView reports for a selection.
onLineSelection: (range: SelectedLineRange | null) => void;
onAddAnnotationForFile: (
filePath: string,
type: CodeAnnotationType,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel,
decorations?: ConventionalDecoration[],
tokenMeta?: TokenAnnotationMeta,
) => void;
onEditAnnotation: (
id: string,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel | null,
decorations?: ConventionalDecoration[],
) => void;
onSelectAnnotation: (id: string | null) => void;
onDeleteAnnotation: (id: string) => void;
// Header actions (P3). Mirror AllFilesDiffView's header surface.
onAddFileCommentForFile?: (filePath: string, text: string) => void;
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
/** Chrome preference (#1277): false hides the header Viewed buttons; the `v`
* shortcut and viewed state are unaffected. */
showViewedControls?: boolean;
stagedFiles?: Set<string>;
onStage?: (filePath: string) => void;
canStageFiles?: boolean;
/** Same preference for the header Git Add buttons (`a` shortcut still works). */
showStageControls?: boolean;
/** Per-file staging gate — false for committed files in since-base mode. The
* All-files surface lists committed files too, so mode-level canStageFiles is
* not enough; without this the `a` shortcut / header would `git add` a
* committed file (a no-op that still flips local staged/viewed state). */
canStagePath?: (filePath: string) => boolean;
stagingFile?: string | null;
stageError?: string | null;
/** Repo-relative paths marked `linguist-generated` in `.gitattributes`
* (#1317). Their diffs SEED collapsed (GitHub-style) and their headers show
* a "generated" tag. Presentation-only: the diff data is fully present, so
* annotations, search, and augmentation behave normally once expanded. */
generatedFiles?: Set<string>;
/** Generated files the user explicitly expanded — session-local state the
* OWNER keeps (outside this component) so expansion survives remounts and
* fileSetKey re-seeds. Read at item-seed time via ref so expanding never
* rebuilds the identity or remounts CodeView. */
expandedGeneratedFiles?: Set<string>;
/** Report a generated file's collapse change so the owner can maintain
* expandedGeneratedFiles. Fires only for paths in generatedFiles. */
onGeneratedFileCollapsedChange?: (filePath: string, collapsed: boolean) => void;
prUrl?: string;
prDiffScope?: string;
// Search (P6). The raw-patch index lives in App (useReviewSearch); these feed
// the per-item <mark> application + scrollTo navigation over the recycled DOM.
searchQuery?: string;
searchMatches?: ReviewSearchMatch[];
activeSearchMatchId?: string | null;
activeSearchMatch?: ReviewSearchMatch | null;
// Token code navigation (P7). Cmd/Ctrl-click a token resolves symbol defs/refs.
onCodeNavRequest?: (request: import('@plannotator/shared/code-nav').CodeNavRequest) => void;
// File-tree active-file highlight follows scroll.
onVisibleFileChange?: (filePath: string | null) => void;
/** Tokenized request to reveal a file through CodeView's own item navigation.
* Guided Review uses this for outline chips and sidebar/AI jumps. The token
* lets repeated requests for the same path fire again. */
fileScrollTarget?: { filePath: string; token: number } | null;
// Which left panel drives the item order: 'tree' (folders-first visual
// order) or 'list' (files array verbatim — the sections view's order).
fileOrder?: 'tree' | 'list';
// Collapse-all lives in the dock tab strip: register the toggle handler and
// mirror the collapsed flag so the header button can drive/reflect it.
registerCollapseAllToggle?: (toggle: (() => void) | null) => void;
onAllCollapsedChange?: (collapsed: boolean) => void;
/** Seed every file collapsed (commit diffs open as a folded overview under
* the commit-description header). The collapse-all toggle still works. */
defaultCollapsed?: boolean;
/** Guide-only seed captured once for this component mount. Local collapse
* changes therefore do not alter CodeView's key; a true outer remount captures
* the shell's latest value. */
mountCollapsed?: boolean;
/** Restore an inner CodeView position after an outer virtualized shell remounts. */
initialScrollPosition?: number;
/** Persist the current inner CodeView position outside this component. */
onScrollPositionChange?: (position: number) => void;
/** Report collapse changes so an outer shell can preserve them across remounts. */
onFileCollapsedChange?: (filePath: string, collapsed: boolean) => void;
/** Content rendered ABOVE the first file, inside the scroller — it scrolls
* away with the diff (not pinned). Implemented as layout.paddingTop +
* a portal into CodeView's scroll container, since CodeView owns both the
* scroller and the virtualized items. */
leadingContent?: React.ReactNode;
// Only handle [/]/z/v/a/c/x keyboard nav when this surface is the active panel.
isActive?: boolean;
// AI props (optional — surfaced into the toolbar). File-aware variants: this
// surface owns which file the selection lives in (activeFilePath), so the
// index-based onAskAI/aiHistoryForSelection (which resolve the file from the
// single-file panel's focus) must not be used here.
aiAvailable?: boolean;
onAskAIForFile?: (filePath: string, question: string) => void;
isAILoading?: boolean;
onViewAIResponse?: (questionId?: string) => void;
/** Line-scoped questions rendered as inline sparkle markers. */
aiMessages?: AIChatEntry[];
onClickAIMarker?: (questionId: string) => void;
getAIHistoryForFile?: (filePath: string) => AIChatEntry[];
/** Let wheel/touch gestures continue into a containing page when this nested
* viewer reaches either vertical boundary. Guided Review file cards opt in. */
allowScrollChaining?: boolean;
/**
* Portable / read-only host (the exported Guided Review viewer): no line or
* gutter selection, no annotation toolbar or comment popovers, no global
* keyboard shortcuts, no /api/file-content augmentation, no open-in
* affordance. Everything the diff LOOKS like is unchanged — this only turns
* off surfaces that require the review server or mutate review state.
* See adr/decisions/007-portable-guided-reviews-20260815.md (D2, D4).
*/
readOnly?: boolean;
/** EXPERIMENTAL flag-gated edit-to-suggestion mode. Only the plain all-files
* dock panel passes this — Guided Review surfaces deliberately do NOT (the
* GuideViewportManager evicts CodeViews beyond ~8 mounted, which would
* destroy an active editor's state; scoping edit mode to this surface is the
* simple safe v1 choice). When absent/false, no edit UI renders and no
* editor is ever constructed (code-split hosts also never fetch the editor
* chunk; the single-file build inlines it, functionally inert). */
enableEditSuggestions?: boolean;
/** Sink for suggestions derived from a completed edit session. Required for
* edit mode to activate. */
onAddSuggestionsForFile?: (filePath: string, hunks: SuggestionHunk[]) => void;
/** Sink for a comment authored through the edit session's Selection Action
* ("Make annotation"): a line-scoped comment anchored to PRISTINE new-side
* lines snapshotted at selection time (see edit/selectionAnchor.ts). */
onAddEditorCommentForFile?: (filePath: string, comment: EditSelectionComment) => void;
}
// Diffshub-style stable path-based id allocation. Plannotator's file list is
// normally one entry per (new) path, so ids are identity (id === path) in the
// common case. Pathological patches (e.g. a delete + re-add of the same path,
// or repeated paths) would otherwise collapse two files onto one CodeView item,
// breaking selection/scroll identity — so a per-base suffix disambiguates them
// while still keeping filePath <-> itemId maps for constant-time lookups.
interface ItemIdentity {
items: CodeViewItem<DiffAnnotationMetadata>[];
/** Maps a file path to the CodeView item id that owns it. */
filePathToItemId: Map<string, string>;
/** Maps a file path to ALL item ids rendering it (duplicate display paths
* produce twins; updates keyed by path must fan out to every twin). */
filePathToItemIds: Map<string, string[]>;
/** Maps a CodeView item id back to the originating file path. */
itemIdToFilePath: Map<string, string>;
/** Maps a CodeView item id to its originating DiffFile. Keyed by the unique
* item id (not path) so duplicate display paths resolve to the correct file. */
itemIdToFile: Map<string, DiffFile>;
}
// The first rendered line of a file's diff, used to anchor file-scoped comments.
// Pierre suppresses the header-prefix slot whenever a custom header is present
// (renderDiffChildren makes them mutually exclusive), so file comments can't
// live "between header and body" — instead they ride the line-annotation slot
// Project a file's LINE annotations into Pierre's DiffLineAnnotation shape (side,
// lineNumber = lineEnd, metadata = DiffAnnotationMetadata). File-scoped comments
// are deliberately excluded — they render in the file header (renderCustomHeader),
// not the gutter (see fileCommentsByPath).
export function projectFileAIMarkers(
aiMessages: AIChatEntry[],
filePath: string,
): DiffLineAnnotation<DiffAnnotationMetadata>[] {
return aiMessages
.filter(
({ question }) =>
question.filePath === filePath &&
question.lineStart != null &&
question.lineEnd != null,
)
.map(({ question, response }) => ({
side: question.side === 'new' ? ('additions' as const) : ('deletions' as const),
lineNumber: question.lineEnd!,
metadata: {
annotationId: question.id,
type: 'comment' as CodeAnnotationType,
kind: 'ai-marker' as const,
questionId: question.id,
promptPreview: question.prompt.slice(0, 40) + (question.prompt.length > 40 ? '...' : ''),
hasResponse: !!response.text && !response.error,
isStreaming: response.isStreaming,
},
}));
}
function projectFileAnnotations(
annotations: CodeAnnotation[],
aiMessages: AIChatEntry[],
filePath: string,
prUrl: string | undefined,
prDiffScope: string | undefined,
): DiffLineAnnotation<DiffAnnotationMetadata>[] {
const reviewAnnotations = annotations
.filter(
(a) =>
a.filePath === filePath &&
(a.scope ?? 'line') === 'line' &&
annotationMatchesPrScope(a, prUrl, prDiffScope),
)
.map((ann) => ({
side: ann.side === 'new' ? ('additions' as const) : ('deletions' as const),
lineNumber: ann.lineEnd,
metadata: lineAnnotationMetadata(ann),
}));
return [...reviewAnnotations, ...projectFileAIMarkers(aiMessages, filePath)];
}
function buildItemIdentity(
files: DiffFile[],
visualOrder: number[],
annotations: CodeAnnotation[],
aiMessages: AIChatEntry[],
prUrl: string | undefined,
prDiffScope: string | undefined,
patchHashes: string[],
seedCollapsed: boolean,
generatedFiles: Set<string> | undefined,
expandedGeneratedFiles: Set<string> | undefined,
): ItemIdentity {
const items: CodeViewItem<DiffAnnotationMetadata>[] = [];
const filePathToItemId = new Map<string, string>();
const filePathToItemIds = new Map<string, string[]>();
const itemIdToFilePath = new Map<string, string>();
const itemIdToFile = new Map<string, DiffFile>();
const usedIds = new Set<string>();
const nextSuffixByBase = new Map<string, number>();
const allocateId = (path: string): string => {
if (!usedIds.has(path)) {
usedIds.add(path);
return path;
}
let suffix = nextSuffixByBase.get(path) ?? 2;
let id = `${path}?${suffix}`;
while (usedIds.has(id)) {
suffix++;
id = `${path}?${suffix}`;
}
nextSuffixByBase.set(path, suffix + 1);
usedIds.add(id);
return id;
};
for (const index of visualOrder) {
const file = files[index];
if (!file) continue;
// getSingularPatch throws when a patch doesn't parse to exactly one file.
// The legacy per-file surface isolated such failures to one FileDiff; here
// one bad patch must not take down the whole all-files surface — skip the
// file (it remains reachable via the tree / single-file panel).
let fileDiff: FileDiffMetadata;
try {
fileDiff = getSingularPatch(file.patch);
} catch (err) {
console.warn(`AllFilesCodeView: skipping unparseable patch for ${file.path}`, err);
continue;
}
const id = allocateId(file.path);
// cacheKey seeds worker highlighting (a later phase), whose cache is a
// singleton that SURVIVES fileSetKey remounts — so the key must be unique
// per item (duplicate display paths) AND per diff content (the same path
// across a base/whitespace/PR switch carries different contents). The
// content hash is the same one fileSetKey uses.
fileDiff.cacheKey = `${id}#${patchHashes[index] ?? ''}`;
// Seed annotations at build time so the first render (and any remount via
// fileSetKey) already paints existing annotations without an extra update.
const fileAnnotations = projectFileAnnotations(annotations, aiMessages, file.path, prUrl, prDiffScope);
// Generated files (#1317) seed collapsed like GitHub's diff view, unless
// the user already expanded them this session. A view-state seed only —
// the item carries the full fileDiff either way.
const seedFileCollapsed = seedCollapsed
|| (generatedFiles?.has(file.path) === true && expandedGeneratedFiles?.has(file.path) !== true);
items.push({
id,
type: 'diff',
fileDiff,
version: 0,
annotations: fileAnnotations,
...(seedFileCollapsed && { collapsed: true }),
});
// First occurrence of a path wins the canonical lookup so the file tree
// (keyed by path) navigates to the primary item for that path.
if (!filePathToItemId.has(file.path)) {
filePathToItemId.set(file.path, id);
}
const twins = filePathToItemIds.get(file.path);
if (twins) twins.push(id);
else filePathToItemIds.set(file.path, [id]);
itemIdToFilePath.set(id, file.path);
itemIdToFile.set(id, file);
}
return { items, filePathToItemId, filePathToItemIds, itemIdToFilePath, itemIdToFile };
}
// Resolved pixel height of the custom header. Must equal FileHeader's fixed
// container height (`style={{ height: 'var(--panel-header-h)' }}`) so CodeView's
// virtualization reserves exactly the right space for the header. FileHeader is
// internally responsive (ResizeObserver shrinks labels) but its OUTER box height
// is fixed, so the responsive label changes never alter the row height.
const PANEL_HEADER_HEIGHT = 33; // --panel-header-h
const COMPACT_PANEL_HEADER_HEIGHT = 44;
// Hunk separator height forced by usePierreTheme unsafeCSS:
// [data-separator='line-info'] { height: 24px; margin-block: 4px; }
// => 24 + 4*2 = 32. Pierre's own 'line-info' default metric is also 32, so
// passing it is redundant today — kept explicit so the metric stays pinned to
// OUR unsafeCSS rule rather than silently tracking a library default.
const HUNK_SEPARATOR_HEIGHT = 32;
// How long the scroller must be quiet before queued augmentation applies
// (item growth + re-render) are allowed to land. Slightly above Pierre's own
// post-interaction restore delay (120ms).
const AUGMENT_APPLY_IDLE_MS = 150;
const EMPTY_AI_MESSAGES: AIChatEntry[] = [];
const noopAIMarkerClick = () => {};
export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
files,
diffStyle,
diffOverflow,
diffIndicators,
lineDiffType,
disableLineNumbers,
disableBackground,
expandUnchanged,
fontFamily,
fontSize,
annotations,
selectedAnnotationId,
scrollTargetAnnotation,
pendingSelection,
reviewBase,
reviewSnapshotId,
compactTouchLayout,
onLineSelection,
onAddAnnotationForFile,
onEditAnnotation,
onSelectAnnotation,
onDeleteAnnotation,
onAddFileCommentForFile,
viewedFiles,
onToggleViewed,
showViewedControls = true,
stagedFiles,
onStage,
canStageFiles = false,
showStageControls = true,
canStagePath,
stagingFile,
stageError,
generatedFiles,
expandedGeneratedFiles,
onGeneratedFileCollapsedChange,
prUrl,
prDiffScope,
searchQuery = '',
searchMatches = [],
activeSearchMatchId = null,
activeSearchMatch = null,
onCodeNavRequest,
onVisibleFileChange,
fileScrollTarget,
fileOrder,
registerCollapseAllToggle,
onAllCollapsedChange,
defaultCollapsed,
mountCollapsed,
initialScrollPosition = 0,
onScrollPositionChange,
onFileCollapsedChange,
leadingContent,
isActive = true,
readOnly = false,
aiAvailable = false,
onAskAIForFile,
isAILoading = false,
onViewAIResponse,
aiMessages = EMPTY_AI_MESSAGES,
onClickAIMarker,
getAIHistoryForFile,
allowScrollChaining = false,
enableEditSuggestions = false,
onAddSuggestionsForFile,
onAddEditorCommentForFile,
}) => {
const mountCollapsedRef = useRef(mountCollapsed);
const seedCollapsed = mountCollapsedRef.current ?? defaultCollapsed;
// showFileHeader: true suppresses usePierreTheme's `[data-title]` hide rule.
// With renderCustomHeader the built-in header runs in 'custom' mode (only the
// header-custom slot, no [data-title] element), so that rule is moot either
// way — we keep `true` to be explicit that the built-in title is irrelevant
// here (our FileHeader owns all header chrome).
const pierreTheme = usePierreTheme({
fontFamily,
fontSize,
showFileHeader: true,
compactTouchLayout,
});
// Worker-pool highlighting: wait for the pool so the first tokenization
// wave runs in workers (not a main-thread fallback), and keep the pool's
// theme pair in step with the UI theme.
const workerPoolReady = useIsWorkerPoolReadyOrDisabled();
useWorkerPoolThemeSync(pierreTheme.syntaxTheme);
const viewerRef = useRef<CodeViewHandle<DiffAnnotationMetadata> | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// State mirror of the scroll container so the leading-content portal can
// mount once CodeView has rendered it (a plain ref can't trigger that).
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const attachScrollContainer = useCallback((el: HTMLDivElement | null) => {
scrollRef.current = el;
setScrollEl(el);
}, []);
// Measured height of the leading content (commit description card) — becomes
// CodeView's layout.paddingTop so the virtualized items start below it and
// the card scrolls away with the content like normal document flow.
const [leadingHeight, setLeadingHeight] = useState(0);
const leadingElRef = useRef<HTMLDivElement | null>(null);
const attachLeadingEl = useCallback((el: HTMLDivElement | null) => {
leadingElRef.current = el;
if (el) setLeadingHeight(el.offsetHeight);
}, []);
useEffect(() => {
const el = leadingElRef.current;
if (!el) {
setLeadingHeight(0);
return;
}
const observer = new ResizeObserver(() => setLeadingHeight(el.offsetHeight));
observer.observe(el);
return () => observer.disconnect();
}, [scrollEl, leadingContent]);
const toolbarHostRef = useRef<ToolbarHostHandle>(null);
// NOTE: no center split dragger on this surface (parity with the legacy
// all-files view, which never had one). One global drag line spanning every
// file is noise on files where a split is meaningless (new/deleted files),
// and the columns default to Pierre's even 1fr/1fr split. The single-file
// DiffViewer keeps its per-file dragger.
// The file path CodeView currently reports as visible (active-file highlight).
// Reset on diff switch so stepping/highlighting never anchors on an old file.
const visibleFileRef = useRef<string | null>(null);
// The file CodeView last reported a selection / line-click in. The toolbar is
// keyed off this file's path + patch, but the value is sourced from the
// CodeView callback context (item.id) — never from geometry inference.
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
// Mirror ref so stable callbacks (Ask AI) read the active file at CALL time.
const activeFilePathRef = useRef(activeFilePath);
activeFilePathRef.current = activeFilePath;
const [selectedLines, setSelectedLines] = useState<CodeViewLineSelection | null>(null);
// A range whose toolbar must open only after the ToolbarHost remounts against
// the newly-activated file (its patch/filePath props changed this render).
const pendingToolbarRange = useRef<SelectedLineRange | null>(null);
// File-scoped comment popover anchor (P3). Anchored by the FileHeader button
// ref handed through the render slot — NOT by querying the recycled/portaled
// header DOM (CodeView reuses header elements, so a DOM lookup is unreliable).
const [fileCommentAnchor, setFileCommentAnchor] = useState<{ el: HTMLElement; filePath: string } | null>(null);
// Per-file-comment-button ref map so the `c` keyboard shortcut can anchor the
// popover without DOM querying. Eagerly populated/cleared by FileHeader's
// fileCommentButtonRef callback as header slots mount/unmount (clicking also
// refreshes the entry via handleFileComment).
const fileCommentButtonRefs = useRef<Map<string, HTMLElement>>(new Map());
// Previous snapshots of header-driving props (see the header-refresh effect
// below). Declared up here with the other refs so the diff-switch reset effect
// can resync them.
const prevViewedRef = useRef<Set<string> | undefined>(viewedFiles);
const prevStagedRef = useRef<Set<string> | undefined>(stagedFiles);
const prevStagingRef = useRef<string | null | undefined>(stagingFile);
const prevStageErrorRef = useRef<string | null | undefined>(stageError);
// Previous line-card snapshots for the per-item annotation-sync effect (P4).
const prevAnnotationsRef = useRef<CodeAnnotation[]>(annotations);
const prevAIMessagesRef = useRef<AIChatEntry[]>(aiMessages);
// Order items to mirror whichever left panel is active: 'tree' replays the
// file-tree's visual order (folders-first); 'list' keeps the files array
// order verbatim — which the sections view already arranged top-down
// (committed → staged → unstaged → untracked). Scrolling this surface then
// always tracks the visible left-panel list one-to-one.
const visualOrder = useMemo(() => {
if (fileOrder === 'list') return files.map((_, index) => index);
const tree = buildFileTree(files);
return getVisualFileOrder(tree);
}, [files, fileOrder]);
// `initialItems` + the identity maps are recomputed whenever the file set
// changes. CodeView is uncontrolled (the Diffshub pattern) and only seeds
// `initialItems` once per instance, so changing `files` in place would NOT
// re-seed it. The ALL_FILES dock panel is reused (single fixed panel id,
// `getPanel().api.setActive()`), and diff-type/base/PR-scope/PR/whitespace
// switches all call `setFiles(...)` WITHOUT recreating the panel — so this
// component instance survives a diff switch. To keep CodeView in sync with
// the new diff we remount it via `fileSetKey` (below), which re-runs the
// `initialItems` seed against the freshly computed identity. This restores
// the legacy AllFilesDiffView behavior (which reads `files` live).
// NOTE: `annotations` is intentionally NOT in the dep list. The identity (and
// the CodeView remount it drives via fileSetKey) must only change when the
// FILE SET changes — otherwise every annotation add/edit/delete would remount
// the whole CodeView and lose scroll/selection state. Existing annotations are
// seeded into items on (re)build via the latest refs for the first paint;
// subsequent annotation/AI-message changes are applied incrementally per item
// by the annotation-sync effect below (updateItem on only the changed file).
const annotationsRef = useRef(annotations);
annotationsRef.current = annotations;
const aiMessagesRef = useRef(aiMessages);
aiMessagesRef.current = aiMessages;
// Per-file patch content hashes — shared by fileSetKey (remount detection)
// and the items' cacheKeys (highlight cache identity). Hashed once per
// files-identity change.
const patchHashes = useMemo(() => files.map((f) => hashString(f.patch)), [files]);
// Generated-file collapse seeding (#1317). The generated SET is content-keyed
// (generatedKey) so a refreshed payload carrying an equal set never rebuilds
// the identity or remounts CodeView; the user's EXPANDED set is read via ref
// so expanding a file (session state owned by App) never rebuilds either —
// the live Pierre item already reflects it, and the ref makes any LATER
// rebuild (diff switch, order change) re-seed those files expanded.
const expandedGeneratedRef = useRef(expandedGeneratedFiles);
expandedGeneratedRef.current = expandedGeneratedFiles;
const generatedKey = useMemo(
() => (generatedFiles && generatedFiles.size > 0 ? [...generatedFiles].sort().join('\n') : ''),
[generatedFiles],
);
const identity = useMemo<ItemIdentity>(
() => buildItemIdentity(
files,
visualOrder,
annotationsRef.current,
aiMessagesRef.current,
prUrl,
prDiffScope,
patchHashes,
seedCollapsed === true,
generatedFiles,
expandedGeneratedRef.current,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[files, visualOrder, prUrl, prDiffScope, patchHashes, seedCollapsed, generatedKey],
);
const { filePathToItemId, filePathToItemIds, itemIdToFilePath, itemIdToFile } = identity;
// Stable identity of the current diff. Changes whenever the file set or any
// file's patch CONTENT changes (diff type / base / whitespace / PR switch),
// and is used as the CodeView `key` to force a remount + fresh seed.
const fileSetKey = useMemo(
// prUrl/prDiffScope are part of the key so a pure scope switch (layer ↔
// full-stack, same file set) remounts and re-seeds annotations through the
// current scope filter — the incremental sync bails on an unchanged
// annotations ref and can't otherwise detect the filter change.
// fileOrder is part of the key: CodeView seeds initialItems once per
// instance, so an order change must remount to re-seed in the new order.
// seedCollapsed is part of the key: normal surfaces can change their live
// default, while guide mounts keep their captured seed stable.
// generatedKey is part of the key (hashed — it can hold many paths): the
// generated set only changes with a served payload, and a changed set must
// remount so items re-seed through the new per-file collapse defaults.
// The user's expandedGenerated set is deliberately NOT in the key —
// expansion is live item state, and remounting on expand would lose
// scroll/selection state.
() => `${fileOrder ?? 'tree'}:${seedCollapsed ? 'c' : 'e'}:g${generatedKey ? hashString(generatedKey) : ''}:${prUrl ?? ''}:${prDiffScope ?? ''}:${reviewSnapshotId ?? ''}:${files.length}:${files.map((f, i) => `${f.path}#${patchHashes[i]}`).join('|')}`,
[files, patchHashes, prUrl, prDiffScope, reviewSnapshotId, fileOrder, seedCollapsed, generatedKey],
);
// Visual-order list of file paths (for [/] stepping). Derived from items so it
// matches CodeView's rendered order exactly.
const orderedItemIds = useMemo(
() => identity.items.map((item) => item.id),
[identity.items],
);
// Path -> DiffFile lookup for the on-demand content augmentation (P5). The
// post-render callback resolves item.id -> path -> DiffFile to know which
// file's patch/oldPath to fetch + reparse.
const activePatch = useMemo(
() => (activeFilePath ? files.find((f) => f.path === activeFilePath)?.patch ?? '' : ''),
[files, activeFilePath],
);
// --- Search (P6) ------------------------------------------------------------
// Group search matches by the CodeView item id that owns the file, so each
// item's onPostRender (and the bulk reapply effect) can apply ONLY its own
// matches. Matches are file-keyed (filePath); resolve to itemId via the bridge.
const matchesByItemId = useMemo(() => {
const map = new Map<string, ReviewSearchMatch[]>();
if (searchMatches.length === 0) return map;
for (const match of searchMatches) {
const itemId = filePathToItemId.get(match.filePath);
if (itemId == null) continue;
const group = map.get(itemId);
if (group) group.push(match);
else map.set(itemId, [match]);
}
return map;
}, [searchMatches, filePathToItemId]);
// Read search state through refs so the stable onPostRender callback always
// sees the latest values without changing the CodeView options identity (which
// would churn the options object and reset CodeView).
const matchesByItemIdRef = useRef(matchesByItemId);
matchesByItemIdRef.current = matchesByItemId;
const searchQueryRef = useRef(searchQuery);
searchQueryRef.current = searchQuery;
const activeSearchMatchIdRef = useRef(activeSearchMatchId);
activeSearchMatchIdRef.current = activeSearchMatchId;
// The CodeView callback context gives us the owning item directly, so file
// identity comes from `item.id` instead of header-geometry inference. If the
// toolbar is already keyed to this file, open immediately; otherwise activate
// the file first and defer until ToolbarHost remounts against its patch.
const routeSelectionToToolbar = useCallback(
(range: SelectedLineRange, filePath: string) => {
if (activeFilePath === filePath) {
toolbarHostRef.current?.handleLineSelectionEnd(range);
} else {
pendingToolbarRange.current = range;
setActiveFilePath(filePath);
// Paint the highlight on the TARGET item directly. The mirror effect
// below can't be trusted to do this: it no-ops on value-equal ranges
// (so a text-drag selecting the same line numbers as the previous
// file's selection would leave the highlight stranded there), and it
// pairs pendingSelection with activeFilePath, which hasn't committed
// yet.
const itemId = filePathToItemId.get(filePath);
if (itemId != null) setSelectedLines({ id: itemId, range });
// Publish the new range alongside the new active file so the
// pendingSelection mirror effect never sees the PREVIOUS file's range
// paired with the new activeFilePath (one-frame wrong highlight).
// openToolbar re-publishes the same range when the deferred flush
// runs — harmless duplicate.
onLineSelection(range);
}
},
[activeFilePath, onLineSelection, filePathToItemId],
);
// Once ToolbarHost has remounted against the newly-active file, flush the
// deferred selection so the toolbar opens with the correct file + range.
// Keyed on activeFilePath AND activePatch: two different files can carry
// byte-identical patch text (two empty new files, the same one-line change),
// in which case switching the active file does NOT change activePatch — and
// a patch-only dependency would never flush, silently swallowing the
// selection.
useEffect(() => {
if (pendingToolbarRange.current && activePatch) {
toolbarHostRef.current?.handleLineSelectionEnd(pendingToolbarRange.current);
pendingToolbarRange.current = null;
}
}, [activeFilePath, activePatch]);
const handleAddAnnotation = useCallback(
(
type: CodeAnnotationType,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel,
decorations?: ConventionalDecoration[],
tokenMeta?: TokenAnnotationMeta,
) => {
if (!activeFilePath) return;
onAddAnnotationForFile(
activeFilePath,
type,
text,
suggestedCode,
originalCode,
conventionalLabel,
decorations,
tokenMeta,
);
},
[activeFilePath, onAddAnnotationForFile],
);
// Ask AI + AI history routed by THIS surface's active file (the file the
// toolbar selection lives in) — never by the single-file panel's focus index.
const handleAskAIForActiveFile = useMemo(() => {
if (!onAskAIForFile) return undefined;
return (question: string) => {
const filePath = activeFilePathRef.current;
if (filePath) onAskAIForFile(filePath, question);
};
}, [onAskAIForFile]);
const aiHistoryForActiveFile = useMemo(
() => (getAIHistoryForFile && activeFilePath ? getAIHistoryForFile(activeFilePath) : []),
[getAIHistoryForFile, activeFilePath],
);
// Edit routes through the ToolbarHost handle (same as AllFilesDiffView). The
// annotation's id resolves to the full CodeAnnotation so the toolbar opens
// pre-filled. ToolbarHost is keyed to the active file's patch; startEdit
// positions itself by last-known mouse position, so it works regardless of
// which file the clicked annotation belongs to.
// useStableCallback + ref read: this handler is baked into slot-portal
// elements (InlineAnnotation onEdit) that only republish on version bumps,
// so it must resolve the annotation at CALL time, never from a captured
// closure.
const handleEditAnnotation = useStableCallback((id: string) => {
const ann = annotationsRef.current.find((a) => a.id === id);
if (!ann) return;
toolbarHostRef.current?.startEdit(ann);
});
// Per-file file-scoped comments, namespaced to the active PR/diff-scope. These
// render in the file HEADER (renderCustomHeader, below the path) when the file
// is expanded — not in the gutter — so they read as a file-level note rather
// than a stray line comment.
const fileCommentsByPath = useMemo(() => {
const map = new Map<string, CodeAnnotation[]>();
for (const a of annotations) {
if (!isFileScopedAnnotation(a) || !annotationMatchesPrScope(a, prUrl, prDiffScope)) continue;
const arr = map.get(a.filePath);
if (arr) arr.push(a);
else map.set(a.filePath, [a]);
}
return map;
}, [annotations, prUrl, prDiffScope]);
// Render a single annotation from item state. `renderAnnotation` receives both
// the LineAnnotation and DiffLineAnnotation union — guard `'side' in
// annotation && item.type === 'diff'` (the Diffshub pattern) so file-item
// annotations (none here) and metadata-less annotations are skipped. Actions
// route by the OWNING item, not an active-file side channel.
const renderAnnotation = useStableCallback(
(
annotation:
| DiffLineAnnotation<DiffAnnotationMetadata>
| LineAnnotation<DiffAnnotationMetadata>,
item: CodeViewItem<DiffAnnotationMetadata>,
) => {
if (!('side' in annotation) || item.type !== 'diff') return null;
if (!annotation.metadata) return null;
if (annotation.metadata.kind === 'ai-marker') {
return (
<InlineAIMarker
questionId={annotation.metadata.questionId!}
promptPreview={annotation.metadata.promptPreview!}
hasResponse={annotation.metadata.hasResponse!}
isStreaming={annotation.metadata.isStreaming!}
onClick={onClickAIMarker ?? noopAIMarkerClick}
/>
);
}
const filePath = itemIdToFilePath.get(item.id);
return (
<InlineAnnotation
metadata={annotation.metadata}
language={filePath ? detectLanguage(filePath) : undefined}
isSelected={selectedAnnotationId === annotation.metadata.annotationId}
onSelect={onSelectAnnotation}
onEdit={handleEditAnnotation}
onDelete={onDeleteAnnotation}
/>
);
},
);
// Reset to a fresh state when the file set changes (diff switch). CodeView
// itself is remounted via `fileSetKey`; this clears the React-side toolbar /
// selection / active-file / header state so nothing keys off a file from the
// old diff.
useEffect(() => {
// Keep the reader's place across a diff switch/refresh: if the file they
// were on still exists in the new diff, scroll the remounted CodeView back
// to it (rAF lets the seed render settle first). Matters most for the
// staleness-refresh flow — "Refresh" must not dump the user at the top.
const previousVisible = visibleFileRef.current;
if (previousVisible) {
const restoreId = filePathToItemId.get(previousVisible);
if (restoreId != null) {
requestAnimationFrame(() => {
viewerRef.current?.scrollTo({ type: 'item', id: restoreId, align: 'start' });
});
}
}
setActiveFilePath(null);
setSelectedLines(null);
pendingToolbarRange.current = null;
visibleFileRef.current = null;
// An edit session cannot survive the CodeView remount (Pierre tears the
// editor down without a completion callback), and fileSetKey also changes
// on sort-order / collapse-default flips, not just diff switches. The
// session controller drops a clean session silently; a dirty one prompts
// to keep its recovered edits as suggestions (this effect runs
// post-commit, so the synchronous confirm inside is safe).
editSession.handleFileSetChange();
// A pending editor-selection comment entry is anchored to the OLD diff's
// pristine coordinates — stale once the file set changes.
setSelectionAnnotationRequest(null);
setFileCommentAnchor(null);
fileCommentButtonRefs.current.clear();
// Resync the header-refresh snapshots to the current props so the post-
// remount header-refresh effect computes deltas against THIS diff, not the
// previous one (the remounted items already seed from live props).
prevViewedRef.current = viewedFiles;
prevStagedRef.current = stagedFiles;
prevStagingRef.current = stagingFile;
prevStageErrorRef.current = stageError;
// Line cards are seeded into the remounted items at build time, so resync
// both snapshots here to avoid a spurious refresh post-remount.
prevAnnotationsRef.current = annotations;
prevAIMessagesRef.current = aiMessages;
// Garbage-collect STALE-generation content fetches. Generation-aware on
// purpose: this passive effect runs AFTER the remounted CodeView's seed
// layout effect has already fired the new diff's first postRender wave —
// augmentItem has started the NEW generation's fetches by the time we get
// here, and a blanket abort+clear would kill our own generation's work
// (re-fetch storm at best; an unaugmented initial window if the rAF
// second wave loses the race). Stale generations are already inert — the
// dedup guard ignores them and isStale() blocks their writes — so this
// sweep is pure cleanup.
for (const [itemId, entry] of augmentRef.current) {
if (entry.generation === fileSetKey) continue;
entry.controller.abort();
augmentRef.current.delete(itemId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileSetKey]);
// --- Collapse via CodeView item state (Diffshub pattern + anchor fix) ------
const [allCollapsed, setAllCollapsed] = useState(seedCollapsed === true);
// Reset the global collapse toggle when the file set changes — items re-seed
// with the current default on CodeView remount.
useEffect(() => setAllCollapsed(seedCollapsed === true), [identity.items, seedCollapsed]);
// Re-derive the collapse-all mirror from live item state after any
// per-file toggle. Matters most for commit diffs (seeded all-collapsed):
// without this, expanding one file left the dock button on "Expand all",
// and clicking it re-collapsed the file the user just opened.
const syncAllCollapsedMirror = useStableCallback(() => {
const handle = viewerRef.current;
if (handle == null) return;
const anyExpanded = identity.items.some(
({ id }) => handle.getItem(id)?.collapsed !== true,
);
setAllCollapsed(!anyExpanded);
});
const reportFileCollapsed = useStableCallback((itemId: string, collapsed: boolean) => {
const filePath = itemIdToFilePath.get(itemId);
if (!filePath) return;
onFileCollapsedChange?.(filePath, collapsed);
// Generated files (#1317): let the owner track explicit expansion so it
// survives remounts. Every collapse mutation funnels through here —
// toggle, viewed+collapse, collapse/expand-all, the collapsed-placeholder
// strip, and the navigation-driven expansions (guide outline, search
// match, sidebar comment) — so the owner's set always mirrors the live
// item state.
if (generatedFiles?.has(filePath)) onGeneratedFileCollapsedChange?.(filePath, collapsed);
});
const toggleItemCollapsed = useStableCallback((itemId: string) => {
const handle = viewerRef.current;
const viewer = handle?.getInstance();
const item = handle?.getItem(itemId);
if (handle == null || viewer == null || item == null) return;
// Collapsing a file that is mid-edit ends its session first (Pierre would
// otherwise end it implicitly; routing through finishIfEditing keeps the
// suggestion capture + pristine restore on our one code path).
if (item.collapsed !== true) editSession.finishIfEditing(itemId);
// If the item top is above scrollTop, re-anchor after the update so the
// collapsing file stays in view (it would otherwise shift the content
// below it upward, jumping the scroll). Diffshub anchor fix.
const itemTop = viewer.getTopForItem(itemId);
item.collapsed = item.collapsed !== true;
item.version = (item.version ?? 0) + 1;
if (!handle.updateItem(item)) return;
syncAllCollapsedMirror();
reportFileCollapsed(itemId, item.collapsed === true);
if (itemTop != null && itemTop < viewer.getScrollTop()) {
viewer.scrollTo({ type: 'item', id: itemId, align: 'start' });
}
});
// Collapse a file (idempotent) — used by viewed+collapse so marking a file
// viewed also folds it away, matching the legacy view.
const collapseItem = useStableCallback((itemId: string) => {
const handle = viewerRef.current;
const item = handle?.getItem(itemId);
if (handle == null || item == null || item.collapsed === true) return;
editSession.finishIfEditing(itemId);
item.collapsed = true;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
syncAllCollapsedMirror();
reportFileCollapsed(itemId, true);
});
const isItemCollapsed = useCallback((itemId: string): boolean => {
return viewerRef.current?.getItem(itemId)?.collapsed === true;
}, []);
// Collapse or expand every file at once — driven by the floating bottom-left
// toggle. Pins scroll to the first file when collapsing so the view doesn't
// jump into empty space.
const setAllItemsCollapsed = useStableCallback((collapsed: boolean) => {
const handle = viewerRef.current;
if (handle == null) return;
for (const { id } of identity.items) {
const item = handle.getItem(id);
if (item == null || (item.collapsed === true) === collapsed) continue;
if (collapsed) editSession.finishIfEditing(id);
item.collapsed = collapsed;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
reportFileCollapsed(id, collapsed);
}
if (collapsed) {
const first = identity.items[0]?.id;
if (first) handle.getInstance()?.scrollTo({ type: 'item', id: first, align: 'start' });
}
});
const handleToggleAllCollapsed = useStableCallback(() => {
const handle = viewerRef.current;
if (handle == null) return;
// If anything is open, collapse all; otherwise expand all. Computed from
// live item state so it stays correct after manual per-file toggles.
const anyExpanded = identity.items.some(
({ id }) => handle.getItem(id)?.collapsed !== true,
);
setAllItemsCollapsed(anyExpanded);
setAllCollapsed(anyExpanded);
});
// The collapse-all control lives in the dock tab strip, not in this
// component — register the handler (and mirror the collapsed flag) so the
// header button can drive and reflect this view.
useEffect(() => {
registerCollapseAllToggle?.(handleToggleAllCollapsed);
return () => registerCollapseAllToggle?.(null);
}, [registerCollapseAllToggle, handleToggleAllCollapsed]);
useEffect(() => {
onAllCollapsedChange?.(allCollapsed);
}, [onAllCollapsedChange, allCollapsed]);
// Force CodeView to re-render an item's slots (header included) WITHOUT
// otherwise mutating it. Pierre renders `renderCustomHeader` into a portal
// driven by an internal store that only republishes on item mount / unmount /
// updateItem. Because `renderCustomHeader` is a stable callback (its identity
// never changes), the memoized SlotPortals will NOT re-render when external
// React state captured by the closure (viewedFiles / stagedFiles /
// stagingFile / stageError) changes. Bumping `item.version` + `updateItem`
// republishes the slot so the header reflects the new state — the same path
// collapse already uses.
const refreshItem = useCallback((itemId: string) => {
const handle = viewerRef.current;
const item = handle?.getItem(itemId);
if (handle == null || item == null) return;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
}, []);
// --- Lazy full-content hunk expansion via CodeView item updates (P5) --------
// Per-item augmentation bookkeeping. `status` guards against double-fetch /
// fetch storms (an item can re-fire onPostRender on every scroll-driven
// remount of its element); `controller` lets us abort an in-flight fetch when
// the diff switches or the component unmounts. Keyed by CodeView item id.
// `generation` is the fileSetKey at fetch start. It makes stale entries
// self-invalidating across diff switches: the remounted CodeView's first
// postRender wave fires BEFORE the diff-switch reset effect can clear this
// map (layout vs passive effect timing), so an entry from the previous diff
// must not satisfy the dedup guard — and a fetch from the previous diff must
// never write into the new diff's (same-id) item.
const augmentRef = useRef<
Map<
string,
{ status: 'pending' | 'done' | 'error'; controller: AbortController; generation: string }
>
>(new Map());
// reviewBase / itemIdToFile / fileSetKey read through refs so the stable
// onPostRender callback always sees the latest values without changing
// identity (which would otherwise churn the CodeView options object).
const reviewBaseRef = useRef(reviewBase);
reviewBaseRef.current = reviewBase;
const reviewSnapshotIdRef = useRef(reviewSnapshotId);
reviewSnapshotIdRef.current = reviewSnapshotId;
const itemIdToFileRef = useRef(itemIdToFile);
itemIdToFileRef.current = itemIdToFile;
const fileSetKeyRef = useRef(fileSetKey);
fileSetKeyRef.current = fileSetKey;
const readOnlyRef = useRef(readOnly);
readOnlyRef.current = readOnly;
// --- Edit-to-suggestion sessions (EXPERIMENTAL, flag-gated) -----------------
// One file at a time; the editor chunk lazy-loads on first entry; the item's
// pristine FileDiffMetadata is deep-cloned before the session and restored
// (via version bump + updateItem) when it ends. See useEditSession.
const editEnabled = enableEditSuggestions && onAddSuggestionsForFile != null;
// A pending "Make annotation" request from the edit session's Selection
// Action popover. The Pierre popover (shadow DOM) only snapshots the
// selection; the actual comment entry is the app's own CommentPopover,
// anchored at the snapshotted rect — focusing an input inside the editor's
// popover would blur the editor, collapse the selection, and tear the
// popover down mid-typing, so entry deliberately lives OUTSIDE the editor.
const [selectionAnnotationRequest, setSelectionAnnotationRequest] =
useState<EditSelectionAnnotationRequest | null>(null);
const editSession = useEditSession({
enabled: editEnabled,
viewerRef,
itemIdToFileRef,
fileSetKeyRef,
reviewBaseRef,
reviewSnapshotIdRef,
annotationsRef,
onAddSuggestions: onAddSuggestionsForFile,
onSelectionAnnotation: onAddEditorCommentForFile ? setSelectionAnnotationRequest : undefined,
refreshItem,
});
// Surface a mid-session comment inside the editor as a marker as soon as it
// lands in the annotations prop. Stable callback; no-op outside a session.
const refreshEditSessionMarkers = editSession.refreshMarkers;
useEffect(() => {
refreshEditSessionMarkers();
}, [annotations, refreshEditSessionMarkers]);
// Augmentation APPLIES are deferred to scroll-idle. updateItem() mutates
// item layout — the full-content parse counts collapsed-context regions the
// raw-patch parse doesn't, so the item GROWS — and forces a re-render +
// re-tokenize. Landing that mid-gesture causes visible chop; worse, when
// the grown item sits ABOVE CodeView's scroll anchor, its corrective
// scrollTo() kills wheel momentum and pins the viewport ("scrolling but
// nothing changes"). Fetches still start as items enter the window — only
// the item mutation waits for the scroll to settle. Staleness is re-checked
// at apply time; the per-item map keeps only the newest apply per item.
const pendingAugmentAppliesRef = useRef(new Map<string, () => void>());
const augmentFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastScrollTsRef = useRef(0);
const flushAugmentApplies = useCallback(() => {
augmentFlushTimerRef.current = null;
const idleFor = Date.now() - lastScrollTsRef.current;
if (idleFor < AUGMENT_APPLY_IDLE_MS) {
augmentFlushTimerRef.current = setTimeout(
flushAugmentApplies,
AUGMENT_APPLY_IDLE_MS - idleFor + 10,
);
return;
}
const applies = [...pendingAugmentAppliesRef.current.values()];
pendingAugmentAppliesRef.current.clear();
for (const apply of applies) apply();
}, []);
const queueAugmentApply = useCallback((itemId: string, apply: () => void) => {
pendingAugmentAppliesRef.current.set(itemId, apply);
if (augmentFlushTimerRef.current == null) {
augmentFlushTimerRef.current = setTimeout(flushAugmentApplies, AUGMENT_APPLY_IDLE_MS);
}
}, [flushAugmentApplies]);
useEffect(() => () => {
if (augmentFlushTimerRef.current != null) clearTimeout(augmentFlushTimerRef.current);
}, []);
// Fetch full file contents for one item, reparse with processFile, and swap
// the item's fileDiff in place so hunk expansion (expand-unchanged gutter
// controls) works against the COMPLETE file. Mirrors LazyFileDiff's per-mount
// fetch, but updates the existing CodeView item instead of mounting a fresh
// FileDiff — so CodeView's own virtualization + element pool stay in charge.
const augmentItem = useCallback((itemId: string) => {
// NOTE: deliberately no viewerRef check here. The FIRST onPostRender wave
// (every initially visible item) fires synchronously inside CodeView's seed
// layout effect, which runs BEFORE useImperativeHandle assigns the handle —
// so viewerRef.current is still null at that point. Bailing on a null
// handle would make the initial window depend entirely on CodeView's
// second (rAF `fitPerfectly`) render wave for augmentation — a library
// implementation detail we'd rather not lean on. The handle is only needed
// at fetch RESOLUTION, where it is re-read fresh from the ref.
const augmentState = augmentRef.current;
const generation = fileSetKeyRef.current;
// One fetch per item PER DIFF: a same-generation entry ('pending' or
// resolved) means do nothing — an item re-entering the rendered window
// re-fires onPostRender, and this guard is what prevents the fetch storm.
// An entry from a PREVIOUS diff (stale generation) does not count: abort it
// and fetch fresh for the new diff's content.
const existing = augmentState.get(itemId);
if (existing) {
if (existing.generation === generation) return;
existing.controller.abort();
}
// Resolve the file by item id (NOT path) so duplicate display paths each
// augment with their own DiffFile content.
const file = itemIdToFileRef.current.get(itemId);
if (file == null) return;
const controller = new AbortController();
// Read-only hosts have no review server: leave the raw-patch context in
// place and mark the item done so it never re-fires (no dead requests,
// no console noise from a CSP that blocks connect-src).
if (readOnlyRef.current) {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
augmentState.set(itemId, { status: 'pending', controller, generation });
// A resolution stage is stale when its fetch was aborted (unmount / diff
// switch) or the diff generation moved on while the response was in flight
// (abort() is a no-op on an already-settled fetch, and the remounted
// CodeView reuses path-derived item ids — without the generation check the
// OLD diff's content would be written into the NEW diff's item). Stale
// stages must not touch augmentState either: it now belongs to the new
// generation.
const isStale = () =>
controller.signal.aborted || fileSetKeyRef.current !== generation;
// Workspace-prefixed paths are passed through verbatim — /api/file-content
// resolves the prefix back to the owning repo (same contract LazyFileDiff /
// DiffViewer rely on).
const params = new URLSearchParams({ path: file.path });
if (file.oldPath) params.set('oldPath', file.oldPath);
const base = reviewBaseRef.current;
if (base) params.set('base', base);
const snapshot = reviewSnapshotIdRef.current;
if (snapshot) params.set('snapshot', snapshot);
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 (isStale()) return;
if (!data || (data.oldContent == null && data.newContent == null)) {
// No content available (e.g. demo mode / binary): mark done so we do
// not retry on every subsequent render. The raw-patch context still
// shows; there is just nothing to expand.
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
// Stale-content guard: the file may have changed on disk since this
// diff was captured (an agent editing/committing mid-review is normal
// usage). Augmenting with contents that no longer reconcile with the
// patch produces an internally inconsistent FileDiffMetadata — Pierre's
// virtualization then fails layout estimation for the item ("trailing
// context mismatch", content disappearing while scrolling). Keep the
// raw-patch view for this file instead; a diff refresh re-augments.
if (!isContentConsistentWithPatch(file.patch, data.oldContent, data.newContent)) {
console.warn(
`AllFilesCodeView: skipping full-content expansion for ${file.path} — file changed since the diff was captured`,
);
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
let augmented: FileDiffMetadata;
try {
const result = processFile(file.patch, {
oldFile:
data.oldContent != null
? { name: file.oldPath || file.path, contents: data.oldContent }
: undefined,
newFile:
data.newContent != null ? { name: file.path, contents: data.newContent } : undefined,
});
if (!result || result.isPartial) {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
augmented = result;
} catch {
augmentState.set(itemId, { status: 'error', controller, generation });
return;
}
if (isStale()) return;
// Defer the item mutation to scroll-idle (see queueAugmentApply) —
// landing it mid-gesture chops scrolling and can kill momentum via
// CodeView's anchor-correcting scrollTo. All staleness checks re-run
// at apply time: the queue can hold entries across aborts and diff
// switches.
queueAugmentApply(itemId, () => {
if (isStale()) return;
const liveHandle = viewerRef.current;
const item = liveHandle?.getItem(itemId);
// The item may have been torn down between fetch start and apply;
// belt-and-suspenders on top of the staleness check above.
if (liveHandle == null || item == null || item.type !== 'diff') {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
// Never clobber an active edit session's document: the editor is
// mutating item.fileDiff in place, and the session already ensured
// full content before starting. Mark done — the pristine restore at
// session end republishes whatever the session started from.
if (itemId === editSession.editingItemIdRef.current) {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
// cacheKey MUST change when fileDiff contents change (types.ts warning):
// otherwise the worker / highlight caches would serve the stale partial
// AST. Derive a fresh key from the augmented (now full-content) diff,
// scoped by generation so the same item id across diff switches never
// collides in a (future) cross-mount worker cache.
augmented.cacheKey = `${generation}::${itemId}#full`;
item.fileDiff = augmented;
item.version = (item.version ?? 0) + 1;
// updateItem re-measures the (now taller) item and resolves the captured
// scroll anchor, so the viewport stays put whether this item is above or
// below the fold — no manual scroll correction needed.
liveHandle.updateItem(item);
augmentState.set(itemId, { status: 'done', controller, generation });
});
})
.catch((err) => {
if (isStale()) {
// Aborted (unmount / diff switch) or superseded: drop the entry only
// if it is still ours — a newer generation may already own this id.
if (augmentState.get(itemId)?.controller === controller) {
augmentState.delete(itemId);
}
return;
}
augmentState.set(itemId, { status: 'error', controller, generation });
void err;
});
}, [queueAugmentApply]);
// (Re)apply search marks for ONE item's node. Called on every render of that
// item (onPostRender mount/update) so marks survive CodeView's element
// recycling — a recycled element is cleared and re-marked for whatever file it
// now shows. `node` is the item's `<diffs-container>` element. Reads search
// state through refs so the stable onPostRender callback stays identity-stable.
const applyItemHighlights = useCallback((node: HTMLElement, itemId: string) => {
const matches = matchesByItemIdRef.current.get(itemId) ?? [];
applyItemSearchHighlights(node, searchQueryRef.current, matches, activeSearchMatchIdRef.current);
}, []);
// CodeView fires onPostRender for an item whenever it enters / updates within
// the rendered window. Phase 'mount' (and 'update' for the first paint of a
// freshly-seeded item) is the direct analogue of LazyFileDiff's
// IntersectionObserver firing — so we trigger augmentation there. We ride
// CodeView's existing virtualization rather than layering our own observer on
// top (which would double-virtualize and fight the element pool).
//
// P6: the same per-item render cycle drives search-mark reconciliation. On
// mount/update we (re)apply this item's marks (defends against recycling); on
// unmount we clear them so a future reuse of the element starts clean. Marks
// are reapplied via rAF so they land after CodeView has (re)written the item's
// line DOM for this render — applying synchronously here could mark a tree
// that's about to be overwritten.
// Element -> owning item id, maintained by onPostRender below. CodeView
// recycles <diffs-container> elements across items, so this is re-registered
// on every mount/update and dropped on unmount.
const nodeToItemIdRef = useRef(new WeakMap<HTMLElement, string>());
const handlePostRender = useStableCallback(
(
node: HTMLElement,
_instance: unknown,
phase: PostRenderPhase,
context: CodeViewItem<DiffAnnotationMetadata>,
) => {
if (context.type !== 'diff') return;
if (phase === 'unmount') {
clearItemSearchHighlights(node);
nodeToItemIdRef.current.delete(node);
return;
}
// Track which item currently owns this <diffs-container> element so the
// text-drag selection handler can resolve file identity from the
// selection's shadow-root host. Registered on every mount/update because
// CodeView recycles elements across items.
nodeToItemIdRef.current.set(node, context.id);
augmentItem(context.id);
const itemId = context.id;
requestAnimationFrame(() => applyItemHighlights(node, itemId));
},
);
// Parity with DiffViewer: dragging a text selection across multiple lines of
// diff CONTENT (not the line-number gutter) opens the annotation toolbar for
// that range. CodeView's enableLineSelection only starts drags from the
// number column, so without this the all-files surface would silently lose
// the select-code-text-to-annotate interaction the single-file panel has.
// The owning file comes from the selection's shadow-root host element (each
// item renders into its own <diffs-container>), mapped via nodeToItemIdRef.
const handleContentTextSelection = useStableCallback(() => {
requestAnimationFrame(() => {
const root = scrollRef.current;
const selection = getDiffSelection(root);
if (!selection || selection.isCollapsed || !selection.toString().trim()) return;
const anchorLine = getLineNumberFromNode(selection.anchorNode);
const focusLine = getLineNumberFromNode(selection.focusNode);
if (anchorLine == null || focusLine == null) return;
// Single-line drags keep native copy behavior (same rule as DiffViewer).
if (anchorLine === focusLine) return;
const rootNode = selection.anchorNode?.getRootNode();
const host = rootNode instanceof ShadowRoot ? rootNode.host : null;
const itemId = host instanceof HTMLElement ? nodeToItemIdRef.current.get(host) : undefined;
if (itemId == null) return;
// Text drags inside an active editor are the editor's own selection.
if (itemId === editSession.editingItemIdRef.current) return;
const filePath = itemIdToFilePath.get(itemId);
if (filePath == null) return;
routeSelectionToToolbar(
{
start: Math.min(anchorLine, focusLine),
end: Math.max(anchorLine, focusLine),
side: getSideFromNode(selection.anchorNode),
},
filePath,
);
selection.removeAllRanges();
});
});
// (Re)attach on fileSetKey: the CodeView remount recreates the container
// element scrollRef points at, dropping any previously-attached listener.
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const handler = () => handleContentTextSelection();
root.addEventListener('mouseup', handler, true);
return () => root.removeEventListener('mouseup', handler, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileSetKey]);
// Abort all in-flight content fetches on unmount.
useEffect(() => {
const augmentState = augmentRef.current;
return () => {
for (const { controller } of augmentState.values()) controller.abort();
augmentState.clear();
};
}, []);
// When the query or the match set changes (but no item re-render is triggered),
// re-apply marks across every currently-rendered item. onPostRender only fires
// when an item mounts/updates/recycles, so a pure query change wouldn't repaint
// existing rows without this. We read live rendered items from the viewer (each
// carries its `<diffs-container>` element) and apply each item's own matches.
// rAF defers one frame so any pending CodeView render settles first.
useEffect(() => {
const handle = viewerRef.current;
if (handle == null) return;
const raf = requestAnimationFrame(() => {
const viewer = viewerRef.current?.getInstance();
if (viewer == null) return;
for (const rendered of viewer.getRenderedItems()) {
if (rendered.type !== 'diff' || rendered.element == null) continue;
applyItemHighlights(rendered.element, rendered.id);
}
});
return () => cancelAnimationFrame(raf);
}, [searchQuery, matchesByItemId, applyItemHighlights]);
// O(1) active-match swap when stepping between matches: recolor just the
// previously-active and newly-active marks across the whole container instead
// of rebuilding every item's marks. Mirrors DiffViewer's swap effect.
useEffect(() => {
const container = scrollRef.current;
if (container == null) return;
swapActiveSearchHighlight(container, activeSearchMatchId);
}, [activeSearchMatchId]);
// Navigate to the active match: expand its owning file (if collapsed) and
// scrollTo the line. scrollTo is DOM-independent (resolves the line top from
// CodeView's layout model), so it works even when the target row is far
// outside the rendered window — the line's marks then paint via onPostRender as
// CodeView renders the row. rAF defers the scroll one frame so an expand's
// layout settles before resolving the line top.
// Gated on isActive (also a dep): scrolling while this panel is HIDDEN
// resolves layout against a display:none container and there is no retry —
// bail instead, and the isActive flip re-runs this effect so a match
// selected while the panel was hidden scrolls once the panel is shown.
useEffect(() => {
if (!isActive) return;
if (activeSearchMatch == null) return;
const itemId = filePathToItemId.get(activeSearchMatch.filePath);
if (itemId == null) return;
const handle = viewerRef.current;
if (handle == null) return;
const item = handle.getItem(itemId);
if (item != null && item.collapsed === true) {
item.collapsed = false;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
syncAllCollapsedMirror();
reportFileCollapsed(itemId, false);
}
// ReviewSearchSide: 'addition' -> additions, 'deletion' -> deletions,
// 'context' -> additions (context rows carry the NEW-side line number in the
// search index, so the additions side resolves the correct row).
const side: 'additions' | 'deletions' =
activeSearchMatch.side === 'deletion' ? 'deletions' : 'additions';
const lineNumber = activeSearchMatch.lineNumber;
const raf = requestAnimationFrame(() => {
const viewer = viewerRef.current;
if (viewer == null) return;
viewer.scrollTo({ type: 'line', id: itemId, lineNumber, side, align: 'center' });
});
return () => cancelAnimationFrame(raf);
}, [activeSearchMatch, filePathToItemId, isActive, syncAllCollapsedMirror, reportFileCollapsed]);
// --- Annotations through CodeView item state (P4) ---------------------------
// Set an item's review annotations and AI markers to the current per-file
// projection, then republish that item only. This preserves CodeView scroll
// state while streaming answers update their inline marker.
const syncItemAnnotations = useCallback(
(
filePath: string,
itemId: string,
allAnnotations: CodeAnnotation[],
allAIMessages: AIChatEntry[],
) => {
const handle = viewerRef.current;
const item = handle?.getItem(itemId);
if (handle == null || item == null || item.type !== 'diff') return;
item.annotations = projectFileAnnotations(
allAnnotations,
allAIMessages,
filePath,
prUrl,
prDiffScope,
);
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
},
[prUrl, prDiffScope],
);
// Keep review annotations incremental: a single add/edit/delete republishes
// only files whose annotation signature changed. New diffs seed the current
// projection during build, so this path never remounts CodeView.
useEffect(() => {
const handle = viewerRef.current;
const prev = prevAnnotationsRef.current;
prevAnnotationsRef.current = annotations;
if (handle == null || prev === annotations) return;
const signatures = (list: CodeAnnotation[]) => {
const map = new Map<string, string>();
for (const a of list) {
const scope = a.scope ?? 'line';
if (scope !== 'line' && scope !== 'file') continue;
if (!annotationMatchesPrScope(a, prUrl, prDiffScope)) continue;
// File comments carry different render-affecting fields than line notes
// (no line/side/suggestion; they DO surface source + profile badges).
const sig = scope === 'file'
? JSON.stringify([
'F', a.id, a.text ?? '', a.source ?? '', a.author ?? '',
a.reviewProfileLabel ?? '', a.conventionalLabel ?? '',
(a.decorations ?? []).join(','), a.createdAt ?? 0, a.reasoning ?? '',
])
: JSON.stringify([
a.id, a.lineEnd, a.side, a.type,
a.text ?? '', a.suggestedCode ?? '', a.originalCode ?? '',
a.conventionalLabel ?? '', (a.decorations ?? []).join(','),
a.severity ?? '', a.reasoning ?? '', a.author ?? '',
a.reviewProfileLabel ?? '', a.source ?? '', a.createdAt ?? 0,
]);
map.set(a.filePath, `${map.get(a.filePath) ?? ''}${sig}\n`);
}
return map;
};
const nextSig = signatures(annotations);
const prevSig = signatures(prev);
const changedPaths = new Set<string>();
nextSig.forEach((sig, path) => {
if (prevSig.get(path) !== sig) changedPaths.add(path);
});
prevSig.forEach((_sig, path) => {
if (!nextSig.has(path)) changedPaths.add(path);
});
for (const path of changedPaths) {
for (const itemId of filePathToItemIds.get(path) ?? []) {
syncItemAnnotations(path, itemId, annotations, aiMessages);
}
}
}, [annotations, aiMessages, prUrl, prDiffScope, filePathToItemIds, syncItemAnnotations]);
// AI answers stream independently of review annotations. Any message change
// republishes only the files represented by the previous or next message set;
// projectFileAnnotations performs the final line-scope/path filter.
useEffect(() => {
const handle = viewerRef.current;
const prev = prevAIMessagesRef.current;
// If the worker-pool gate still hides CodeView, retain the old snapshot.
// The workerPoolReady dependency replays this sync once the handle exists.
if (handle == null) return;
prevAIMessagesRef.current = aiMessages;
if (prev === aiMessages) return;
const changedPaths = new Set<string>();
for (const { question } of [...prev, ...aiMessages]) {
if (question.filePath) changedPaths.add(question.filePath);
}
for (const path of changedPaths) {
for (const itemId of filePathToItemIds.get(path) ?? []) {
syncItemAnnotations(path, itemId, annotations, aiMessages);
}
}
}, [aiMessages, annotations, filePathToItemIds, syncItemAnnotations, workerPoolReady]);
// --- Header actions ---------------------------------------------------------
const handleToggleViewedAndCollapse = useStableCallback((filePath: string, itemId: string) => {
const wasViewed = viewedFiles?.has(filePath) ?? false;
onToggleViewed?.(filePath);
// Mark-as-viewed also collapses (legacy behavior); un-viewing leaves it.
// collapseItem bumps the version + updateItem so the header re-renders to
// the viewed state. Un-viewing performs no collapse, so it would otherwise
// skip the version bump and leave the (now stale) Viewed badge on screen —
// force a header refresh so the Viewed button reverts both ways.
if (!wasViewed) {
collapseItem(itemId);
} else {
refreshItem(itemId);
}
});
const handleFileComment = useStableCallback((filePath: string, anchorEl: HTMLElement) => {
fileCommentButtonRefs.current.set(filePath, anchorEl);
setFileCommentAnchor({ el: anchorEl, filePath });
});
// Header chrome (Viewed badge, staging spinner / Added checkmark, stage-error
// text) is driven by external React props, but the custom header is rendered
// into Pierre's slot portal which only republishes on updateItem — never when
// a stable render callback's captured props change. So whenever any of those
// header-driving props change, force a re-render of every affected item.
//
// Direct paths (the `a` key and the header Git Add button both call
// onStage(filePath) without bumping any version; the header Viewed button's
// un-view branch likewise) are all covered here, so the header stays in sync
// regardless of which surface triggered the change. We track the previous
// snapshots (declared with the other refs above) and refresh exactly the
// items whose state actually changed.
useEffect(() => {
const handle = viewerRef.current;
if (handle == null) {
// Update snapshots even when no viewer is mounted yet so the first real
// diff doesn't refresh everything spuriously.
prevViewedRef.current = viewedFiles;
prevStagedRef.current = stagedFiles;
prevStagingRef.current = stagingFile;
prevStageErrorRef.current = stageError;
return;
}
const changedPaths = new Set<string>();
const collectSetDelta = (
next: Set<string> | undefined,
prev: Set<string> | undefined,
) => {
if (next === prev) return;
next?.forEach((p) => {
if (!prev?.has(p)) changedPaths.add(p);
});
prev?.forEach((p) => {
if (!next?.has(p)) changedPaths.add(p);
});
};
collectSetDelta(viewedFiles, prevViewedRef.current);
collectSetDelta(stagedFiles, prevStagedRef.current);
// Generated tags (#1317) deliberately have no delta here: any
// content-changed generated set remounts CodeView via fileSetKey
// (generatedKey), so a delta on the live items is unreachable.
// stagingFile / stageError are single-file scalars: the file that just
// started/stopped staging (or whose error appeared/cleared) needs a refresh.
if (stagingFile !== prevStagingRef.current) {
if (stagingFile) changedPaths.add(stagingFile);
if (prevStagingRef.current) changedPaths.add(prevStagingRef.current);
}
if (stageError !== prevStageErrorRef.current) {
// stageError is shown on the file currently/last staging, so refresh that
// file in both the appear and clear directions.
if (stagingFile) changedPaths.add(stagingFile);
if (prevStagingRef.current) changedPaths.add(prevStagingRef.current);
}
prevViewedRef.current = viewedFiles;
prevStagedRef.current = stagedFiles;
prevStagingRef.current = stagingFile;
prevStageErrorRef.current = stageError;
for (const path of changedPaths) {
// All twins of a duplicate path share viewed/staged state (it's keyed by
// path), so refresh every item rendering it.
for (const itemId of filePathToItemIds.get(path) ?? []) {
refreshItem(itemId);
}
}
}, [viewedFiles, stagedFiles, stagingFile, stageError, filePathToItemIds, refreshItem]);
// The control-visibility preferences affect every header at once, so a
// toggle refreshes all items (same slot-portal republish constraint as the
// per-file sync above).
const prevShowViewedRef = useRef(showViewedControls);
const prevShowStageRef = useRef(showStageControls);
useEffect(() => {
const changed =
prevShowViewedRef.current !== showViewedControls ||
prevShowStageRef.current !== showStageControls;
prevShowViewedRef.current = showViewedControls;
prevShowStageRef.current = showStageControls;
if (!changed || viewerRef.current == null) return;
for (const itemIds of filePathToItemIds.values()) {
for (const itemId of itemIds) refreshItem(itemId);
}
}, [showViewedControls, showStageControls, filePathToItemIds, refreshItem]);
// --- Line selection through CodeView (replaces geometry-based inference) ---
const handleSelectedLinesChange = useStableCallback(
(selection: CodeViewLineSelection | null) => {
setSelectedLines(selection);
onLineSelection(selection ? selection.range : null);
},
);
// Mirror ref so the pendingSelection effect below can compare against the
// live CodeView selection without re-running on every drag delta.
const selectedLinesRef = useRef(selectedLines);
selectedLinesRef.current = selectedLines;
// Reconcile the App-level `pendingSelection` (the range the toolbar / AI is
// operating on) with CodeView's highlighted lines. CodeView selection is
// CONTROLLED here, and `onSelectedLinesChange` fires on EVERY drag delta —
// each delta already paints `selectedLines` on the owning item (correct id)
// AND publishes the range to App. So when pendingSelection matches the live
// selection, this effect must do NOTHING: re-deriving the highlight from
// `activeFilePath` mid-drag would clear it (activeFilePath only updates at
// pointer-up) or paint it on the previously-active file. It only acts on:
// 1. pendingSelection cleared (annotation submitted / cancelled / AI done)
// → drop the highlight instead of leaving it stuck on the file.
// 2. A toolbar-originated range CodeView doesn't know about (gutter-utility
// click on a not-yet-active file, draft restore) → paint it on the
// active file's item.
// The controlled line highlight for a selected annotation (null for file-scoped
// / unresolved). Shared by the compose-end restore and the selection-replay
// effect so the two can't diverge.
const lineSelectionForAnnotation = useStableCallback(
(ann: CodeAnnotation | null | undefined): CodeViewLineSelection | null => {
if (!ann || isFileScopedAnnotation(ann)) return null;
const itemId = filePathToItemId.get(ann.filePath);
return itemId != null ? { id: itemId, range: lineRangeForAnnotation(ann) } : null;
},
);
// Mirror so the effect below can restore the selected comment's highlight when
// a compose ends, without taking selectedAnnotationId as a dep.
const selectedAnnotationIdRef = useRef(selectedAnnotationId);
selectedAnnotationIdRef.current = selectedAnnotationId;
useEffect(() => {
if (pendingSelection == null) {
// Compose ended — restore the selected line comment's highlight instead of
// clearing, mirroring single-file's `pendingSelection ?? annotationRange`.
const ann = selectedAnnotationIdRef.current
? annotationsRef.current.find((a) => a.id === selectedAnnotationIdRef.current)
: null;
setSelectedLines(lineSelectionForAnnotation(ann));
return;
}
const current = selectedLinesRef.current;
if (
current != null &&
current.range.start === pendingSelection.start &&
current.range.end === pendingSelection.end &&
current.range.side === pendingSelection.side
) {
// Selection originated inside CodeView — already on the right item.
return;
}
if (activeFilePath) {
const itemId = filePathToItemId.get(activeFilePath);
if (itemId != null) {
setSelectedLines({ id: itemId, range: pendingSelection });
}
}
}, [activeFilePath, pendingSelection, filePathToItemId]);
const handleLineSelectionInteraction = useStableCallback(
(
source: LineSelectionSource,
range: SelectedLineRange | null,
item: CodeViewItem<DiffAnnotationMetadata>,
) => {
if (range == null || item.type !== 'diff') return;
// The file being edited owns its pointer interactions — opening the
// annotation toolbar over an active editor would fight its focus.
if (item.id === editSession.editingItemIdRef.current) return;
const filePath = itemIdToFilePath.get(item.id);
if (filePath == null) return;
if (resolveLineSelectionBehavior({
source,
compactTouchLayout: compactTouchLayout === true,
}) === 'preserve-selection') {
pendingToolbarRange.current = null;
setActiveFilePath(filePath);
setSelectedLines({ id: item.id, range });
onLineSelection(range);
return;
}
routeSelectionToToolbar(range, filePath);
},
);
const handleLineSelectionEnd = useStableCallback(
(range: SelectedLineRange | null, item: CodeViewItem<DiffAnnotationMetadata>) => {
handleLineSelectionInteraction('range-gesture', range, item);
},
);
const handleGutterUtilityClick = useStableCallback(
(range: SelectedLineRange, item: CodeViewItem<DiffAnnotationMetadata>) => {
handleLineSelectionInteraction('gutter-comment-action', range, item);
},
);
// --- Token code navigation (P7) ---------------------------------------------
// Cmd/Ctrl-click a token resolves symbol defs/refs (parity with DiffViewer and
// the legacy all-files view). File identity comes from the owning item, not an
// active-file side channel. Only wired when onCodeNavRequest is provided.
const handleTokenClick = useStableCallback(
(props: DiffTokenEventBaseProps, event: MouseEvent, item: CodeViewItem<DiffAnnotationMetadata>) => {
if (!onCodeNavRequest || item.type !== 'diff') return;
if (!(event.metaKey || event.ctrlKey)) return;
const filePath = itemIdToFilePath.get(item.id);
if (filePath == null) return;
onCodeNavRequest(buildCodeNavRequest(props, filePath));
},
);
const handleTokenEnter = useStableCallback(
(props: DiffTokenEventBaseProps, event: PointerEvent) => {
if (onCodeNavRequest && (event.metaKey || event.ctrlKey)) {
props.tokenElement.classList.add('pn-token-nav');
}
},
);
const handleTokenLeave = useStableCallback((props: DiffTokenEventBaseProps) => {
props.tokenElement.classList.remove('pn-token-nav');
});
// --- Active-file tracking via CodeView rendered items (no header geometry) ---
const reportVisibleFile = useStableCallback(() => {
const viewer = viewerRef.current?.getInstance();
if (viewer == null) return;
const rendered = viewer.getRenderedItems();
if (rendered.length === 0) return;
const scrollTop = viewer.getScrollTop();
// The active file is the last rendered item whose top is at or above the
// current scroll position (with a small threshold), i.e. the file the user
// is currently reading. Falls back to the first rendered item.
let bestId = rendered[0].id;
for (const renderedItem of rendered) {
const top = viewer.getTopForItem(renderedItem.id);
if (top == null) continue;
if (top <= scrollTop + 50) bestId = renderedItem.id;
}
// At-bottom override (legacy parity): a short final file pinned at the
// container bottom never gets its top above scrollTop+threshold, so the
// loop would leave an earlier file active while the user reads the last
// one. Uses CodeView's cached accessors — raw container.scrollHeight /
// clientHeight reads here forced a synchronous layout on EVERY scroll
// event, right after the frame's DOM writes (measurable jank).
if (
viewer.getScrollTop() + viewer.getHeight() >= viewer.getScrollHeight() - 2
) {
bestId = rendered[rendered.length - 1].id;
}
const path = itemIdToFilePath.get(bestId) ?? null;
if (path !== visibleFileRef.current) {
visibleFileRef.current = path;
onVisibleFileChange?.(path);
}
});
// Coalesced to one run per animation frame — CodeView fires onScroll per
// scroll EVENT, which can outpace frames during momentum scrolling. Also
// stamps scroll activity for the augmentation idle-flush.
const scrollReportRafRef = useRef<number | null>(null);
const handleScroll = useStableCallback((position: number) => {
lastScrollTsRef.current = Date.now();
onScrollPositionChange?.(position);
if (scrollReportRafRef.current != null) return;
scrollReportRafRef.current = requestAnimationFrame(() => {
scrollReportRafRef.current = null;
reportVisibleFile();
});
});
useEffect(() => () => {
if (scrollReportRafRef.current != null) cancelAnimationFrame(scrollReportRafRef.current);
}, []);
// CodeView's onScroll only fires on actual scroll, so seed the initial
// active-file highlight once the viewer has rendered its first window. rAF
// gives CodeView a frame to mount + measure before we read rendered items.
// Re-runs on `fileSetKey` because a diff switch remounts CodeView, so the
// new diff's first file must be re-reported as the active file.
useEffect(() => {
const raf = requestAnimationFrame(() => reportVisibleFile());
return () => cancelAnimationFrame(raf);
}, [reportVisibleFile, fileSetKey]);
// Outer Guide file shells survive while this CodeView is evicted. Restore
// their last inner position once after this component mount; later parent
// renders may expose a newer live ref value, but must not snap active scrolling.
const hasRestoredInitialScrollRef = useRef(false);
useEffect(() => {
if (hasRestoredInitialScrollRef.current || initialScrollPosition <= 0) return;
hasRestoredInitialScrollRef.current = true;
const raf = requestAnimationFrame(() => {
viewerRef.current?.scrollTo({ type: 'position', position: initialScrollPosition });
});
return () => cancelAnimationFrame(raf);
}, [fileSetKey, initialScrollPosition]);
// --- [/]/z/v/a/c/x navigation + header actions driven by CodeView ----------
const scrollToItem = useCallback((itemId: string) => {
const viewer = viewerRef.current;
if (viewer == null) return;
viewer.scrollTo({ type: 'item', id: itemId, align: 'start' });
}, []);
// File-level navigation for surfaces whose lightweight navigation UI lives
// outside CodeView (Guided Review's outline). Expand before scrolling so a
// viewed/collapsed target reveals code rather than only its file header.
// rAF waits for CodeView's initial seed/remount to publish the imperative
// handle; token semantics allow the same file to be requested repeatedly.
useEffect(() => {
if (!fileScrollTarget) return;
const itemId = filePathToItemId.get(fileScrollTarget.filePath);
if (itemId == null) return;
const raf = requestAnimationFrame(() => {
const handle = viewerRef.current;
const item = handle?.getItem(itemId);
if (handle == null || item == null) return;
if (item.collapsed === true) {
item.collapsed = false;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
syncAllCollapsedMirror();
reportFileCollapsed(itemId, false);
}
handle.scrollTo({ type: 'item', id: itemId, align: 'start' });
});
return () => cancelAnimationFrame(raf);
}, [fileScrollTarget?.filePath, fileScrollTarget?.token, fileSetKey, filePathToItemId, syncAllCollapsedMirror, reportFileCollapsed]);
// --- Selected-annotation highlight + navigation ----------------------------
// SELECTION (inline card OR sidebar) paints the line highlight + repaints the
// card ring — but NEVER scrolls. Clicking a comment in the diff must not move
// the viewport. `annotations` is read through the ref so this fires only on
// selection change, not on any add/edit/delete while one is selected.
// Read-only mirror of pendingSelection so the selection effect can yield to an
// active compose WITHOUT taking pendingSelection as a dep (which would re-run
// it on every drag delta).
const pendingSelectionRef = useRef(pendingSelection);
pendingSelectionRef.current = pendingSelection;
const prevSelectedFileRef = useRef<string | null>(null);
useEffect(() => {
const ann = selectedAnnotationId
? annotationsRef.current.find((a) => a.id === selectedAnnotationId)
: null;
const newFile = ann?.filePath ?? null;
// Repaint the inline card's selected ring on the previously- AND
// newly-selected file: renderAnnotation only re-runs on updateItem, so a bare
// selection-state change wouldn't otherwise reach the portal'd cards.
const filesToRefresh = new Set<string>();
if (prevSelectedFileRef.current) filesToRefresh.add(prevSelectedFileRef.current);
if (newFile) filesToRefresh.add(newFile);
prevSelectedFileRef.current = newFile;
for (const path of filesToRefresh) {
for (const itemId of filePathToItemIds.get(path) ?? []) refreshItem(itemId);
}
// An active compose (toolbar open) owns selectedLines — clicking/deselecting a
// comment must not clobber the in-progress range highlight. Mirrors
// single-file DiffViewer's `pendingSelection ?? selectedAnnotationRange`.
if (pendingSelectionRef.current != null) return;
// Replay the selected line comment's range as the controlled highlight (the
// same state a drag paints); file-scoped / deselection clears it.
setSelectedLines(lineSelectionForAnnotation(ann));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnnotationId, filePathToItemId, filePathToItemIds, refreshItem]);
// NAVIGATION (sidebar only) — expand the owning file if collapsed and scroll
// to the comment. Keyed on the navigation token so it fires per sidebar click
// (re-clicking the same comment re-centers it) and NEVER on a bare in-diff
// selection. rAF defers the scroll a frame so the expand's layout has settled.
useEffect(() => {
if (!scrollTargetAnnotation) return;
const ann = annotationsRef.current.find((a) => a.id === scrollTargetAnnotation.id);
if (!ann) return;
const itemId = filePathToItemId.get(ann.filePath);
const handle = viewerRef.current;
if (itemId == null || handle == null) return;
const item = handle.getItem(itemId);
if (item != null && item.collapsed === true) {
item.collapsed = false;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
syncAllCollapsedMirror();
reportFileCollapsed(itemId, false);
}
const isFile = isFileScopedAnnotation(ann);
const range = lineRangeForAnnotation(ann);
const raf = requestAnimationFrame(() => {
const viewer = viewerRef.current;
if (viewer == null) return;
if (isFile) viewer.scrollTo({ type: 'item', id: itemId, align: 'start' });
else viewer.scrollTo({ type: 'range', id: itemId, range });
});
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollTargetAnnotation, filePathToItemId]);
useEffect(() => {
if (!isActive || readOnly) return;
const handler = (e: KeyboardEvent) => {
// composedPath()[0] pierces shadow DOM: window-level e.target retargets
// to the shadow HOST (e.g. <diffs-container>), which would hide a
// typeable element living inside a shadow root from this guard.
const el = (e.composedPath?.()[0] ?? e.target) as HTMLElement | null;
if (
el &&
(el.tagName === 'INPUT' ||
el.tagName === 'TEXTAREA' ||
el.tagName === 'SELECT' ||
el.isContentEditable)
)
return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (orderedItemIds.length === 0) return;
// The item the user is currently reading (active-file tracking).
const currentId = visibleFileRef.current
? filePathToItemId.get(visibleFileRef.current) ?? null
: null;
const currentPath = currentId ? itemIdToFilePath.get(currentId) ?? null : null;
// x — collapse/expand the current file.
if (e.key === 'x' && currentId) {
e.preventDefault();
toggleItemCollapsed(currentId);
return;
}
// z — re-expand + scroll to a collapsed file. Legacy used a collapse
// history stack; we approximate with the nearest collapsed item AT or
// BEFORE the current position in visual order (the file you most likely
// just collapsed), falling back to the nearest one after it.
if (e.key === 'z') {
const collapsedIds = orderedItemIds.filter((id) => isItemCollapsed(id));
if (collapsedIds.length === 0) return;
e.preventDefault();
const currentIdx = currentId ? orderedItemIds.indexOf(currentId) : -1;
const target =
[...collapsedIds]
.reverse()
.find((id) => orderedItemIds.indexOf(id) <= currentIdx) ?? collapsedIds[0];
toggleItemCollapsed(target);
scrollToItem(target);
return;
}
// c — open the file-scoped comment popover for the current file. The
// anchor element comes from the eager fileCommentButtonRef registration;
// isConnected guards against an element whose header was recycled out of
// the rendered window between registration and keypress.
if (e.key === 'c' && currentPath && onAddFileCommentForFile) {
e.preventDefault();
const btn = fileCommentButtonRefs.current.get(currentPath);
if (btn?.isConnected) setFileCommentAnchor({ el: btn, filePath: currentPath });
return;
}
// v — toggle viewed (and collapse on mark-viewed) for the current file.
if (e.key === 'v' && currentPath && currentId) {
e.preventDefault();
handleToggleViewedAndCollapse(currentPath, currentId);
return;
}
// a — stage/unstage the current file (per-file gate: never a committed
// file in since-base mode).
if (e.key === 'a' && currentPath && (canStagePath ? canStagePath(currentPath) : canStageFiles)) {
e.preventDefault();
onStage?.(currentPath);
return;
}
if (e.key !== '[' && e.key !== ']') return;
e.preventDefault();
const currentIdx = currentId ? orderedItemIds.indexOf(currentId) : -1;
let targetIdx: number;
if (e.key === ']') {
targetIdx = currentIdx < orderedItemIds.length - 1 ? currentIdx + 1 : orderedItemIds.length - 1;
} else {
targetIdx = currentIdx > 0 ? currentIdx - 1 : 0;
}
scrollToItem(orderedItemIds[targetIdx]);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [
isActive,
readOnly,
orderedItemIds,
filePathToItemId,
itemIdToFilePath,
scrollToItem,
toggleItemCollapsed,
isItemCollapsed,
onAddFileCommentForFile,
handleToggleViewedAndCollapse,
canStageFiles,
canStagePath,
onStage,
]);
// --- Custom header render slot (the full Plannotator FileHeader) -----------
const renderCustomHeader = useStableCallback((item: CodeViewItem<DiffAnnotationMetadata>) => {
if (item.type !== 'diff') return null;
const filePath = itemIdToFilePath.get(item.id);
if (filePath == null) return null;
// Resolve by item id (NOT files.find by path): duplicate display paths each
// have their own DiffFile, and a path lookup would render the FIRST file's
// stats on every duplicate's header.
const file = itemIdToFile.get(item.id);
if (file == null) return null;
const collapsed = item.collapsed === true;
const fileComments = fileCommentsByPath.get(filePath) ?? [];
// Edit-to-suggestion affordance (flag-gated). Slot portals republish on
// updateItem BEFORE React commits state, so read the session's refs.
const isEditingThis = editEnabled && editSession.editingItemIdRef.current === item.id;
const editDisabledReason = editEnabled
? editSession.editUnavailableRef.current.get(filePath) ?? null
: null;
return (
<div className="flex flex-col">
<FileHeader
compactTouchLayout={compactTouchLayout}
readOnly={readOnly}
filePath={filePath}
patch={file.patch}
status={file.status}
oldPath={file.oldPath}
onEditFile={editEnabled ? () => editSession.startEdit(item.id) : undefined}
isEditing={isEditingThis}
editDisabledReason={editDisabledReason}
isViewed={viewedFiles?.has(filePath)}
isGenerated={generatedFiles?.has(filePath) === true}
onToggleViewed={onToggleViewed ? () => handleToggleViewedAndCollapse(filePath, item.id) : undefined}
showViewedControl={showViewedControls}
isStaged={stagedFiles?.has(filePath)}
isStaging={stagingFile === filePath}
onStage={onStage ? () => onStage(filePath) : undefined}
canStage={canStagePath ? canStagePath(filePath) : canStageFiles}
showStageControl={showStageControls}
stageError={stagingFile === filePath ? stageError : null}
onFileComment={onAddFileCommentForFile ? (anchorEl) => handleFileComment(filePath, anchorEl) : undefined}
// Eager registration so the `c` shortcut can anchor the popover for a
// file whose button was never clicked. Detach (null) deletes the entry
// — React detaches the old ref before attaching the new one in the
// same commit, so a slot republish never leaves the map stale.
fileCommentButtonRef={
onAddFileCommentForFile
? (el) => {
if (el) fileCommentButtonRefs.current.set(filePath, el);
else fileCommentButtonRefs.current.delete(filePath);
}
: undefined
}
collapseToggle={
<button
onClick={(e) => {
e.stopPropagation();
toggleItemCollapsed(item.id);
}}
data-pn-touch-target={compactTouchLayout || undefined}
data-pn-touch-target-icon={compactTouchLayout || undefined}
className="flex items-center justify-center w-6 h-6 rounded hover:bg-foreground/10 transition-colors flex-shrink-0"
title={collapsed ? 'Expand diff' : 'Collapse diff'}
>
<svg
className={`w-3 h-3 transition-transform ${collapsed ? '' : 'rotate-90'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
}
onCollapseToggle={() => toggleItemCollapsed(item.id)}
/>
{/* A collapsed generated file must read as an intentional fold, never
a failed render: an explicit placeholder strip with the counts,
clickable through the SAME toggle funnel as the chevron. */}
{collapsed && generatedFiles?.has(filePath) === true && (
<GeneratedFileNotice
additions={file.additions}
deletions={file.deletions}
onExpand={() => toggleItemCollapsed(item.id)}
onHeightChange={() => refreshItem(item.id)}
/>
)}
{/* Files over the review size cap arrive as a contents-free stub, so
Pierre renders nothing below the header. Explain why rather than
leaving a bare header that reads as a broken diff. */}
{!collapsed && isOversizedReviewStubPatch(file.patch) && (
<OversizedFileNotice onHeightChange={() => refreshItem(item.id)} />
)}
{/* The general fallback under that specific case: any OTHER hunkless
binary chunk draws nothing either. Gated on the marker so a
marker-carrying stub is explained exactly once, by the line above. */}
{!collapsed
&& !isOversizedReviewStubPatch(file.patch)
&& isContentlessBinaryPatch(file.patch) && (
<BinaryFileNotice onHeightChange={() => refreshItem(item.id)} />
)}
{/* EXPERIMENTAL edit-session HUD: session controls + state in a slim
strip below the header, above the file content. Appears/disappears
with session start/end, which both go through a version-bumped
updateItem, so the slot height is re-measured on each transition. */}
{isEditingThis && (
<EditSessionHud
onComplete={editSession.completeEdit}
onCancel={editSession.cancelEdit}
dirtyStore={editSession.dirtyStore}
/>
)}
{/* File-scoped comments live in the header (below the path), shown only
when the file is expanded. They ride the sticky header — fine for a
short guide note; long ones scroll within the banner. */}
{!collapsed && fileComments.length > 0 && (
<FileCommentBanner
comments={fileComments}
selectedAnnotationId={selectedAnnotationId}
onSelect={onSelectAnnotation}
onEdit={onEditAnnotation}
onDelete={onDeleteAnnotation}
// Re-measure the item when a comment expands/collapses/edits — the
// custom-header height isn't auto-observed, so without this the
// content below would overlap until an unrelated refresh.
onHeightChange={() => refreshItem(item.id)}
/>
)}
</div>
);
});
// Pass-through allowlist only (CODE_VIEW_DIFF_OPTION_KEYS). hunkSeparators,
// stickyHeaders, itemMetrics, and the selection callbacks are CodeView-level
// options. The selection/gutter callbacks receive a context whose `.item` is
// the owning CodeViewItem, which is how file identity flows without geometry
// inference. itemMetrics must reflect the custom header height and the
// unsafeCSS-customized hunk separator height (see constants above), otherwise
// CodeView's virtualization estimate drifts.
// usePierreTheme forces `line-height: 1.5` ONLY when a custom font size is
// set. In that case CodeView's pre-measure row-height estimate must match
// (fontPx * 1.5) or virtualization/scroll estimates drift. With no custom
// size, Pierre's default lineHeight estimate is correct — leave it unset.
const customLineHeight = useMemo(() => {
if (!fontSize) return undefined;
const px = parseFloat(fontSize);
return Number.isFinite(px) && px > 0 ? Math.round(px * 1.5) : undefined;
}, [fontSize]);
const options = useMemo<CodeViewOptions<DiffAnnotationMetadata>>(
() => ({
themeType: pierreTheme.type,
unsafeCSS: pierreTheme.css,
...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }),
diffStyle,
overflow: diffOverflow,
diffIndicators,
lineDiffType,
disableLineNumbers,
disableBackground,
expandUnchanged,
enableLineSelection: !readOnly,
enableGutterUtility: !readOnly,
hunkSeparators: 'line-info',
stickyHeaders: true,
// Flush files together (no inter-file gap) — file boundaries already read
// via the sticky header. Keep Pierre's default 8px list edge padding.
// leadingHeight reserves space for the leading-content portal (commit
// description card) so items start below it and it scrolls with them.
layout: { gap: 0, paddingTop: 8 + leadingHeight, paddingBottom: 8 },
itemMetrics: {
diffHeaderHeight: compactTouchLayout ? COMPACT_PANEL_HEADER_HEIGHT : PANEL_HEADER_HEIGHT,
hunkSeparatorHeight: HUNK_SEPARATOR_HEIGHT,
...(customLineHeight != null && { lineHeight: customLineHeight }),
},
// Opt-in safety net for the hand-maintained itemMetrics above: Pierre
// compares its virtualization estimates against measured DOM heights and
// warns on drift. Explicit env opt-in (VITE_PIERRE_VALIDATE_HEIGHTS=1)
// rather than blanket DEV: validation runs getBoundingClientRect() per
// rendered item per frame inside the scroll loop — it made the dev
// server's scrolling visibly choppy on its own. Still doubly gated: the
// option only takes effect when the library itself runs a development
// build (NODE_ENV), so it is inert in production even if it leaks.
...(import.meta.env.DEV &&
import.meta.env.VITE_PIERRE_VALIDATE_HEIGHTS === '1' && {
__devOnlyValidateItemHeights: true,
}),
onLineSelectionEnd(range, context) {
handleLineSelectionEnd(range, context.item);
},
onGutterUtilityClick(range, context) {
handleGutterUtilityClick(range, context.item);
},
// P7: token code navigation. CodeView appends the owning-item context as
// the final arg to every shared callback (same as the selection/gutter
// callbacks), so file identity comes from context.item — no geometry or
// active-file inference. Only wired when onCodeNavRequest is provided.
...(onCodeNavRequest && {
// Pierre's renderer-options builder drops onToken* before it evaluates
// shouldUseTokenTransformer, so the handlers alone never wrap tokens
// (no data-char) and token events never fire. Enable it explicitly.
useTokenTransformer: true,
onTokenClick(props, event, context) {
handleTokenClick(props, event, context.item);
},
onTokenEnter(props, event, _context) {
handleTokenEnter(props, event);
},
onTokenLeave(props, _event, _context) {
handleTokenLeave(props);
},
}),
// P5: lazily augment an item with full file content when it enters the
// rendered window. P6: (re)apply / clear search marks per item so they
// survive recycling. CodeView appends the item context as the final arg.
onPostRender(node, _instance, phase, context) {
handlePostRender(node, _instance, phase, context.item);
},
}),
[
pierreTheme.type,
pierreTheme.css,
pierreTheme.syntaxTheme,
diffStyle,
diffOverflow,
diffIndicators,
lineDiffType,
disableLineNumbers,
disableBackground,
expandUnchanged,
readOnly,
customLineHeight,
compactTouchLayout,
leadingHeight,
handleLineSelectionEnd,
handleGutterUtilityClick,
onCodeNavRequest,
handleTokenClick,
handleTokenEnter,
handleTokenLeave,
handlePostRender,
],
);
// After all hooks: hold the surface until the worker pool can take the
// first tokenization wave (≈100-300ms once per session; instant after).
if (!workerPoolReady) {
return <div className="relative h-full" />;
}
const codeView = (
<CodeView<DiffAnnotationMetadata>
// Remount on diff switch so uncontrolled `initialItems` re-seeds from
// the freshly computed identity. Without this, switching diff
// type/base/whitespace/PR with the all-files panel open would keep the
// OLD diff on screen (the panel instance is reused, not recreated).
key={fileSetKey}
ref={viewerRef}
containerRef={attachScrollContainer}
// Containment mirrors Pierre's own production wrapper (diffshub
// CodeViewWrapper): without it, every forced layout during scrolling
// recomputes the whole document instead of the clipped subtree.
// overflow-anchor:none disables the BROWSER's scroll anchoring, which
// otherwise fights CodeView's own anchor resolution whenever item
// heights change (our augmentation applies).
className={`relative h-full overflow-y-auto overflow-x-clip ${allowScrollChaining ? 'overscroll-auto' : 'overscroll-contain'} [contain:strict] [overflow-anchor:none] [will-change:scroll-position] [&_diffs-container]:overflow-clip [&_diffs-container]:[contain:layout_paint_style]`}
initialItems={identity.items}
options={options}
selectedLines={selectedLines}
onSelectedLinesChange={handleSelectedLinesChange}
onScroll={handleScroll}
renderCustomHeader={renderCustomHeader}
renderAnnotation={renderAnnotation}
// Edit-to-suggestion (flag-gated): only wired when enabled so the
// flag-off surface is byte-identical to the pre-feature one.
{...(editEnabled && {
editorOptions: editSession.editorOptions,
onItemEditChange: editSession.onItemEditChange,
onItemEditComplete: editSession.onItemEditComplete,
})}
/>
);
return (
<div
className="relative h-full"
data-history-owner={editSession.editingItemId ? 'edit-session' : undefined}
>
{/* EditProvider only mounts when the experimental flag is on; its
factory declines attaches until the lazy editor chunk has loaded
(the chunk loads on first Edit click, never before). */}
{editEnabled ? (
<EditProvider createEditor={editSession.createEditor}>{codeView}</EditProvider>
) : (
codeView
)}
{/* Leading content (commit description card) lives INSIDE the scroll
container at content-top: absolutely positioned children of a scroller
are part of its scrollable overflow, so the card scrolls away with
the diff. layout.paddingTop (measured height) keeps items below it. */}
{leadingContent && scrollEl &&
createPortal(
<div ref={attachLeadingEl} className="absolute top-0 left-0 right-0">
{leadingContent}
</div>,
scrollEl,
)}
{!readOnly && (
<ToolbarHost
ref={toolbarHostRef}
patch={activePatch}
filePath={activeFilePath ?? ''}
isFocused={true}
onLineSelection={onLineSelection}
onAddAnnotation={handleAddAnnotation}
onEditAnnotation={onEditAnnotation}
aiAvailable={aiAvailable}
onAskAI={handleAskAIForActiveFile}
isAILoading={isAILoading}
onViewAIResponse={onViewAIResponse}
aiHistoryMessages={aiHistoryForActiveFile}
/>
)}
{!readOnly && fileCommentAnchor && onAddFileCommentForFile && (
<CommentPopover
key={`file:${prUrl ?? ''}:${prDiffScope ?? ''}:${fileCommentAnchor.filePath}`}
anchorEl={fileCommentAnchor.el}
contextText={fileCommentAnchor.filePath.split('/').pop() || fileCommentAnchor.filePath}
isGlobal={false}
draftKey={`file:${prUrl ?? ''}:${prDiffScope ?? ''}:${fileCommentAnchor.filePath}`}
onSubmit={(text) => {
onAddFileCommentForFile(fileCommentAnchor.filePath, text);
setFileCommentAnchor(null);
}}
onClose={() => setFileCommentAnchor(null)}
/>
)}
{/* Comment entry for the edit session's "Make annotation" action. The
anchor rect and the pristine line range were snapshotted at click
time, so this stays valid even if the editor selection has since
collapsed or the session has ended (pristine coordinates are
session-invariant). */}
{!readOnly && selectionAnnotationRequest && onAddEditorCommentForFile && (
<CommentPopover
key={`edit-selection:${selectionAnnotationRequest.filePath}:${selectionAnnotationRequest.lineStart}-${selectionAnnotationRequest.lineEnd}`}
anchorRect={selectionAnnotationRequest.anchorRect}
contextText={selectionAnnotationRequest.selectedText.replace(/\s+/g, ' ').trim()}
isGlobal={false}
allowImages={false}
onSubmit={(text) => {
onAddEditorCommentForFile(selectionAnnotationRequest.filePath, {
lineStart: selectionAnnotationRequest.lineStart,
lineEnd: selectionAnnotationRequest.lineEnd,
exact: selectionAnnotationRequest.exact,
selectedText: selectionAnnotationRequest.selectedText,
text,
});
// The editor kept its ranged selection while the entry was open;
// collapse it so the Selection Action popover does not re-open
// over the just-annotated lines.
editSession.collapseSelection();
setSelectionAnnotationRequest(null);
}}
onClose={() => setSelectionAnnotationRequest(null)}
/>
)}
</div>
);
};