Files
backnotprop__plannotator/packages/review-editor/components/AllFilesCodeView.tsx
T
Michael Ramos afac44b940 feat(review): migrate all-files code review to Pierre CodeView (#885)
Replaces the all-files renderer with Pierre's virtualized CodeView (one scroller, identical look, scales to huge diffs); deletes the legacy AllFilesDiffView/LazyFileDiff and their flag; single-file panels stay on FileDiff. @pierre/diffs pinned exact 1.2.8 (1.2.9's tree is broken on npm — see #880).

Also ships: diff staleness detection ('Diff out of date · Refresh' toolbar notice backed by shared per-VCS fingerprints + GET /api/diff/fresh on both Bun and Pi servers), a stale-content guard so files edited mid-review degrade to raw-patch view instead of breaking virtualization, the Ask AI wrong-file fix (pre-existing), and toolbar/header UI polish (global settings cog, aligned sem badges, no all-files split dragger).

Hardened through five review waves; tested with 1384 passing tests incl. real-git fingerprint and patch-consistency suites. Known minor gaps documented in the PR.
2026-06-10 21:07:58 -07:00

1792 lines
81 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getSingularPatch, processFile } from '@pierre/diffs';
import type {
CodeViewItem,
CodeViewLineSelection,
CodeViewOptions,
DiffLineAnnotation,
FileDiffMetadata,
LineAnnotation,
PostRenderPhase,
SelectedLineRange,
} from '@pierre/diffs';
import { CodeView, 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 type { DiffFile } 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 { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost';
import { FileHeader } from './FileHeader';
import { InlineAnnotation } from './InlineAnnotation';
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 the container's
* DOM scrollTop into a bounded 12M-px paged window, so that heuristic would
* misfire against CodeView's own rebasing. CodeView is the scroll authority
* here; we rely on it rather than a guardian that would fight it. (Still
* needs real WebKit validation.)
*
* The worker pool remains a later phase.
*/
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;
pendingSelection: SelectedLineRange | null;
reviewBase?: string;
// 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;
stagedFiles?: Set<string>;
onStage?: (filePath: string) => void;
canStageFiles?: boolean;
stagingFile?: string | null;
stageError?: string | null;
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;
// 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;
getAIHistoryForFile?: (filePath: string) => AIChatEntry[];
}
// 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 a filePath <-> itemId map for the bridge.
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>;
}
// Cheap content hash (djb2 xor variant) for diff-change detection. Replaces
// patch-LENGTH proxies: a same-length different-content patch set must still
// remount CodeView (fileSetKey) and must not collide in highlight caches
// (cacheKey). Not cryptographic — collision odds for this purpose are fine.
function hashString(value: string): string {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = ((hash * 33) ^ value.charCodeAt(i)) >>> 0;
}
return hash.toString(36);
}
// Project a file's line annotations into Pierre's DiffLineAnnotation shape. This
// is the EXACT projection AllFilesDiffView builds (side, lineNumber = lineEnd,
// metadata = DiffAnnotationMetadata) so the two surfaces render identically.
// Filters to line-scoped annotations that belong to this file in the active
// PR/diff-scope (file-scoped comments live in the header, not the gutter).
function projectFileAnnotations(
annotations: CodeAnnotation[],
filePath: string,
prUrl: string | undefined,
prDiffScope: string | undefined,
): DiffLineAnnotation<DiffAnnotationMetadata>[] {
return annotations
.filter(
(a) =>
a.filePath === filePath &&
(a.scope ?? 'line') === 'line' &&
(!a.prUrl || !prUrl || a.prUrl === prUrl) &&
(!a.diffScope || !prDiffScope || a.diffScope === prDiffScope),
)
.map((ann) => ({
side: ann.side === 'new' ? ('additions' as const) : ('deletions' as const),
lineNumber: ann.lineEnd,
metadata: {
annotationId: ann.id,
type: ann.type,
text: ann.text,
suggestedCode: ann.suggestedCode,
originalCode: ann.originalCode,
author: ann.author,
severity: ann.severity,
reasoning: ann.reasoning,
conventionalLabel: ann.conventionalLabel,
decorations: ann.decorations,
} as DiffAnnotationMetadata,
}));
}
function buildItemIdentity(
files: DiffFile[],
visualOrder: number[],
annotations: CodeAnnotation[],
prUrl: string | undefined,
prDiffScope: string | undefined,
patchHashes: string[],
): 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, file.path, prUrl, prDiffScope);
items.push({ id, type: 'diff', fileDiff, version: 0, annotations: fileAnnotations });
// 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
// 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;
export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
files,
diffStyle,
diffOverflow,
diffIndicators,
lineDiffType,
disableLineNumbers,
disableBackground,
expandUnchanged,
fontFamily,
fontSize,
annotations,
selectedAnnotationId,
pendingSelection,
reviewBase,
onLineSelection,
onAddAnnotationForFile,
onEditAnnotation,
onSelectAnnotation,
onDeleteAnnotation,
onAddFileCommentForFile,
viewedFiles,
onToggleViewed,
stagedFiles,
onStage,
canStageFiles = false,
stagingFile,
stageError,
prUrl,
prDiffScope,
searchQuery = '',
searchMatches = [],
activeSearchMatchId = null,
activeSearchMatch = null,
onCodeNavRequest,
onVisibleFileChange,
isActive = true,
aiAvailable = false,
onAskAIForFile,
isAILoading = false,
onViewAIResponse,
getAIHistoryForFile,
}) => {
// 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 });
const viewerRef = useRef<CodeViewHandle<DiffAnnotationMetadata> | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
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 annotations snapshot for the per-item annotation-sync effect (P4).
const prevAnnotationsRef = useRef<CodeAnnotation[]>(annotations);
// Order items by the current visual file-tree order — same ordering the
// legacy all-files view uses, so the two surfaces present files identically.
const visualOrder = useMemo(() => {
const tree = buildFileTree(files);
return getVisualFileOrder(tree);
}, [files]);
// `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 captured `annotations` closure for
// the first paint; subsequent annotation changes are applied incrementally per
// item by the annotation-sync effect below (updateItem on only the changed
// file). We read the latest annotations through a ref at build time so a
// remount triggered by a file-set change still seeds current annotations.
const annotationsRef = useRef(annotations);
annotationsRef.current = annotations;
// 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]);
const identity = useMemo<ItemIdentity>(
() => buildItemIdentity(files, visualOrder, annotationsRef.current, prUrl, prDiffScope, patchHashes),
// eslint-disable-next-line react-hooks/exhaustive-deps
[files, visualOrder, prUrl, prDiffScope, patchHashes],
);
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(
() => `${files.length}:${files.map((f, i) => `${f.path}#${patchHashes[i]}`).join('|')}`,
[files, patchHashes],
);
// 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);
});
// 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;
const filePath = itemIdToFilePath.get(item.id);
return (
<InlineAnnotation
metadata={annotation.metadata}
language={filePath ? detectLanguage(filePath) : undefined}
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;
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;
// Annotations are seeded into the remounted items at build time, so resync
// the snapshot here to avoid a spurious full annotation refresh post-remount.
prevAnnotationsRef.current = annotations;
// 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 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;
// 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;
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;
item.collapsed = true;
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
});
const isItemCollapsed = useCallback((itemId: string): boolean => {
return viewerRef.current?.getItem(itemId)?.collapsed === true;
}, []);
// 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 itemIdToFileRef = useRef(itemIdToFile);
itemIdToFileRef.current = itemIdToFile;
const fileSetKeyRef = useRef(fileSetKey);
fileSetKeyRef.current = fileSetKey;
// 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();
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);
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) {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
augmented = result;
} catch {
augmentState.set(itemId, { status: 'error', controller, generation });
return;
}
// Re-check right before the write: processFile above is synchronous but
// an abort / diff switch could have landed since the first check.
if (isStale()) return;
const liveHandle = viewerRef.current;
const item = liveHandle?.getItem(itemId);
// The item may have been torn down between fetch start and resolution;
// 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;
}
// 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;
});
}, []);
// (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;
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);
}
// 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]);
// --- Annotations through CodeView item state (P4) ---------------------------
// Set an item's annotations to the current per-file projection, bump version,
// and updateItem. Mirrors Diffshub's updateViewerDiffItem (getItem, mutate,
// version++, updateItem) but rebuilds the whole annotation array from the
// source-of-truth `annotations` rather than splicing a single entry — the diff
// is computed at the item granularity by the sync effect below, so only files
// whose annotation set actually changed get an updateItem.
const syncItemAnnotations = useCallback(
(filePath: string, itemId: string, allAnnotations: CodeAnnotation[]) => {
const handle = viewerRef.current;
const item = handle?.getItem(itemId);
if (handle == null || item == null || item.type !== 'diff') return;
item.annotations = projectFileAnnotations(allAnnotations, filePath, prUrl, prDiffScope);
item.version = (item.version ?? 0) + 1;
handle.updateItem(item);
},
[prUrl, prDiffScope],
);
// Whenever the `annotations` prop changes, re-project per file and updateItem
// ONLY on the files whose annotation set changed (so a single add/edit/delete
// re-renders just its owning file, never the whole CodeView). Diff is keyed on
// a per-file annotation signature so unrelated files are untouched. New diffs
// remount CodeView via fileSetKey and seed annotations at build time, so the
// diff-switch reset effect resynchronizes prevAnnotationsRef to avoid a
// spurious full refresh right after a remount.
useEffect(() => {
const handle = viewerRef.current;
const prev = prevAnnotationsRef.current;
prevAnnotationsRef.current = annotations;
if (handle == null || prev === annotations) return;
// Per-file annotation signature: id|line|side|content fingerprint. We only
// need to know whether a file's gutter annotations changed, so a stable
// string built from the fields that affect rendering is sufficient and far
// cheaper than deep-equality of the projected objects.
const signatures = (list: CodeAnnotation[]) => {
const map = new Map<string, string>();
for (const a of list) {
if ((a.scope ?? 'line') !== 'line') continue;
if (a.prUrl && prUrl && a.prUrl !== prUrl) continue;
if (a.diffScope && prDiffScope && a.diffScope !== prDiffScope) continue;
const sig = 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 ?? '',
]);
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) {
// Fan out to ALL items rendering this path (duplicate display paths
// produce twins; updating only the canonical first item would leave the
// twin rendering deleted/stale annotations until the next remount).
for (const itemId of filePathToItemIds.get(path) ?? []) {
syncItemAnnotations(path, itemId, annotations);
}
}
}, [annotations, prUrl, prDiffScope, filePathToItemIds, syncItemAnnotations]);
// --- 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);
// 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]);
// --- 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.
useEffect(() => {
if (pendingSelection == null) {
setSelectedLines(null);
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 handleLineSelectionEnd = useStableCallback(
(range: SelectedLineRange | null, item: CodeViewItem<DiffAnnotationMetadata>) => {
if (range == null || item.type !== 'diff') return;
const filePath = itemIdToFilePath.get(item.id);
if (filePath == null) return;
routeSelectionToToolbar(range, filePath);
},
);
const handleGutterUtilityClick = useStableCallback(
(range: SelectedLineRange, item: CodeViewItem<DiffAnnotationMetadata>) => {
if (item.type !== 'diff') return;
const filePath = itemIdToFilePath.get(item.id);
if (filePath == null) return;
routeSelectionToToolbar(range, filePath);
},
);
// --- 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. DOM scrollTop/scrollHeight are consistent with each other even
// under CodeView's paged scroll rebasing, so this check is safe.
const container = scrollRef.current;
if (
container != null &&
container.scrollTop + container.clientHeight >= container.scrollHeight - 2
) {
bestId = rendered[rendered.length - 1].id;
}
const path = itemIdToFilePath.get(bestId) ?? null;
if (path !== visibleFileRef.current) {
visibleFileRef.current = path;
onVisibleFileChange?.(path);
}
});
const handleScroll = useStableCallback(() => {
reportVisibleFile();
});
// 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]);
// --- [/]/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' });
}, []);
// --- Selected-annotation navigation (P4) -----------------------------------
// Selecting an annotation in the sidebar must expand its owning file (if
// collapsed) and scroll to it. We expand via item state (collapsed=false +
// version bump + updateItem — the Diffshub pattern), then scrollTo the
// annotation's line range so it lands in view. rAF defers the scroll one frame
// so the expand's layout has settled before CodeView resolves the line top.
// `annotations` is read through the ref, NOT the dep list: this must fire only
// when the SELECTION changes. With `annotations` as a dep, any annotation
// change while one is selected (add/edit/delete elsewhere, an external SSE
// annotation arriving) re-runs the effect and yanks the viewport back to the
// selected annotation with zero user action.
useEffect(() => {
if (!selectedAnnotationId) return;
const ann = annotationsRef.current.find((a) => a.id === selectedAnnotationId);
if (!ann) return;
const itemId = filePathToItemId.get(ann.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);
}
const start = Math.min(ann.lineStart, ann.lineEnd);
const end = Math.max(ann.lineStart, ann.lineEnd);
const side = ann.side === 'new' ? ('additions' as const) : ('deletions' as const);
const raf = requestAnimationFrame(() => {
const viewer = viewerRef.current;
if (viewer == null) return;
viewer.scrollTo({ type: 'range', id: itemId, range: { start, end, side } });
});
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnnotationId, filePathToItemId]);
useEffect(() => {
if (!isActive) 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.
if (e.key === 'a' && 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,
orderedItemIds,
filePathToItemId,
itemIdToFilePath,
scrollToItem,
toggleItemCollapsed,
isItemCollapsed,
onAddFileCommentForFile,
handleToggleViewedAndCollapse,
canStageFiles,
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;
return (
<FileHeader
filePath={filePath}
patch={file.patch}
isViewed={viewedFiles?.has(filePath)}
onToggleViewed={onToggleViewed ? () => handleToggleViewedAndCollapse(filePath, item.id) : undefined}
isStaged={stagedFiles?.has(filePath)}
isStaging={stagingFile === filePath}
onStage={onStage ? () => onStage(filePath) : undefined}
canStage={canStageFiles}
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);
}}
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)}
/>
);
});
// 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: true,
enableGutterUtility: true,
hunkSeparators: 'line-info',
stickyHeaders: true,
itemMetrics: {
diffHeaderHeight: PANEL_HEADER_HEIGHT,
hunkSeparatorHeight: HUNK_SEPARATOR_HEIGHT,
...(customLineHeight != null && { lineHeight: customLineHeight }),
},
// Dev-only safety net for the hand-maintained itemMetrics above: Pierre
// compares its virtualization estimates against measured DOM heights and
// warns on drift. 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 the flag leaks through.
...(import.meta.env.DEV && { __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 && {
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,
customLineHeight,
handleLineSelectionEnd,
handleGutterUtilityClick,
onCodeNavRequest,
handleTokenClick,
handleTokenEnter,
handleTokenLeave,
handlePostRender,
],
);
return (
<div className="relative h-full">
<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={scrollRef}
className="h-full overflow-auto"
initialItems={identity.items}
options={options}
selectedLines={selectedLines}
onSelectedLinesChange={handleSelectedLinesChange}
onScroll={handleScroll}
renderCustomHeader={renderCustomHeader}
renderAnnotation={renderAnnotation}
/>
<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}
/>
{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)}
/>
)}
</div>
);
};