252 lines
9.8 KiB
TypeScript
252 lines
9.8 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from 'react';
|
|
|
|
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
|
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
|
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
|
import {
|
|
getResolvedReviewModifiedContent,
|
|
hasReviewSnippetText,
|
|
isReviewFileMissingOnDisk,
|
|
isReviewTextContentUnavailable,
|
|
shouldRenderCurrentDiskContextPreview,
|
|
} from './reviewContentPreview';
|
|
import { ReviewDiffContent } from './ReviewDiffContent';
|
|
import {
|
|
shouldRenderCodeMirrorReviewDiff,
|
|
shouldRenderSnippetReviewPreview,
|
|
} from './reviewDiffSafety';
|
|
|
|
import type { EditorView } from '@codemirror/view';
|
|
import type { FileChangeWithContent } from '@shared/types';
|
|
import type { EditorSelectionInfo } from '@shared/types/editor';
|
|
import type { FileChangeSummary } from '@shared/types/review';
|
|
|
|
interface FileSectionDiffProps {
|
|
file: FileChangeSummary;
|
|
fileContent: FileChangeWithContent | null;
|
|
isLoading: boolean;
|
|
collapseUnchanged: boolean;
|
|
onHunkAccepted: (filePath: string, hunkIndex: number) => void;
|
|
onHunkRejected: (filePath: string, hunkIndex: number) => void;
|
|
onFullyViewed: (filePath: string) => void;
|
|
onContentChanged: (filePath: string, content: string) => void;
|
|
onEditorViewReady: (filePath: string, view: EditorView | null) => void;
|
|
discardCounter: number;
|
|
autoViewed: boolean;
|
|
isViewed: boolean;
|
|
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
|
globalHunkOffset?: number;
|
|
totalReviewHunks?: number;
|
|
}
|
|
|
|
export const FileSectionDiff = ({
|
|
file,
|
|
fileContent,
|
|
isLoading,
|
|
collapseUnchanged,
|
|
onHunkAccepted,
|
|
onHunkRejected,
|
|
onFullyViewed,
|
|
onContentChanged,
|
|
onEditorViewReady,
|
|
discardCounter,
|
|
autoViewed,
|
|
isViewed,
|
|
onSelectionChange,
|
|
globalHunkOffset = 0,
|
|
totalReviewHunks,
|
|
}: FileSectionDiffProps): React.ReactElement => {
|
|
const localEditorViewRef = useRef<EditorView | null>(null);
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
const hasSnippetText = hasReviewSnippetText(file);
|
|
const canRenderSnippetPreview = hasSnippetText && shouldRenderSnippetReviewPreview(file.snippets);
|
|
|
|
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
|
|
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
|
// and internal recreation (e.g. when `modified` prop changes after Save).
|
|
const handleViewChange = useCallback(
|
|
(view: EditorView | null) => {
|
|
localEditorViewRef.current = view;
|
|
onEditorViewReady(file.filePath, view);
|
|
},
|
|
[file.filePath, onEditorViewReady]
|
|
);
|
|
|
|
// Auto-viewed sentinel observer
|
|
useEffect(() => {
|
|
if (!sentinelRef.current || !autoViewed || isViewed) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
onFullyViewed(file.filePath);
|
|
}
|
|
}
|
|
},
|
|
{ threshold: 0.85 }
|
|
);
|
|
|
|
observer.observe(sentinelRef.current);
|
|
return () => observer.disconnect();
|
|
}, [autoViewed, isViewed, file.filePath, onFullyViewed]);
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
if (!hasSnippetText) {
|
|
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-auto">
|
|
{canRenderSnippetPreview ? (
|
|
<ReviewDiffContent file={file} />
|
|
) : (
|
|
<OversizedDiffNotice message="Diff preview skipped because the change is too large to render safely." />
|
|
)}
|
|
<div ref={sentinelRef} className="h-1 shrink-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Resolve modified content: prefer full content, fall back to write-type snippet
|
|
// Only write-new/write-update snippets contain the full file - edit snippets are partial
|
|
const resolvedModified = getResolvedReviewModifiedContent(file, fileContent);
|
|
|
|
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
|
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
|
|
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
|
|
const hasLedgerManualAction = file.snippets.some(
|
|
(snippet) =>
|
|
!!snippet.ledger &&
|
|
(snippet.ledger.relation?.kind === 'rename' ||
|
|
(!!snippet.ledger.beforeState?.unavailableReason &&
|
|
snippet.ledger.originalFullContent == null) ||
|
|
(!!snippet.ledger.afterState?.unavailableReason &&
|
|
snippet.ledger.modifiedFullContent == null))
|
|
);
|
|
|
|
// Show CodeMirror only when we have a trustworthy original baseline:
|
|
// - new files: original is legitimately empty
|
|
// - otherwise: original must be known (non-null). If original is unknown, do not
|
|
// pretend it's empty; fall back to snippet-level diff.
|
|
const canRenderCodeMirror =
|
|
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
|
|
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
|
|
const canRenderCodeMirrorSafely =
|
|
canRenderCodeMirror &&
|
|
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
|
|
const canRenderCurrentDiskContext =
|
|
resolvedModified !== null &&
|
|
shouldRenderCurrentDiskContextPreview(file, fileContent) &&
|
|
shouldRenderCodeMirrorReviewDiff(resolvedModified, resolvedModified);
|
|
const currentDiskContextContent = canRenderCurrentDiskContext ? resolvedModified : null;
|
|
|
|
if (!canRenderCodeMirrorSafely) {
|
|
return (
|
|
<div className="overflow-auto">
|
|
<OversizedDiffNotice
|
|
message={
|
|
canRenderCurrentDiskContext
|
|
? 'No original baseline is available; showing current disk content for context only. Reject is disabled for this file.'
|
|
: hasLedgerManualAction || isContentUnavailable
|
|
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
|
|
: canRenderCodeMirror && !canRenderSnippetPreview
|
|
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
|
: canRenderCodeMirror
|
|
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
|
|
: hasSnippetText
|
|
? 'Diff preview skipped because the available change data is too large to render safely.'
|
|
: file.snippets.length > 0
|
|
? 'This file change was captured as metadata only; no text diff data is available.'
|
|
: 'No text diff data is available for this file.'
|
|
}
|
|
/>
|
|
{canRenderSnippetPreview ? (
|
|
<ReviewDiffContent file={file} />
|
|
) : currentDiskContextContent != null ? (
|
|
<DiffErrorBoundary
|
|
filePath={file.filePath}
|
|
oldString={currentDiskContextContent}
|
|
newString={currentDiskContextContent}
|
|
>
|
|
<CodeMirrorDiffView
|
|
key={`${file.filePath}:${discardCounter}:current-disk-context`}
|
|
original={currentDiskContextContent}
|
|
modified={currentDiskContextContent}
|
|
fileName={file.relativePath}
|
|
readOnly={true}
|
|
showMergeControls={false}
|
|
collapseUnchanged={false}
|
|
usePortionCollapse={true}
|
|
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
|
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
|
|
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
|
editorViewRef={localEditorViewRef}
|
|
onViewChange={handleViewChange}
|
|
onSelectionChange={
|
|
onSelectionChange
|
|
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
|
|
: undefined
|
|
}
|
|
globalHunkOffset={globalHunkOffset}
|
|
totalReviewHunks={totalReviewHunks}
|
|
/>
|
|
</DiffErrorBoundary>
|
|
) : null}
|
|
<div ref={sentinelRef} className="h-1 shrink-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-auto">
|
|
{isMissingOnDisk && (
|
|
<div
|
|
className="border-b border-border bg-red-500/10 px-4 py-2 text-xs"
|
|
style={{ color: 'var(--diff-removed-text)' }}
|
|
>
|
|
File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
|
|
<span className="font-medium">Restore</span> to create the file on disk.
|
|
</div>
|
|
)}
|
|
<DiffErrorBoundary
|
|
filePath={file.filePath}
|
|
oldString={originalForDiff}
|
|
newString={resolvedModified}
|
|
>
|
|
<CodeMirrorDiffView
|
|
key={`${file.filePath}:${discardCounter}`}
|
|
original={originalForDiff}
|
|
modified={resolvedModified}
|
|
fileName={file.relativePath}
|
|
readOnly={hasLedgerManualAction}
|
|
showMergeControls={!isMissingOnDisk && !hasLedgerManualAction}
|
|
collapseUnchanged={collapseUnchanged}
|
|
usePortionCollapse={true}
|
|
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
|
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
|
|
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
|
editorViewRef={localEditorViewRef}
|
|
onViewChange={handleViewChange}
|
|
onSelectionChange={
|
|
onSelectionChange
|
|
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
|
|
: undefined
|
|
}
|
|
globalHunkOffset={globalHunkOffset}
|
|
totalReviewHunks={totalReviewHunks}
|
|
/>
|
|
</DiffErrorBoundary>
|
|
<div ref={sentinelRef} className="h-1 shrink-0" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OversizedDiffNotice = ({ message }: { message: string }): React.ReactElement => {
|
|
return (
|
|
<div className="border-b border-border bg-amber-500/10 px-4 py-3 text-xs text-amber-300">
|
|
{message}
|
|
</div>
|
|
);
|
|
};
|