A diff is ordered by file path, which is almost never the order in which a change makes sense. This adds a Walkthrough surface that reorders it: the model groups related hunks into stops, explains what each group changes about behavior, and orders the stops so each builds on the last. It explains and orders; judging code stays with the existing Review action. Reviews uncommitted work (all, staged, unstaged), a branch against its base, or a pull request. Generation is always user-initiated — nothing runs on a timer, on a file change, or as a side effect of opening a panel. Invariants worth preserving: - Hunk identity is derived on the server and only there. Ids are content hashes, so an anchor that no longer resolves is proof the code it described changed, and staleness needs no heuristics. The client matches ids to ids and never recomputes them; two implementations would have to agree forever. - The digest is never truncated. A diff that does not fit the model's context is refused with an actionable reason, because a walkthrough written against half a diff reads as confident and is wrong. - Nothing disappears. Lockfiles and other generated output are excluded from the model's input by name — never by size — and everything no stop covers is listed at the end, so "have I seen all of it" stays answerable. - Cost is explicit. Results are content-addressed, so returning the working tree to an earlier state costs nothing; generation outlives its request, so a refresh detaches the client rather than discarding paid-for work, and only an explicit cancel stops it. Supporting changes to shared modules: - git: expose the existing getRangeDiff as GET /api/git listUntrackedPaths and getUntrackedDiffs. The latter resolve the repository once for a batch instead of per file, taking a panel ~340ms on an 80-file working tree. - small-model: structured output across four wire forma and abort signal, and an onOverflow policy so an oversized prompt fails loudly instead of being silently clipped. A provider remembered so the prompt-side fallback goes first next time. - models.dev metadata: surface structured_output as tri false blocks a model, a missing field does not, because the catalog omits it for roughly half of all models. Desktop and tablet only: VS Code serves Git through its these routes, and the mobile shell does not consume the surface registry. Docs: packages/docs walkthrough page in English and all eight locales.
1816 lines
76 KiB
TypeScript
1816 lines
76 KiB
TypeScript
import React from 'react';
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
|
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 } from './PierreDiffViewer';
|
|
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 { useI18n } from '@/lib/i18n';
|
|
import type { I18nKey } from '@/lib/i18n/store';
|
|
import { fileDiffFromPatch } 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' | 'staged' | 'working' | 'turn';
|
|
|
|
type TurnSnapshotDiff = {
|
|
file?: string;
|
|
status?: string;
|
|
before?: string;
|
|
after?: string;
|
|
patch?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
};
|
|
|
|
const BinaryDiffPlaceholder = React.memo(() => {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
|
|
<div className="typography-meta text-muted-foreground">{t('diffView.binary.unavailable')}</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
type ChangeDescriptor = {
|
|
code: string;
|
|
color: string;
|
|
descriptionKey: I18nKey;
|
|
};
|
|
|
|
const CHANGE_DESCRIPTORS: Record<string, ChangeDescriptor> = {
|
|
'?': { 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 isBinaryPatch = (patch: string): boolean =>
|
|
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
|
|
|
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 (
|
|
<span
|
|
className={cn(
|
|
'typography-meta flex items-center gap-1 text-xs whitespace-nowrap',
|
|
options?.shrink ? 'min-w-0 overflow-hidden' : 'flex-shrink-0',
|
|
options?.className,
|
|
)}
|
|
>
|
|
{added ? <span style={{ color: 'var(--status-success)' }}>+{added}</span> : null}
|
|
{removed ? <span style={{ color: 'var(--status-error)' }}>-{removed}</span> : null}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
interface ChangeScopeSelectorProps {
|
|
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
|
workingCount: number;
|
|
stagedCount: number;
|
|
turnCount: number;
|
|
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
|
}
|
|
|
|
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
|
scope,
|
|
workingCount,
|
|
stagedCount,
|
|
turnCount,
|
|
onScopeChange,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [open, setOpen] = React.useState(false);
|
|
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
|
const currentLabel = scope === 'staged'
|
|
? t('diffView.scope.staged')
|
|
: scope === 'turn'
|
|
? t('diffView.scope.lastTurn')
|
|
: t('diffView.scope.changed');
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-7 flex-shrink-0 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={t('diffView.scope.selectorAria')}
|
|
>
|
|
<span className="whitespace-nowrap">
|
|
{currentLabel}<span className="diff-toolbar__scope-count">: {currentCount}</span>
|
|
</span>
|
|
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-60" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-40">
|
|
<DropdownMenuRadioGroup
|
|
value={scope}
|
|
onValueChange={(value) => {
|
|
if (value === 'working' || value === 'staged' || value === 'turn') {
|
|
onScopeChange?.(value);
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<DropdownMenuRadioItem value="working">
|
|
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
|
<span>{t('diffView.scope.changed')}</span>
|
|
<span className="typography-meta text-muted-foreground">{workingCount}</span>
|
|
</span>
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="staged">
|
|
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
|
<span>{t('diffView.scope.staged')}</span>
|
|
<span className="typography-meta text-muted-foreground">{stagedCount}</span>
|
|
</span>
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="turn">
|
|
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
|
<span>{t('diffView.scope.lastTurn')}</span>
|
|
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
|
</span>
|
|
</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
});
|
|
|
|
interface FileListProps {
|
|
changedFiles: FileEntry[];
|
|
selectedFile: string | null;
|
|
onSelectFile: (path: string) => void;
|
|
}
|
|
|
|
const FileList = React.memo<FileListProps>(({
|
|
changedFiles,
|
|
selectedFile,
|
|
onSelectFile,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
if (changedFiles.length === 0) return null;
|
|
|
|
return (
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-2 py-2">
|
|
<ul className="flex flex-col gap-1">
|
|
{changedFiles.map((file) => {
|
|
const descriptor = describeChange(file);
|
|
const isActive = selectedFile === file.path;
|
|
|
|
return (
|
|
<li key={file.path}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectFile(file.path)}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
|
isActive
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground'
|
|
)}
|
|
>
|
|
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span
|
|
className="typography-micro font-semibold w-4 text-center uppercase"
|
|
style={{ color: descriptor.color }}
|
|
title={t(descriptor.descriptionKey)}
|
|
aria-label={t(descriptor.descriptionKey)}
|
|
>
|
|
{descriptor.code}
|
|
</span>
|
|
<span
|
|
className="min-w-0 flex-1 truncate typography-meta"
|
|
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
|
title={file.path}
|
|
>
|
|
{file.path}
|
|
</span>
|
|
{formatDiffTotals(file.insertions, file.deletions)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</ScrollableOverlay>
|
|
);
|
|
});
|
|
|
|
// Image diff viewer for binary image files
|
|
interface InlineImageDiffViewerProps {
|
|
filePath: string;
|
|
diff: DiffData;
|
|
renderSideBySide: boolean;
|
|
}
|
|
|
|
const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
|
|
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 (
|
|
<div className="w-full overflow-auto p-4" style={{ contain: 'layout' }}>
|
|
<div className={containerClass}>
|
|
{hasOriginal && (
|
|
<div className={imageContainerClass}>
|
|
<span className="typography-meta text-muted-foreground font-medium">{t('diffView.image.original')}</span>
|
|
<img
|
|
src={diff.original}
|
|
alt={t('diffView.image.originalAlt', { path: filePath })}
|
|
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
|
|
style={{ imageRendering: 'auto' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{hasModified && (
|
|
<div className={imageContainerClass}>
|
|
<span className="typography-meta text-muted-foreground font-medium">
|
|
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
|
|
</span>
|
|
<img
|
|
src={diff.modified}
|
|
alt={t('diffView.image.modifiedAlt', { path: filePath })}
|
|
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
|
|
style={{ imageRendering: 'auto' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface InlineDiffViewerProps {
|
|
filePath: string;
|
|
diff: DiffData;
|
|
renderSideBySide: boolean;
|
|
wrapLines: boolean;
|
|
}
|
|
|
|
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
|
filePath,
|
|
diff,
|
|
renderSideBySide,
|
|
wrapLines,
|
|
}) => {
|
|
const language = React.useMemo(
|
|
() => getLanguageFromExtension(filePath) || 'text',
|
|
[filePath]
|
|
);
|
|
|
|
if (diff.isBinary) {
|
|
return <BinaryDiffPlaceholder />;
|
|
}
|
|
|
|
if (isImageFile(filePath)) {
|
|
return (
|
|
<InlineImageDiffViewer
|
|
filePath={filePath}
|
|
diff={diff}
|
|
renderSideBySide={renderSideBySide}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full" style={{ contain: 'layout' }}>
|
|
<PierreDiffViewer
|
|
original={diff.original}
|
|
modified={diff.modified}
|
|
fileDiff={diff.fileDiff}
|
|
language={language}
|
|
fileName={filePath}
|
|
renderSideBySide={renderSideBySide}
|
|
wrapLines={wrapLines}
|
|
layout="inline"
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
type FileDiffAction = 'stage' | 'unstage' | 'discard';
|
|
|
|
interface FileDiffActionsProps {
|
|
filePath: string;
|
|
staged: boolean;
|
|
busyAction: FileDiffAction | null;
|
|
disabled: boolean;
|
|
onAction: (action: FileDiffAction) => void;
|
|
}
|
|
|
|
const FileDiffActions = React.memo<FileDiffActionsProps>(({
|
|
filePath,
|
|
staged,
|
|
busyAction,
|
|
disabled,
|
|
onAction,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-lg backdrop-blur-md">
|
|
{staged ? (
|
|
<FileDiffActionButton
|
|
label={t('gitView.changes.unstageFileAria', { path: filePath })}
|
|
icon="arrow-go-back"
|
|
loading={busyAction === 'unstage'}
|
|
disabled={disabled}
|
|
onClick={() => onAction('unstage')}
|
|
/>
|
|
) : (
|
|
<>
|
|
<FileDiffActionButton
|
|
label={t('gitView.changes.revertFileAria', { path: filePath })}
|
|
icon="arrow-go-back"
|
|
loading={busyAction === 'discard'}
|
|
disabled={disabled}
|
|
tone="failure"
|
|
onClick={() => onAction('discard')}
|
|
/>
|
|
<FileDiffActionButton
|
|
label={t('gitView.changes.stageFileAria', { path: filePath })}
|
|
icon="add"
|
|
loading={busyAction === 'stage'}
|
|
disabled={disabled}
|
|
tone="success"
|
|
onClick={() => onAction('stage')}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface FileDiffActionButtonProps {
|
|
label: string;
|
|
icon: 'add' | 'arrow-go-back';
|
|
loading: boolean;
|
|
disabled: boolean;
|
|
tone?: 'failure' | 'success';
|
|
onClick: () => void;
|
|
}
|
|
|
|
const FileDiffActionButton: React.FC<FileDiffActionButtonProps> = ({
|
|
label,
|
|
icon,
|
|
loading,
|
|
disabled,
|
|
tone,
|
|
onClick,
|
|
}) => (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
'h-6 w-6 rounded-none bg-transparent p-0 text-muted-foreground opacity-70 hover:bg-transparent hover:text-foreground hover:opacity-100',
|
|
tone === 'failure' && 'text-[var(--status-error)] hover:text-[var(--status-error)]',
|
|
tone === 'success' && 'text-[var(--status-success)] hover:text-[var(--status-success)]'
|
|
)}
|
|
disabled={disabled}
|
|
title={label}
|
|
aria-label={label}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onClick();
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Icon name={icon} className={icon === 'add' ? 'size-4' : 'size-3.5'} />
|
|
)}
|
|
</Button>
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|
directory,
|
|
file,
|
|
layout,
|
|
wrapLines,
|
|
isSelected,
|
|
isExpanded,
|
|
isMounted,
|
|
onSelect,
|
|
onExpandedChange,
|
|
registerSectionRef,
|
|
showOpenInEditorAction = false,
|
|
isOpeningInEditor = false,
|
|
onOpenInEditor,
|
|
staged = false,
|
|
loadFullFiles = false,
|
|
initialDiffData = null,
|
|
}) => {
|
|
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 [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
|
|
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
|
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
|
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
|
const lastDiffRequestRef = React.useRef<string | null>(null);
|
|
const sectionRef = React.useRef<HTMLDivElement | null>(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 diffData = React.useMemo<DiffData | 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' };
|
|
}, [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 (!directory || initialDiffData || (diffData && diffDataMatchesContextMode)) {
|
|
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 fetchPromise = isImageFile(file.path)
|
|
? git.getGitFileDiff(directory, { path: file.path, staged })
|
|
: git.getGitDiff(directory, { path: file.path, staged, contextLines });
|
|
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
});
|
|
|
|
void Promise.race([fetchPromise, timeoutPromise])
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
|
|
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) return;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setDiffLoadError(message);
|
|
setIsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (lastDiffRequestRef.current === requestKey) {
|
|
lastDiffRequestRef.current = null;
|
|
}
|
|
};
|
|
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
|
|
|
const handleToggle = React.useCallback(() => {
|
|
handleOpenChange(!isExpanded);
|
|
handleSelect();
|
|
}, [handleOpenChange, handleSelect, isExpanded]);
|
|
|
|
const handleFileAction = React.useCallback(async (action: FileDiffAction) => {
|
|
if (!directory || fileAction !== null) {
|
|
return;
|
|
}
|
|
|
|
setFileAction(action);
|
|
try {
|
|
if (action === 'stage') {
|
|
await git.stageGitFile(directory, file.path);
|
|
} else if (action === 'unstage') {
|
|
await git.unstageGitFile(directory, file.path);
|
|
} else {
|
|
await git.revertGitFile(directory, file.path, { scope: 'working' });
|
|
}
|
|
setDiffRetryNonce((nonce) => nonce + 1);
|
|
await fetchStatus(directory, git);
|
|
} catch (error) {
|
|
const fallbackKey = action === 'unstage'
|
|
? 'gitView.toast.unstageFileFailed'
|
|
: action === 'stage'
|
|
? 'gitView.toast.stageFileFailed'
|
|
: 'gitView.toast.revertFailed';
|
|
toast.error(error instanceof Error ? error.message : t(fallbackKey));
|
|
} finally {
|
|
setFileAction((current) => (current === action ? null : current));
|
|
}
|
|
}, [directory, fetchStatus, file.path, fileAction, git, t]);
|
|
|
|
return (
|
|
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
|
<div className="sticky top-0 z-30 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={handleToggle}
|
|
onKeyDown={(event) => {
|
|
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
|
|
)}
|
|
>
|
|
<div className="absolute inset-0 pointer-events-none group-hover/header:bg-[var(--interactive-hover)]/50" />
|
|
<div className="relative flex min-w-0 flex-1 items-center gap-2">
|
|
<span className="flex size-5 items-center justify-center opacity-70 group-hover/header:opacity-100">
|
|
{isExpanded ? (
|
|
<Icon name="arrow-down-s" className="size-4" />
|
|
) : (
|
|
<Icon name="arrow-right-s" className="size-4" />
|
|
)}
|
|
</span>
|
|
<span
|
|
className="typography-micro font-semibold leading-none w-4 text-center uppercase"
|
|
style={{ color: descriptor.color }}
|
|
title={t(descriptor.descriptionKey)}
|
|
aria-label={t(descriptor.descriptionKey)}
|
|
>
|
|
{descriptor.code}
|
|
</span>
|
|
<span
|
|
className="min-w-0 flex-1 overflow-hidden typography-ui-label"
|
|
title={file.path}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0 align-middle" />
|
|
{(() => {
|
|
const lastSlash = file.path.lastIndexOf('/');
|
|
if (lastSlash === -1) {
|
|
return (
|
|
<span
|
|
className="block min-w-0 truncate typography-ui-label text-foreground"
|
|
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
|
>
|
|
{file.path}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const dir = file.path.slice(0, lastSlash);
|
|
const name = file.path.slice(lastSlash + 1);
|
|
|
|
return (
|
|
<span className="flex min-w-0 items-baseline overflow-hidden">
|
|
<span
|
|
className="min-w-0 truncate typography-ui-label text-muted-foreground"
|
|
style={{ direction: 'rtl', textAlign: 'left', unicodeBidi: 'plaintext' }}
|
|
>
|
|
{dir}
|
|
</span>
|
|
<span className="flex-shrink-0 typography-ui-label">
|
|
<span className="text-muted-foreground">/</span>
|
|
<span className="text-foreground">{name}</span>
|
|
</span>
|
|
</span>
|
|
);
|
|
})()}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="relative flex shrink-0 items-center justify-self-end gap-2">
|
|
{formatDiffTotals(file.insertions, file.deletions)}
|
|
{showOpenInEditorAction && onOpenInEditor ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
|
|
title={t('diffView.actions.openFileInEditorAtChange')}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpenInEditor(file.path, diffData);
|
|
}}
|
|
disabled={isOpeningInEditor}
|
|
>
|
|
{isOpeningInEditor ? (
|
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Icon name="edit" className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
<DiffViewToggle
|
|
mode={renderSideBySide ? 'side-by-side' : 'unified'}
|
|
onModeChange={(mode: DiffViewMode) => {
|
|
const nextLayout: 'inline' | 'side-by-side' =
|
|
mode === 'side-by-side' ? 'side-by-side' : 'inline';
|
|
setDiffFileLayout(file.path, nextLayout);
|
|
}}
|
|
className="opacity-70"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isExpanded && (
|
|
<div className="relative bg-background overflow-hidden">
|
|
{!isMounted && !diffLoadError ? (
|
|
<div className="h-40 border border-border/40 bg-background/40" />
|
|
) : null}
|
|
{diffLoadError ? (
|
|
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
|
<div className="typography-ui-label font-semibold text-foreground">
|
|
{t('diffView.state.failedToLoadDiff')}
|
|
</div>
|
|
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
|
|
{diffLoadError}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="typography-ui-label text-primary hover:underline"
|
|
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
|
|
>
|
|
{t('diffView.actions.retry')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{isMounted && isLoading && !diffData && !diffLoadError ? (
|
|
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
{t('diffView.state.loadingDiff')}
|
|
</div>
|
|
) : null}
|
|
{isMounted && diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
|
|
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
|
<div className="typography-ui-label font-semibold text-foreground">
|
|
{t('diffView.state.largeDiff', { count: file.insertions + file.deletions })}
|
|
</div>
|
|
<div className="typography-meta text-muted-foreground">
|
|
{t('diffView.state.largeDiffDescription')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="typography-ui-label text-primary hover:underline"
|
|
onClick={() => setForceRenderLarge(true)}
|
|
>
|
|
{t('diffView.actions.renderAnyway')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{isMounted && diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
|
<>
|
|
<InlineDiffViewer
|
|
filePath={file.path}
|
|
diff={diffData}
|
|
renderSideBySide={renderSideBySide}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
|
<div className="pointer-events-auto">
|
|
<FileDiffActions
|
|
filePath={file.path}
|
|
staged={staged}
|
|
busyAction={fileAction}
|
|
disabled={fileAction !== null}
|
|
onAction={handleFileAction}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface DiffViewProps {
|
|
hideStackedFileSidebar?: boolean;
|
|
stackedDefaultCollapsedAll?: boolean;
|
|
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
|
showOpenInEditorAction?: boolean;
|
|
diffScope?: DiffScope;
|
|
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
|
targetFilePath?: string | null;
|
|
/** Render diff content flush with the container edges (no outer padding). */
|
|
flushContent?: boolean;
|
|
}
|
|
|
|
export const DiffView: React.FC<DiffViewProps> = ({
|
|
hideStackedFileSidebar = false,
|
|
stackedDefaultCollapsedAll = false,
|
|
pinSelectedFileHeaderToTopOnNavigate = false,
|
|
showOpenInEditorAction = false,
|
|
diffScope = 'all',
|
|
onDiffScopeChange,
|
|
targetFilePath = null,
|
|
flushContent = false,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const { git, files } = useRuntimeAPIs();
|
|
const effectiveDirectory = useEffectiveDirectory();
|
|
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 fetchStatus = useGitStore((state) => state.fetchStatus);
|
|
const setDiff = useGitStore((state) => state.setDiff);
|
|
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
|
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
|
|
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
|
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(() => new Set());
|
|
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
|
|
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
|
|
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
|
|
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
|
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
|
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
|
|
|
|
React.useEffect(() => {
|
|
setActiveDiffScope(diffScope);
|
|
}, [diffScope]);
|
|
|
|
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
|
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
|
const pendingDiffScope = useUIStore((state) => state.pendingDiffScope);
|
|
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
|
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
|
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
|
|
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
|
const diffWrapLinesStore = useUIStore((state) => state.diffWrapLines);
|
|
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
|
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined);
|
|
const diffWrapLines = diffWrapLinesStore;
|
|
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
|
|
const activeDiffStaged = forcedStaged ?? displayFileStaged;
|
|
|
|
const isMobileLayout = isMobile || screenWidth <= 768;
|
|
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
|
// Same runtime and width rules as the rail surface: no point offering an
|
|
// entry point to a surface that cannot open here.
|
|
const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
|
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
|
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
|
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
|
const pendingScrollTargetRef = React.useRef<string | null>(null);
|
|
const pendingScrollFrameRef = React.useRef<number | null>(null);
|
|
const shouldPinAfterAlignRef = React.useRef(false);
|
|
const visibleSyncFrameRef = React.useRef<number | null>(null);
|
|
const stackedStateScopeRef = React.useRef<string | null>(null);
|
|
|
|
const cancelPendingScrollAlignment = React.useCallback(() => {
|
|
pendingScrollTargetRef.current = null;
|
|
shouldPinAfterAlignRef.current = false;
|
|
setPinnedStackedTarget(null);
|
|
if (pendingScrollFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
|
pendingScrollFrameRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const expandStackedFile = React.useCallback((path: string) => {
|
|
setExpandedFiles((previous) => {
|
|
if (previous.has(path)) {
|
|
return previous;
|
|
}
|
|
const next = new Set(previous);
|
|
next.add(path);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const lastTurnDiffs = React.useMemo(() => {
|
|
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = sessionMessages[index] as { role?: string; summary?: { diffs?: unknown } };
|
|
if (message.role !== 'user') continue;
|
|
return listTurnDiffs(message.summary?.diffs);
|
|
}
|
|
return [];
|
|
}, [sessionMessages]);
|
|
|
|
const lastTurnDiffData = React.useMemo(() => {
|
|
const map = new Map<string, DiffData>();
|
|
for (const diff of lastTurnDiffs) {
|
|
if (!diff.file) continue;
|
|
if (typeof diff.patch === 'string') {
|
|
map.set(diff.file, createTextDiffDataFromPatch(diff.file, diff.patch, 'patch'));
|
|
continue;
|
|
}
|
|
map.set(diff.file, {
|
|
original: diff.before ?? '',
|
|
modified: diff.after ?? '',
|
|
contextMode: 'full',
|
|
});
|
|
}
|
|
return map;
|
|
}, [lastTurnDiffs]);
|
|
|
|
const changedFiles: FileEntry[] = React.useMemo(() => {
|
|
if (activeDiffScope === 'turn') {
|
|
return lastTurnDiffs
|
|
.map((diff) => ({
|
|
path: diff.file ?? '',
|
|
index: '',
|
|
working_dir: statusToGitCode(diff.status),
|
|
insertions: diff.additions ?? 0,
|
|
deletions: diff.deletions ?? 0,
|
|
isNew: diff.status === 'added',
|
|
}))
|
|
.filter((file) => file.path)
|
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
}
|
|
|
|
if (!status?.files) return [];
|
|
const diffStats = status.diffStats ?? {};
|
|
const includeFile = activeDiffScope === 'staged'
|
|
? isStagedStatusFile
|
|
: activeDiffScope === 'working'
|
|
? isWorkingStatusFile
|
|
: () => true;
|
|
|
|
return status.files
|
|
.filter(includeFile)
|
|
.map((file) => ({
|
|
...file,
|
|
insertions: diffStats[file.path]?.insertions ?? 0,
|
|
deletions: diffStats[file.path]?.deletions ?? 0,
|
|
isNew: isNewStatusFile(file),
|
|
}))
|
|
.sort((a, b) => a.path.localeCompare(b.path));
|
|
}, [activeDiffScope, lastTurnDiffs, status]);
|
|
|
|
const workingFileCount = React.useMemo(() => {
|
|
if (!status?.files) return 0;
|
|
return status.files.filter(isWorkingStatusFile).length;
|
|
}, [status]);
|
|
|
|
const stagedFileCount = React.useMemo(() => {
|
|
if (!status?.files) return 0;
|
|
return status.files.filter(isStagedStatusFile).length;
|
|
}, [status]);
|
|
|
|
const turnFileCount = lastTurnDiffs.length;
|
|
|
|
const changedFilePathsKey = React.useMemo(
|
|
() => changedFiles.map((file) => file.path).join('\0'),
|
|
[changedFiles],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
|
const pathSet = new Set(paths);
|
|
const scopeKey = `${effectiveDirectory ?? ''}:${activeDiffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
|
const shouldInitialize = stackedStateScopeRef.current !== scopeKey;
|
|
stackedStateScopeRef.current = scopeKey;
|
|
|
|
setExpandedFiles((previous) => {
|
|
if (shouldInitialize) {
|
|
const defaultExpandedCount = stackedDefaultCollapsedAll
|
|
? 0
|
|
: getStackedViewDefaultExpandedCount(paths.length);
|
|
return new Set(paths.slice(0, defaultExpandedCount));
|
|
}
|
|
|
|
let changed = false;
|
|
const next = new Set<string>();
|
|
for (const path of previous) {
|
|
if (!pathSet.has(path)) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
next.add(path);
|
|
}
|
|
return changed ? next : previous;
|
|
});
|
|
|
|
setMountedStackedFiles((previous) => {
|
|
if (shouldInitialize) {
|
|
return new Set();
|
|
}
|
|
|
|
let changed = false;
|
|
const next = new Set<string>();
|
|
for (const path of previous) {
|
|
if (!pathSet.has(path)) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
next.add(path);
|
|
}
|
|
return changed ? next : previous;
|
|
});
|
|
}, [activeDiffScope, changedFilePathsKey, effectiveDirectory, stackedDefaultCollapsedAll]);
|
|
|
|
const syncVisibleStackedFiles = React.useCallback(() => {
|
|
visibleSyncFrameRef.current = null;
|
|
const scrollRoot = diffScrollRef.current;
|
|
if (!scrollRoot) return;
|
|
|
|
const rootRect = scrollRoot.getBoundingClientRect();
|
|
const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN;
|
|
const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN;
|
|
const next: Record<string, boolean> = {};
|
|
|
|
for (const [path, node] of fileSectionRefs.current) {
|
|
if (!node || !expandedFiles.has(path)) continue;
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.bottom < top || rect.top > bottom) continue;
|
|
next[path] = true;
|
|
}
|
|
|
|
setMountedStackedFiles((previous) => {
|
|
let changed = false;
|
|
const mounted = new Set(previous);
|
|
for (const path of Object.keys(next)) {
|
|
if (mounted.has(path)) continue;
|
|
mounted.add(path);
|
|
changed = true;
|
|
}
|
|
return changed ? mounted : previous;
|
|
});
|
|
}, [expandedFiles]);
|
|
|
|
const queueVisibleStackedFilesSync = React.useCallback(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (visibleSyncFrameRef.current !== null) return;
|
|
visibleSyncFrameRef.current = window.requestAnimationFrame(syncVisibleStackedFiles);
|
|
}, [syncVisibleStackedFiles]);
|
|
|
|
React.useEffect(() => {
|
|
const scrollRoot = diffScrollRef.current;
|
|
if (!scrollRoot) return;
|
|
|
|
queueVisibleStackedFilesSync();
|
|
scrollRoot.addEventListener('scroll', queueVisibleStackedFilesSync, { passive: true });
|
|
window.addEventListener('resize', queueVisibleStackedFilesSync);
|
|
|
|
return () => {
|
|
scrollRoot.removeEventListener('scroll', queueVisibleStackedFilesSync);
|
|
window.removeEventListener('resize', queueVisibleStackedFilesSync);
|
|
if (visibleSyncFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(visibleSyncFrameRef.current);
|
|
visibleSyncFrameRef.current = null;
|
|
}
|
|
};
|
|
}, [changedFiles, expandedFiles, queueVisibleStackedFilesSync]);
|
|
|
|
const getLayoutForFile = React.useCallback((file: FileEntry): 'inline' | 'side-by-side' => {
|
|
const override = diffFileLayout[file.path];
|
|
if (override) return override;
|
|
|
|
if (diffLayoutPreference === 'inline') {
|
|
return 'inline';
|
|
}
|
|
|
|
if (diffLayoutPreference === 'side-by-side') {
|
|
return 'side-by-side';
|
|
}
|
|
|
|
const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH;
|
|
if (file.isNew || isNarrow) {
|
|
return 'inline';
|
|
}
|
|
|
|
return 'side-by-side';
|
|
}, [diffFileLayout, diffLayoutPreference, screenWidth]);
|
|
|
|
const currentLayoutForAllFiles = React.useMemo<'inline' | 'side-by-side' | null>(() => {
|
|
if (changedFiles.length === 0) return null;
|
|
return changedFiles.every((file) => getLayoutForFile(file) === 'side-by-side')
|
|
? 'side-by-side'
|
|
: 'inline';
|
|
}, [changedFiles, getLayoutForFile]);
|
|
|
|
// Ensure git status on mount
|
|
React.useEffect(() => {
|
|
if (effectiveDirectory) {
|
|
setActiveDirectory(effectiveDirectory);
|
|
void ensureStatus(effectiveDirectory, git);
|
|
}
|
|
}, [effectiveDirectory, setActiveDirectory, ensureStatus, git]);
|
|
|
|
React.useEffect(() => {
|
|
if (!effectiveDirectory) {
|
|
return;
|
|
}
|
|
|
|
return sessionEvents.onGitRefreshHint((hint) => {
|
|
if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) {
|
|
return;
|
|
}
|
|
void fetchStatus(effectiveDirectory, git);
|
|
});
|
|
}, [effectiveDirectory, fetchStatus, git]);
|
|
|
|
// Handle pending diff file from external navigation
|
|
React.useEffect(() => {
|
|
if (activeDiffScope !== 'all' && !pendingDiffScope) {
|
|
return;
|
|
}
|
|
|
|
if (pendingDiffFile) {
|
|
if (pendingDiffScope) {
|
|
setActiveDiffScope(pendingDiffScope);
|
|
}
|
|
setDisplayFile(pendingDiffFile);
|
|
setDisplayFileStaged(pendingDiffScope === 'staged' || (!pendingDiffScope && pendingDiffStaged));
|
|
setPendingDiffFile(null);
|
|
shouldPinAfterAlignRef.current = true;
|
|
pendingScrollTargetRef.current = pendingDiffFile;
|
|
expandStackedFile(pendingDiffFile);
|
|
setScrollRequestNonce((value) => value + 1);
|
|
}
|
|
}, [activeDiffScope, expandStackedFile, pendingDiffFile, pendingDiffScope, pendingDiffStaged, setPendingDiffFile]);
|
|
|
|
React.useEffect(() => {
|
|
if (activeDiffScope === 'all') {
|
|
return;
|
|
}
|
|
|
|
const normalizedTarget = targetFilePath?.trim();
|
|
if (!normalizedTarget) {
|
|
return;
|
|
}
|
|
|
|
setDisplayFile(normalizedTarget);
|
|
setDisplayFileStaged(activeDiffScope === 'staged');
|
|
|
|
shouldPinAfterAlignRef.current = true;
|
|
pendingScrollTargetRef.current = normalizedTarget;
|
|
expandStackedFile(normalizedTarget);
|
|
setScrollRequestNonce((value) => value + 1);
|
|
}, [activeDiffScope, expandStackedFile, targetFilePath]);
|
|
|
|
React.useEffect(() => {
|
|
if (!displayFile) {
|
|
return;
|
|
}
|
|
|
|
const stillExists = changedFiles.some((file) => file.path === displayFile);
|
|
if (!stillExists) {
|
|
setDisplayFile(null);
|
|
setDisplayFileStaged(false);
|
|
}
|
|
}, [changedFiles, displayFile]);
|
|
|
|
const registerSectionRef = React.useCallback((path: string, node: HTMLDivElement | null) => {
|
|
const map = fileSectionRefs.current;
|
|
if (node) {
|
|
map.set(path, node);
|
|
} else {
|
|
map.delete(path);
|
|
}
|
|
queueVisibleStackedFilesSync();
|
|
}, [queueVisibleStackedFilesSync]);
|
|
|
|
const handleStackedEntryExpandedChange = React.useCallback((path: string, expanded: boolean) => {
|
|
cancelPendingScrollAlignment();
|
|
setExpandedFiles((previous) => {
|
|
const hasPath = previous.has(path);
|
|
if (expanded === hasPath) {
|
|
return previous;
|
|
}
|
|
const next = new Set(previous);
|
|
if (expanded) {
|
|
next.add(path);
|
|
} else {
|
|
next.delete(path);
|
|
}
|
|
return next;
|
|
});
|
|
if (!expanded) {
|
|
setMountedStackedFiles((previous) => {
|
|
if (!previous.has(path)) return previous;
|
|
const next = new Set(previous);
|
|
next.delete(path);
|
|
return next;
|
|
});
|
|
}
|
|
queueVisibleStackedFilesSync();
|
|
}, [cancelPendingScrollAlignment, queueVisibleStackedFilesSync]);
|
|
|
|
const handleExpandOrCollapseAll = React.useCallback(() => {
|
|
cancelPendingScrollAlignment();
|
|
setExpandedFiles((previous) => {
|
|
if (previous.size > 0) {
|
|
return new Set();
|
|
}
|
|
return new Set(changedFiles.map((file) => file.path));
|
|
});
|
|
setMountedStackedFiles(new Set());
|
|
queueVisibleStackedFilesSync();
|
|
}, [cancelPendingScrollAlignment, changedFiles, queueVisibleStackedFilesSync]);
|
|
|
|
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
|
|
if (!currentSessionId) return;
|
|
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || '';
|
|
if (!directory) {
|
|
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
|
|
return;
|
|
}
|
|
|
|
setReviewFlowSubmitting(true);
|
|
try {
|
|
await startReviewFlow({
|
|
originalSessionID: currentSessionId,
|
|
directory,
|
|
providerID: execution.providerID,
|
|
modelID: execution.modelID,
|
|
agent: execution.agent || undefined,
|
|
variant: execution.variant || undefined,
|
|
generateHandoff: execution.generateHandoff,
|
|
returnAfterHandoffRequest: execution.generateHandoff,
|
|
autoReview: execution.autoReview,
|
|
});
|
|
setReviewDialogOpen(false);
|
|
} catch (error) {
|
|
console.error('[review-flow] failed to start review flow', error);
|
|
toast.error(error instanceof Error ? error.message : t('diffView.reviewDialog.toast.startFailed'));
|
|
} finally {
|
|
setReviewFlowSubmitting(false);
|
|
}
|
|
}, [currentSessionId, effectiveDirectory, t]);
|
|
|
|
const scrollToFile = React.useCallback((path: string): boolean => {
|
|
const node = fileSectionRefs.current.get(path);
|
|
const scrollRoot = diffScrollRef.current;
|
|
if (!node || !scrollRoot) {
|
|
return false;
|
|
}
|
|
|
|
const rootRect = scrollRoot.getBoundingClientRect();
|
|
const nodeRect = node.getBoundingClientRect();
|
|
const delta = nodeRect.top - rootRect.top;
|
|
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
|
|
scrollRoot.scrollTop = Math.min(maxTop, Math.max(0, scrollRoot.scrollTop + delta));
|
|
return true;
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const target = pendingScrollTargetRef.current;
|
|
if (!target) return;
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 20;
|
|
let cancelled = false;
|
|
|
|
const cancelPending = () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
cancelled = true;
|
|
pendingScrollTargetRef.current = null;
|
|
shouldPinAfterAlignRef.current = false;
|
|
setPinnedStackedTarget(null);
|
|
if (pendingScrollFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
|
pendingScrollFrameRef.current = null;
|
|
}
|
|
};
|
|
|
|
const tryAlign = () => {
|
|
if (cancelled) {
|
|
pendingScrollFrameRef.current = null;
|
|
return;
|
|
}
|
|
const currentTarget = pendingScrollTargetRef.current;
|
|
if (!currentTarget) {
|
|
cancelPending();
|
|
pendingScrollFrameRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const result = scrollToFile(currentTarget);
|
|
if (!result) {
|
|
attempts += 1;
|
|
if (attempts < maxAttempts) {
|
|
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
|
|
} else {
|
|
cancelPending();
|
|
pendingScrollFrameRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
|
|
setPinnedStackedTarget(currentTarget);
|
|
}
|
|
cancelPending();
|
|
};
|
|
|
|
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (pendingScrollFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(pendingScrollFrameRef.current);
|
|
pendingScrollFrameRef.current = null;
|
|
}
|
|
};
|
|
}, [pinSelectedFileHeaderToTopOnNavigate, scrollRequestNonce, scrollToFile]);
|
|
|
|
const handleSelectFile = React.useCallback((value: string) => {
|
|
void value;
|
|
}, []);
|
|
|
|
const handleSelectFileAndScroll = React.useCallback((value: string) => {
|
|
cancelPendingScrollAlignment();
|
|
|
|
setDisplayFile(value);
|
|
setDisplayFileStaged(false);
|
|
shouldPinAfterAlignRef.current = true;
|
|
pendingScrollTargetRef.current = value;
|
|
expandStackedFile(value);
|
|
setScrollRequestNonce((nonce) => nonce + 1);
|
|
scrollToFile(value);
|
|
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
|
|
|
|
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
|
|
const nextLayout: 'inline' | 'side-by-side' =
|
|
mode === 'side-by-side' ? 'side-by-side' : 'inline';
|
|
|
|
changedFiles.forEach((file) => {
|
|
setDiffFileLayout(file.path, nextLayout);
|
|
});
|
|
}, [changedFiles, setDiffFileLayout]);
|
|
|
|
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
|
|
|
|
const openFileInEditorAtChange = React.useCallback(async (filePath: string, cachedDiffData: DiffData | null) => {
|
|
if (!effectiveDirectory || !filePath) {
|
|
return;
|
|
}
|
|
|
|
setOpeningEditorFilePath(filePath);
|
|
const runtimeKey = getRuntimeKey();
|
|
try {
|
|
let targetLine: number | null = null;
|
|
|
|
if (cachedDiffData?.patch && !cachedDiffData.isBinary && !isImageFile(filePath)) {
|
|
targetLine = getFirstChangedModifiedLineFromPatch(cachedDiffData.patch);
|
|
} else if (cachedDiffData && cachedDiffData.contextMode === 'full' && !cachedDiffData.isBinary && !isImageFile(filePath)) {
|
|
targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified);
|
|
}
|
|
|
|
if (targetLine === null) {
|
|
try {
|
|
const patchResponse = await git.getGitDiff(effectiveDirectory, {
|
|
path: filePath,
|
|
staged: activeDiffStaged,
|
|
contextLines: 3,
|
|
});
|
|
targetLine = getFirstChangedModifiedLineFromPatch(patchResponse.diff);
|
|
} catch {
|
|
targetLine = null;
|
|
}
|
|
}
|
|
|
|
let diffForNavigation = cachedDiffData;
|
|
if (targetLine === null || !diffForNavigation) {
|
|
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath, staged: activeDiffStaged });
|
|
diffForNavigation = {
|
|
original: response.original ?? '',
|
|
modified: response.modified ?? '',
|
|
isBinary: response.isBinary,
|
|
};
|
|
if (!activeDiffStaged) {
|
|
setDiff(effectiveDirectory, filePath, diffForNavigation, runtimeKey);
|
|
}
|
|
}
|
|
|
|
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
|
|
? 1
|
|
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
|
|
|
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
|
|
const openValidation = await validateContextFileOpen(files, absolutePath, { directory: effectiveDirectory });
|
|
if (!openValidation.ok) {
|
|
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
|
return;
|
|
}
|
|
|
|
openContextFileAtLine(
|
|
effectiveDirectory,
|
|
absolutePath,
|
|
resolvedTargetLine,
|
|
1,
|
|
);
|
|
} finally {
|
|
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
|
|
}
|
|
}, [activeDiffStaged, effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
|
|
|
|
const renderStackedDiffView = () => {
|
|
if (!effectiveDirectory) return null;
|
|
|
|
const getFileStaged = (path: string) => {
|
|
if (forcedStaged !== null) {
|
|
return forcedStaged;
|
|
}
|
|
return displayFileStaged && path === displayFile;
|
|
};
|
|
|
|
return (
|
|
<div className={cn('flex min-w-0 flex-1 min-h-0 h-full', flushContent ? 'gap-0' : 'gap-3 px-3 pb-3 pt-2')}>
|
|
{showFileSidebar && (
|
|
<section className="hidden lg:flex w-72 flex-col rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/40">
|
|
<span className="typography-ui-header font-semibold text-foreground">{t('diffView.section.files')}</span>
|
|
<span className="typography-meta text-muted-foreground">{changedFiles.length}</span>
|
|
</div>
|
|
<FileList
|
|
changedFiles={changedFiles}
|
|
selectedFile={null}
|
|
onSelectFile={handleSelectFileAndScroll}
|
|
/>
|
|
</section>
|
|
)}
|
|
<div className="relative flex-1 min-w-0 min-h-0 h-full">
|
|
<ScrollableOverlay
|
|
ref={diffScrollRef}
|
|
outerClassName="min-h-0 h-full"
|
|
className="[overflow-anchor:none] pb-16"
|
|
disableHorizontal
|
|
observeMutations={false}
|
|
preventOverscroll
|
|
data-diff-virtual-root
|
|
>
|
|
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
|
|
{changedFiles.map((file) => (
|
|
<MultiFileDiffEntry
|
|
key={file.path}
|
|
directory={effectiveDirectory}
|
|
file={file}
|
|
layout={getLayoutForFile(file)}
|
|
wrapLines={diffWrapLines}
|
|
isSelected={false}
|
|
isExpanded={expandedFiles.has(file.path)}
|
|
isMounted={mountedStackedFiles.has(file.path) || file.path === pinnedStackedTarget}
|
|
onSelect={handleSelectFile}
|
|
onExpandedChange={handleStackedEntryExpandedChange}
|
|
registerSectionRef={registerSectionRef}
|
|
showOpenInEditorAction={showOpenInEditorAction && activeDiffScope !== 'turn'}
|
|
isOpeningInEditor={openingEditorFilePath === file.path}
|
|
onOpenInEditor={(filePath, diffData) => {
|
|
void openFileInEditorAtChange(filePath, diffData);
|
|
}}
|
|
staged={getFileStaged(file.path)}
|
|
loadFullFiles={loadFullFiles}
|
|
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
|
|
/>
|
|
))}
|
|
</div>
|
|
</ScrollableOverlay>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
|
|
if (!effectiveDirectory) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
{t('diffView.state.selectSessionDirectory')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeDiffScope !== 'turn' && isLoadingStatus && !status) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
{t('diffView.state.loadingRepositoryStatus')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeDiffScope !== 'turn' && isGitRepo === false) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
{t('diffView.state.notGitRepository')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (changedFiles.length === 0) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderStackedDiffView();
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden bg-background">
|
|
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
|
|
{!isMobile && (
|
|
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
|
<ChangeScopeSelector
|
|
scope={activeDiffScope}
|
|
workingCount={workingFileCount}
|
|
stagedCount={stagedFileCount}
|
|
turnCount={turnFileCount}
|
|
onScopeChange={(scope) => {
|
|
setActiveDiffScope(scope);
|
|
onDiffScopeChange?.(scope);
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground shrink-0">
|
|
<span className="typography-ui-label font-semibold text-foreground">
|
|
{isLoadingStatus && !status
|
|
? t('diffView.state.loadingChanges')
|
|
: (changedFiles.length === 1
|
|
? t('diffView.summary.changedFilesSingle', { count: changedFiles.length })
|
|
: t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
|
|
</span>
|
|
</div>
|
|
)
|
|
)}
|
|
{changedFiles.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleExpandOrCollapseAll}
|
|
className={cn(
|
|
'diff-toolbar__expand-button h-7 flex-shrink-0 gap-1 px-1.5 text-muted-foreground hover:text-foreground',
|
|
'ml-auto',
|
|
)}
|
|
title={expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
|
|
>
|
|
<Icon
|
|
name="expand-up-down"
|
|
className="size-4"
|
|
/>
|
|
<span className="diff-toolbar__expand-label typography-ui-label">
|
|
{expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{changedFiles.length > 0 && showReviewAction && (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => setReviewDialogOpen(true)}
|
|
disabled={reviewFlowSubmitting}
|
|
className="diff-toolbar__review-button h-7 flex-shrink-0 gap-1.5 px-2"
|
|
aria-label={t('diffView.actions.reviewAria')}
|
|
>
|
|
{reviewFlowSubmitting ? (
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
) : (
|
|
<Icon name="search-eye" className="size-4" />
|
|
)}
|
|
<span className="diff-toolbar__review-label typography-ui-label">
|
|
{t('diffView.actions.review')}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{changedFiles.length > 0 && showWalkthroughAction && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
// Carry the scope across: opening the walkthrough
|
|
// while looking at staged changes should review
|
|
// staged changes, not whatever the panel showed last.
|
|
const directory = effectiveDirectory ?? '';
|
|
requestWalkthroughSource(directory, {
|
|
kind: 'working-tree',
|
|
scope: activeDiffScope === 'staged' || activeDiffScope === 'working'
|
|
? activeDiffScope
|
|
: 'all',
|
|
});
|
|
openContextSurface(directory, 'walkthrough');
|
|
}}
|
|
className={cn('diff-toolbar__walkthrough-button h-7 flex-shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
|
aria-label={t('walkthrough.action.open')}
|
|
>
|
|
<Icon name="route" className="size-4" />
|
|
<span className="diff-toolbar__walkthrough-label typography-ui-label">
|
|
{t('walkthrough.action.open')}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{changedFiles.length > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLoadFullFiles((value) => !value)}
|
|
aria-pressed={loadFullFiles}
|
|
aria-label={loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')}
|
|
className={cn(
|
|
'h-7 w-7 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground',
|
|
loadFullFiles && 'bg-interactive-selection text-interactive-selection-foreground',
|
|
)}
|
|
>
|
|
<Icon name="file-download" className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{changedFiles.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDiffWrapLines(!diffWrapLinesStore)}
|
|
className={cn(
|
|
'h-5 w-5 p-0 transition-opacity',
|
|
diffWrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
|
)}
|
|
title={diffWrapLines ? t('diffView.actions.disableLineWrap') : t('diffView.actions.enableLineWrap')}
|
|
>
|
|
<Icon name="text-wrap" className="size-4" />
|
|
</Button>
|
|
)}
|
|
{currentLayoutForAllFiles && (
|
|
<DiffViewToggle
|
|
mode={currentLayoutForAllFiles === 'side-by-side' ? 'side-by-side' : 'unified'}
|
|
onModeChange={handleHeaderLayoutChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<ReviewFlowDialog
|
|
open={reviewDialogOpen}
|
|
onOpenChange={setReviewDialogOpen}
|
|
projectDirectory={effectiveDirectory ?? null}
|
|
submitting={reviewFlowSubmitting}
|
|
onConfirm={handleStartReviewFlow}
|
|
/>
|
|
|
|
{renderContent()}
|
|
</div>
|
|
);
|
|
};
|