import React from 'react';
import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore';
import { useCommitComparison } from '@/hooks/useCommitComparison';
import { useGitComparison, type GitComparisonSource } from '@/hooks/useGitComparison';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
import { branchRefLabel } from '@/components/views/git/baseBranch';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { coerceDiffScope, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { PierreDiffViewer, type DiffHunkActions } from './PierreDiffViewer';
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { toAbsoluteFilePath } from '@/lib/path-utils';
import { sessionEvents } from '@/lib/sessionEvents';
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
import { fileDiffFromPatch, isBinaryPatch, extractHunkPatch, haveMatchingPatchVersions, getPatchHunkAnchors } from '@/lib/diff/patchFileDiff';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startReviewFlow } from '@/lib/reviewFlow';
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils';
import type { FileDiffMetadata } from '@pierre/diffs';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
const DIFF_REQUEST_TIMEOUT_MS = 15000;
const LARGE_DIFF_CHANGED_LINES = 500;
const STACKED_DIFF_MOUNT_MARGIN = 300;
const FULL_CONTEXT_DIFF_LINES = 1_000_000;
const DEFAULT_CONTEXT_DIFF_LINES = 3;
// Perf: limit concurrent expanded diffs in stacked view.
// Expanding many diffs mounts many Pierre instances + lots of DOM.
const getStackedViewDefaultExpandedCount = (fileCount: number): number => {
if (fileCount <= 6) return fileCount;
if (fileCount <= 12) return 6;
if (fileCount <= 25) return 4;
return 2;
};
type FileEntry = GitStatus['files'][number] & {
insertions: number;
deletions: number;
isNew: boolean;
};
type DiffContextMode = 'patch' | 'full';
type DiffData = {
original: string;
modified: string;
isBinary?: boolean;
patch?: string;
fileDiff?: FileDiffMetadata;
contextMode?: DiffContextMode;
};
type DiffScope = 'all' | PendingDiffScope;
type TurnSnapshotDiff = {
file?: string;
status?: string;
before?: string;
after?: string;
patch?: string;
additions?: number;
deletions?: number;
};
type ComparisonDiffResult =
| { status: 'loading' }
| { status: 'ready'; data: DiffData }
| { status: 'error'; message: string };
const EMPTY_COMPARISON_DIFF: ComparisonDiffResult = { status: 'loading' };
/** Bounded retries for branch metadata in the context diff panel (see effect). */
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
{t('diffView.binary.unavailable')}
);
});
type ChangeDescriptor = {
code: string;
color: string;
descriptionKey: I18nKey;
};
const CHANGE_DESCRIPTORS: Record = {
'?': { code: '?', color: 'var(--status-info)', descriptionKey: 'diffView.change.untracked' },
A: { code: 'A', color: 'var(--status-success)', descriptionKey: 'diffView.change.new' },
D: { code: 'D', color: 'var(--status-error)', descriptionKey: 'diffView.change.deleted' },
R: { code: 'R', color: 'var(--status-info)', descriptionKey: 'diffView.change.renamed' },
C: { code: 'C', color: 'var(--status-info)', descriptionKey: 'diffView.change.copied' },
M: { code: 'M', color: 'var(--status-warning)', descriptionKey: 'diffView.change.modified' },
};
const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M;
const getChangeSymbol = (file: GitStatus['files'][number]): string => {
const indexCode = file.index?.trim();
const workingCode = file.working_dir?.trim();
if (indexCode && indexCode !== '?') return indexCode.charAt(0);
if (workingCode) return workingCode.charAt(0);
return indexCode?.charAt(0) || workingCode?.charAt(0) || 'M';
};
const describeChange = (file: GitStatus['files'][number]): ChangeDescriptor => {
const symbol = getChangeSymbol(file);
return CHANGE_DESCRIPTORS[symbol] ?? DEFAULT_CHANGE_DESCRIPTOR;
};
const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
const { index, working_dir: workingDir } = file;
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const indexCode = file.index?.trim();
return Boolean(indexCode && indexCode !== '?');
};
const isWorkingStatusFile = (file: GitStatus['files'][number]): boolean => {
const workingCode = file.working_dir?.trim();
return Boolean(workingCode) || file.index === '?';
};
const toAbsolutePath = (directory: string, filePath: string): string => {
return toAbsoluteFilePath(directory, filePath);
};
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
const getFirstChangedModifiedLine = (original: string, modified: string): number => {
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
const sharedLength = Math.min(originalLines.length, modifiedLines.length);
for (let index = 0; index < sharedLength; index += 1) {
if (originalLines[index] !== modifiedLines[index]) {
return index + 1;
}
}
if (modifiedLines.length > originalLines.length) {
return originalLines.length + 1;
}
if (originalLines.length > modifiedLines.length) {
return Math.max(1, modifiedLines.length);
}
return 1;
};
const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => {
if (!Array.isArray(value)) return [];
return value.filter((diff): diff is TurnSnapshotDiff => {
if (!diff || typeof diff !== 'object') return false;
return typeof (diff as TurnSnapshotDiff).file === 'string';
});
};
const statusToGitCode = (status?: string): string => {
if (status === 'added') return 'A';
if (status === 'deleted') return 'D';
return 'M';
};
const createTextDiffDataFromPatch = (filePath: string, patch: string, contextMode: DiffContextMode): DiffData => {
if (isBinaryPatch(patch)) {
return { original: '', modified: '', isBinary: true, patch, contextMode };
}
return {
original: '',
modified: '',
patch,
fileDiff: fileDiffFromPatch(filePath, patch),
contextMode,
};
};
const formatDiffTotals = (
insertions?: number,
deletions?: number,
options?: { shrink?: boolean; className?: string },
) => {
const added = insertions ?? 0;
const removed = deletions ?? 0;
if (!added && !removed) return null;
return (
{added ? +{added} : null}
{removed ? -{removed} : null}
);
};
interface ChangeScopeSelectorProps {
scope: PendingDiffScope;
workingCount: number;
stagedCount: number;
turnCount: number;
branchCount: number | null;
commitCount: number | null;
showCommitOption: boolean;
showBranchOption: boolean;
onScopeChange?: (scope: PendingDiffScope) => void;
}
const ChangeScopeSelector = React.memo(({
scope,
workingCount,
stagedCount,
turnCount,
branchCount,
commitCount,
showCommitOption,
showBranchOption,
onScopeChange,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : scope === 'commit' ? (commitCount ?? 0) : workingCount;
const currentLabel = scope === 'staged'
? t('diffView.scope.staged')
: scope === 'turn'
? t('diffView.scope.lastTurn')
: scope === 'branch'
? t('diffView.scope.branch')
: scope === 'commit' ? t('commitComparison.mode') : t('diffView.scope.changed');
return (
{
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit') {
onScopeChange?.(value);
setOpen(false);
}
}}
>
{t('diffView.scope.changed')}
{workingCount}
{t('diffView.scope.staged')}
{stagedCount}
{t('diffView.scope.lastTurn')}
{turnCount}
{showBranchOption ? (
{t('diffView.scope.branch')}
{branchCount ?? '…'}
) : null}
{showCommitOption && (
{t('commitComparison.mode')}
{commitCount ?? '…'}
)}
);
});
interface FileListProps {
changedFiles: FileEntry[];
selectedFile: string | null;
onSelectFile: (path: string) => void;
}
const FileList = React.memo(({
changedFiles,
selectedFile,
onSelectFile,
}) => {
const { t } = useI18n();
if (changedFiles.length === 0) return null;
return (
{changedFiles.map((file) => {
const descriptor = describeChange(file);
const isActive = selectedFile === file.path;
return (
-
);
})}
);
});
// Image diff viewer for binary image files
interface InlineImageDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
}
const InlineImageDiffViewer = React.memo(({
filePath,
diff,
renderSideBySide,
}) => {
const { t } = useI18n();
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
const containerClass = renderSideBySide
? 'flex flex-row gap-6 items-start justify-center'
: 'flex flex-col gap-4 items-center';
const imageContainerClass = renderSideBySide
? 'flex flex-col items-center gap-2 flex-1 min-w-0'
: 'flex flex-col items-center gap-2';
return (
{hasOriginal && (
{t('diffView.image.original')}
)}
{hasModified && (
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
)}
);
});
interface InlineDiffViewerProps {
filePath: string;
diff: DiffData;
renderSideBySide: boolean;
wrapLines: boolean;
hunkActions?: DiffHunkActions;
}
const InlineDiffViewer = React.memo(({
filePath,
diff,
renderSideBySide,
wrapLines,
hunkActions,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath) || 'text',
[filePath]
);
if (diff.isBinary) {
return ;
}
if (isImageFile(filePath)) {
return (
);
}
return (
);
});
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
layout: 'inline' | 'side-by-side';
wrapLines: boolean;
isSelected: boolean;
isExpanded: boolean;
isMounted: boolean;
onSelect: (path: string) => void;
onExpandedChange: (path: string, expanded: boolean) => void;
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
showOpenInEditorAction?: boolean;
isOpeningInEditor?: boolean;
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
staged?: boolean;
loadFullFiles?: boolean;
initialDiffData?: DiffData | null;
comparisonDiff?: ComparisonDiffResult;
onRetryComparisonDiff?: () => void;
/** Hide stage/unstage/revert actions for branch and commit comparisons. */
readOnlyActions?: boolean;
/** Hunk mutations require a live working/index diff, never a turn snapshot. */
hunkActionsEnabled?: boolean;
}
export const MultiFileDiffEntry = React.memo(({
directory,
file,
layout,
wrapLines,
isSelected,
isExpanded,
isMounted,
onSelect,
onExpandedChange,
registerSectionRef,
showOpenInEditorAction = false,
isOpeningInEditor = false,
onOpenInEditor,
staged = false,
loadFullFiles = false,
initialDiffData = null,
comparisonDiff,
onRetryComparisonDiff,
readOnlyActions = false,
hunkActionsEnabled = false,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(file.path) ?? null;
}, [directory, file.path])
);
const setDiff = useGitStore((state) => state.setDiff);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [localDiffLoadError, setDiffLoadError] = React.useState(null);
const [isFetching, setIsLoading] = React.useState(false);
const diffLoadError = comparisonDiff ? (comparisonDiff.status === 'error' ? comparisonDiff.message : null) : localDiffLoadError;
const isLoading = comparisonDiff ? comparisonDiff.status === 'loading' : isFetching;
const [hunkAction, setHunkAction] = React.useState(null);
const mutationInFlight = React.useRef(false);
const [canonicalPatch, setCanonicalPatch] = React.useState<{ scope: string; patch: string } | null>(null);
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
const [localDiffData, setLocalDiffData] = React.useState(null);
const [stagedDiffData, setStagedDiffData] = React.useState(null);
const lastDiffRequestRef = React.useRef(null);
const sectionRef = React.useRef(null);
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch';
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
const hunkEligible = hunkActionsEnabled && !readOnlyActions && !initialDiffData && !comparisonDiff && !isImageFile(file.path);
const patchScope = JSON.stringify([getRuntimeKey(), directory, file.path, staged, fileStatusKey, diffRetryNonce]);
const actionPatch = canonicalPatch?.scope === patchScope ? canonicalPatch.patch : null;
const diffData = React.useMemo(() => {
if (comparisonDiff) return comparisonDiff.status === 'ready' ? comparisonDiff.data : null;
if (initialDiffData) return initialDiffData;
if (staged) return stagedDiffData;
if (localDiffData) return localDiffData;
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
}, [comparisonDiff, cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
sectionRef.current = node;
registerSectionRef(file.path, node);
}, [file.path, registerSectionRef]);
const handleOpenChange = React.useCallback((open: boolean) => {
onExpandedChange(file.path, open);
}, [file.path, onExpandedChange]);
const handleSelect = React.useCallback(() => {
onSelect(file.path);
}, [file.path, onSelect]);
React.useEffect(() => {
if (!staged) {
setLocalDiffData(null);
} else {
setStagedDiffData(null);
}
setDiffLoadError(null);
lastDiffRequestRef.current = null;
}, [fileStatusKey, staged]);
React.useEffect(() => {
if (!isExpanded || !isMounted) return;
if (localDiffLoadError) return;
if (!directory || comparisonDiff || initialDiffData || (diffData && diffDataMatchesContextMode && (!hunkEligible || actionPatch !== null))) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
}
const requestKey = `${directory}::${file.path}::${staged ? 'staged' : 'unstaged'}::${fileStatusKey}::${desiredContextMode}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
lastDiffRequestRef.current = requestKey;
setDiffLoadError(null);
setIsLoading(true);
let cancelled = false;
const runtimeKey = getRuntimeKey();
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
const displayRequest = isImageFile(file.path)
? git.getGitFileDiff(directory, { path: file.path, staged })
: !loadFullFiles && actionPatch !== null
? Promise.resolve({ diff: actionPatch })
: git.getGitDiff(directory, { path: file.path, staged, contextLines });
const canonicalRequest = hunkEligible && loadFullFiles && actionPatch === null
? git.getGitDiff(directory, { path: file.path, staged, contextLines: DEFAULT_CONTEXT_DIFF_LINES }).then((response) => response.diff)
: Promise.resolve(actionPatch);
const fetchPromise = Promise.all([displayRequest, canonicalRequest]);
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
let timeout: ReturnType | undefined;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
void Promise.race([fetchPromise, timeoutPromise])
.then(([response, normalPatch]) => {
if (cancelled || runtimeKey !== getRuntimeKey()) return;
const patch = 'diff' in response && !loadFullFiles ? response.diff : normalPatch;
if (hunkEligible && loadFullFiles && patch !== null && /^@@\s/m.test(patch)
&& ('diff' in response && response.diff !== patch && !haveMatchingPatchVersions(response.diff, patch))) {
setCanonicalPatch(null);
throw new Error(t('diffView.hunk.unavailable'));
}
if (hunkEligible && patch !== null) setCanonicalPatch({ scope: patchScope, patch });
if ('diff' in response) {
const nextDiff = createTextDiffDataFromPatch(file.path, response.diff, desiredContextMode);
if (staged) {
setStagedDiffData(nextDiff);
} else {
setLocalDiffData(nextDiff);
}
} else {
const nextDiff = {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
contextMode: 'full' as const,
};
if (staged) {
setStagedDiffData(nextDiff);
} else {
setDiff(directory, file.path, nextDiff, runtimeKey);
}
}
setIsLoading(false);
})
.catch((error) => {
if (cancelled || runtimeKey !== getRuntimeKey()) return;
const message = error instanceof Error ? error.message : String(error);
setDiffLoadError(message);
setIsLoading(false);
}).finally(() => clearTimeout(timeout));
return () => {
cancelled = true;
clearTimeout(timeout);
if (lastDiffRequestRef.current === requestKey) {
lastDiffRequestRef.current = null;
}
};
}, [actionPatch, hunkEligible, patchScope, comparisonDiff, desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, localDiffLoadError, setDiff, staged, t]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
handleSelect();
}, [handleOpenChange, handleSelect, isExpanded]);
const invalidatePatch = React.useCallback(() => {
setDiffLoadError(null);
setCanonicalPatch(null);
setLocalDiffData(null);
setStagedDiffData(null);
lastDiffRequestRef.current = null;
setDiffRetryNonce((nonce) => nonce + 1);
}, []);
const handleHunkAction = React.useCallback(async (hunkIndex: number, action: HunkDiffAction) => {
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null) {
return;
}
const hunkPatch = actionPatch ? extractHunkPatch(actionPatch, hunkIndex) : null;
if (!hunkPatch) {
toast.error(t('diffView.hunk.unavailable'));
return;
}
if ((staged && action !== 'unstage') || (!staged && action === 'unstage')) return;
mutationInFlight.current = true;
const runtimeKey = getRuntimeKey();
setHunkAction({ index: hunkIndex, action });
try {
const hunkMutation = action === 'stage'
? git.stageGitHunk
: action === 'unstage'
? git.unstageGitHunk
: git.revertGitHunk;
if (!hunkMutation) {
toast.error(t('diffView.hunk.unsupported'));
return;
}
await hunkMutation(directory, file.path, hunkPatch);
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
sessionEvents.requestGitRefresh({ directory, paths: [file.path] });
await fetchStatus(directory, git);
} catch (error) {
if (runtimeKey !== getRuntimeKey()) return;
invalidatePatch();
toast.error(error instanceof Error && error.message ? error.message : t('diffView.hunk.unavailable'));
} finally {
mutationInFlight.current = false;
setHunkAction((current) => (current?.index === hunkIndex && current.action === action ? null : current));
}
}, [actionPatch, hunkEligible, isLoading, diffLoadError, directory, fetchStatus, file.path, git, hunkAction, invalidatePatch, staged, t]);
const hunkAnchors = React.useMemo(() => hunkEligible && actionPatch !== null ? getPatchHunkAnchors(actionPatch) : [], [actionPatch, hunkEligible]);
const renderHunkActions = React.useCallback((index: number) => (
), [diffLoadError, handleHunkAction, hunkAction, isLoading, staged]);
const diffHunkActions = React.useMemo(() => hunkAnchors.length > 0
? { anchors: hunkAnchors, render: renderHunkActions } : undefined, [hunkAnchors, renderHunkActions]);
return (
{
if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle();
}
}}
className={cn(
'cursor-pointer',
'group/header relative grid min-h-9 w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden px-3 py-2',
'bg-transparent',
'text-muted-foreground hover:text-foreground',
isSelected ? 'bg-[var(--interactive-selection)]/35' : null
)}
>
{isExpanded ? (
) : (
)}
{descriptor.code}
{(() => {
const lastSlash = file.path.lastIndexOf('/');
if (lastSlash === -1) {
return (
{file.path}
);
}
const dir = file.path.slice(0, lastSlash);
const name = file.path.slice(lastSlash + 1);
return (
{dir}
/
{name}
);
})()}
{formatDiffTotals(file.insertions, file.deletions)}
{showOpenInEditorAction && onOpenInEditor ? (
) : null}
{
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
{isExpanded && (
{!isMounted && !diffLoadError ? (
) : null}
{diffLoadError ? (
{t('diffView.state.failedToLoadDiff')}
{diffLoadError}
) : null}
{isMounted && isLoading && !diffData && !diffLoadError ? (
{t('diffView.state.loadingDiff')}
) : null}
{isMounted && diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
{t('diffView.state.largeDiff', { count: file.insertions + file.deletions })}
{t('diffView.state.largeDiffDescription')}
) : null}
{isMounted && diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
<>
>
) : null}
)}
);
});
interface DiffViewProps {
hideStackedFileSidebar?: boolean;
stackedDefaultCollapsedAll?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
onDiffScopeChange?: (scope: PendingDiffScope) => void;
targetFilePath?: string | null;
/** Render diff content flush with the container edges (no outer padding). */
flushContent?: boolean;
}
export const DiffView: React.FC = ({
hideStackedFileSidebar = false,
stackedDefaultCollapsedAll = false,
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
diffScope = 'all',
onDiffScopeChange,
targetFilePath = null,
flushContent = false,
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
const rootDirectory = useEffectiveDirectory();
// Diffs belong to the repository being diffed: when the root is not
// itself a repository, operate on the resolved nested repository instead.
const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null);
const effectiveDirectory = nestedGitDirectory ?? rootDirectory;
const openContextSurface = useUIStore((state) => state.openContextSurface);
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
const { screenWidth, isMobile } = useDeviceInfo();
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const setDiff = useGitStore((state) => state.setDiff);
const [displayFile, setDisplayFile] = React.useState(null);
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState(null);
const [expandedFiles, setExpandedFiles] = React.useState>(() => new Set());
const [mountedStackedFiles, setMountedStackedFiles] = React.useState>(() => new Set());
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState