Prevents History file loading from getting stuck Disables inline comments in History diffs Keeps review comments available in regular diff views
681 lines
26 KiB
TypeScript
681 lines
26 KiB
TypeScript
import React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { cn } from '@/lib/utils';
|
|
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi';
|
|
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
|
import type { LanedCommit } from './gitGraph';
|
|
import { GitGraphSegment } from './GitGraphSegment';
|
|
import * as git from '@/lib/gitApi';
|
|
import { toast } from '@/components/ui/toast';
|
|
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
|
|
|
const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000;
|
|
const HISTORY_DIFF_LARGE_CHANGED_LINES = 500;
|
|
const HISTORY_DIFF_CACHE_MAX_ENTRIES = 12;
|
|
const HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 8 * 1024 * 1024;
|
|
|
|
type HistoryDiffCacheValue = CommitFileDiffResponse | 'loading' | 'error';
|
|
|
|
const getHistoryDiffCacheSize = (value: HistoryDiffCacheValue): number => {
|
|
if (typeof value === 'string') {
|
|
return 0;
|
|
}
|
|
return (value.original?.length ?? 0) + (value.modified?.length ?? 0);
|
|
};
|
|
|
|
const trimHistoryDiffCache = (cache: Map<string, HistoryDiffCacheValue>): Map<string, HistoryDiffCacheValue> => {
|
|
if (cache.size <= HISTORY_DIFF_CACHE_MAX_ENTRIES) {
|
|
let totalSize = 0;
|
|
for (const value of cache.values()) {
|
|
totalSize += getHistoryDiffCacheSize(value);
|
|
}
|
|
if (totalSize <= HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) {
|
|
return cache;
|
|
}
|
|
}
|
|
|
|
const entries = Array.from(cache.entries()).reverse();
|
|
const next = new Map<string, HistoryDiffCacheValue>();
|
|
let totalSize = 0;
|
|
for (const [key, value] of entries) {
|
|
if (next.size >= HISTORY_DIFF_CACHE_MAX_ENTRIES) {
|
|
continue;
|
|
}
|
|
const entrySize = getHistoryDiffCacheSize(value);
|
|
if (totalSize + entrySize > HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES && next.size > 0) {
|
|
continue;
|
|
}
|
|
next.set(key, value);
|
|
totalSize += entrySize;
|
|
}
|
|
|
|
return new Map(Array.from(next.entries()).reverse());
|
|
};
|
|
|
|
interface HistoryCommitRowProps {
|
|
entry: GitLogEntry;
|
|
mode?: 'history' | 'graph';
|
|
laned?: LanedCommit;
|
|
totalLanes?: number;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
files: CommitFileEntry[];
|
|
isLoadingFiles: boolean;
|
|
onCopyHash: (hash: string) => void;
|
|
directory: string | undefined;
|
|
onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void;
|
|
onActionSuccess?: () => void;
|
|
}
|
|
|
|
function formatCommitDate(date: string, timeFormatPreference: TimeFormatPreference) {
|
|
const value = new Date(date);
|
|
if (Number.isNaN(value.getTime())) {
|
|
return date;
|
|
}
|
|
|
|
return formatDateTimeForPreference(value, timeFormatPreference, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function getChangeTypeColor(changeType: string) {
|
|
switch (changeType) {
|
|
case 'A':
|
|
return 'text-[var(--status-success)]';
|
|
case 'D':
|
|
return 'text-[var(--status-error)]';
|
|
case 'M':
|
|
return 'text-[var(--status-warning)]';
|
|
case 'R':
|
|
return 'text-[var(--status-info)]';
|
|
default:
|
|
return 'text-muted-foreground';
|
|
}
|
|
}
|
|
|
|
interface RefBadge {
|
|
label: string;
|
|
isHead: boolean;
|
|
isTag: boolean;
|
|
}
|
|
|
|
function parseRefBadges(refs: string): RefBadge[] {
|
|
if (!refs) return [];
|
|
return refs
|
|
.split(',')
|
|
.map((r) => r.trim())
|
|
.filter(Boolean)
|
|
.map((r) => {
|
|
const isHead = r.startsWith('HEAD ->');
|
|
const label = isHead ? r.replace('HEAD -> ', '') : r.replace('tag: ', '');
|
|
return {
|
|
label,
|
|
isHead,
|
|
isTag: r.startsWith('tag: '),
|
|
};
|
|
});
|
|
}
|
|
|
|
export const HistoryCommitRow = React.memo(({
|
|
entry,
|
|
mode = 'history',
|
|
laned,
|
|
totalLanes,
|
|
isExpanded,
|
|
onToggle,
|
|
files,
|
|
isLoadingFiles,
|
|
onCopyHash,
|
|
directory,
|
|
onConflict,
|
|
onActionSuccess,
|
|
}: HistoryCommitRowProps) => {
|
|
const { t } = useI18n();
|
|
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
|
const isGraphMode = mode === 'graph';
|
|
type PendingAction =
|
|
| 'checkout' | 'cherryPick' | 'revert'
|
|
| 'merge' | 'rebase'
|
|
| 'resetSoft' | 'resetMixed' | 'resetHard';
|
|
|
|
const [actionLoading, setActionLoading] = React.useState<string | null>(null);
|
|
const [showCreateBranch, setShowCreateBranch] = React.useState(false);
|
|
const [newBranchName, setNewBranchName] = React.useState('');
|
|
const [pendingAction, setPendingAction] = React.useState<PendingAction | null>(null);
|
|
|
|
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
|
|
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
|
|
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(new Set());
|
|
|
|
const handleCheckout = async () => {
|
|
if (!directory) return;
|
|
setActionLoading('checkout');
|
|
try {
|
|
await git.checkoutCommit(directory, entry.hash);
|
|
toast.success(t('gitView.history.actions.detachedHead'));
|
|
onActionSuccess?.();
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateBranch = async () => {
|
|
if (!directory || !newBranchName.trim()) return;
|
|
setActionLoading('createBranch');
|
|
try {
|
|
await git.createBranch(directory, newBranchName.trim(), entry.hash);
|
|
setShowCreateBranch(false);
|
|
setNewBranchName('');
|
|
onActionSuccess?.();
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleCherryPick = async () => {
|
|
if (!directory) return;
|
|
setActionLoading('cherryPick');
|
|
try {
|
|
const result = await git.cherryPick(directory, entry.hash);
|
|
if (result.conflict) {
|
|
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'cherry-pick' });
|
|
} else {
|
|
onActionSuccess?.();
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleRevert = async () => {
|
|
if (!directory) return;
|
|
setActionLoading('revert');
|
|
try {
|
|
const result = await git.revertCommit(directory, entry.hash);
|
|
if (result.conflict) {
|
|
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'revert' });
|
|
} else {
|
|
onActionSuccess?.();
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleReset = async (mode: 'soft' | 'mixed' | 'hard', force = false) => {
|
|
if (!directory || actionLoading !== null) return;
|
|
setActionLoading('reset');
|
|
try {
|
|
await git.resetToCommit(directory, entry.hash, mode, force);
|
|
onActionSuccess?.();
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// Single confirm handler dispatches to the right action based on pendingAction
|
|
const confirmPendingAction = async () => {
|
|
if (!pendingAction) return;
|
|
const action = pendingAction;
|
|
setPendingAction(null);
|
|
switch (action) {
|
|
case 'checkout': return handleCheckout();
|
|
case 'cherryPick': return handleCherryPick();
|
|
case 'revert': return handleRevert();
|
|
case 'merge': return handleMerge();
|
|
case 'rebase': return handleRebase();
|
|
case 'resetSoft': return handleReset('soft');
|
|
case 'resetMixed': return handleReset('mixed');
|
|
case 'resetHard': return handleReset('hard', true); // force=true: user already confirmed
|
|
}
|
|
};
|
|
|
|
const handleMerge = async () => {
|
|
if (!directory) return;
|
|
setActionLoading('merge');
|
|
try {
|
|
const result = await git.merge(directory, { branch: entry.hash });
|
|
if (result.conflict) {
|
|
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'merge' });
|
|
} else {
|
|
onActionSuccess?.();
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleRebase = async () => {
|
|
if (!directory) return;
|
|
setActionLoading('rebase');
|
|
try {
|
|
const result = await git.rebase(directory, { onto: entry.hash });
|
|
if (result.conflict) {
|
|
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'rebase' });
|
|
} else {
|
|
onActionSuccess?.();
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error(String((e as Error).message));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const loadFileDiff = React.useCallback(async (file: CommitFileEntry) => {
|
|
const key = file.path;
|
|
if (!directory) {
|
|
setDiffCache(prev => new Map(prev).set(key, 'error'));
|
|
return;
|
|
}
|
|
|
|
setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, 'loading')));
|
|
try {
|
|
const fetchPromise = getCommitFileDiff(directory, entry.hash, file.path, false);
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error(`Timed out after ${HISTORY_DIFF_REQUEST_TIMEOUT_MS}ms`)), HISTORY_DIFF_REQUEST_TIMEOUT_MS);
|
|
});
|
|
const result = await Promise.race([fetchPromise, timeoutPromise]);
|
|
setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, result)));
|
|
} catch {
|
|
setDiffCache(prev => new Map(prev).set(key, 'error'));
|
|
}
|
|
}, [directory, entry.hash]);
|
|
|
|
const toggleFileDiff = React.useCallback(async (file: CommitFileEntry) => {
|
|
const key = file.path;
|
|
|
|
if (file.changeType === 'R' || file.isBinary) {
|
|
setOpenDiffPaths(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
const cached = diffCache.get(key);
|
|
const isOpen = openDiffPaths.has(key);
|
|
|
|
if (isOpen && cached && cached !== 'error') {
|
|
// Close it
|
|
setOpenDiffPaths(prev => { const next = new Set(prev); next.delete(key); return next; });
|
|
return;
|
|
}
|
|
|
|
// Open it (or re-fetch on error)
|
|
setOpenDiffPaths(prev => { const next = new Set(prev); next.add(key); return next; });
|
|
|
|
if (cached && cached !== 'error') return; // Already loaded
|
|
|
|
const changedLines = file.insertions + file.deletions;
|
|
if (changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES && !forceRenderLargePaths.has(key)) {
|
|
return;
|
|
}
|
|
|
|
await loadFileDiff(file);
|
|
}, [diffCache, forceRenderLargePaths, loadFileDiff, openDiffPaths]);
|
|
|
|
return (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className={cn(
|
|
'w-full flex items-start gap-3 px-3 py-2 text-left transition-colors',
|
|
isGraphMode
|
|
? 'hover:bg-[var(--interactive-hover)]/40'
|
|
: isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
|
|
)}
|
|
>
|
|
{isGraphMode && laned && totalLanes !== undefined ? (
|
|
<div className="-my-2 shrink-0 self-stretch">
|
|
<GitGraphSegment laned={laned} totalLanes={totalLanes} isExpanded={isExpanded} />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: 'var(--status-success)' }}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
{/* Ref badges */}
|
|
{isGraphMode ? (() => {
|
|
const badges = parseRefBadges(entry.refs);
|
|
return badges.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1 mb-0.5">
|
|
{badges.map((badge) => (
|
|
<span key={badge.label}
|
|
className={cn(
|
|
'inline-flex items-center px-1.5 py-0 typography-micro rounded font-medium',
|
|
badge.isHead
|
|
? 'bg-[var(--chart-1)] text-[var(--primary-foreground)]'
|
|
: badge.isTag
|
|
? 'bg-[var(--chart-5)] text-[var(--primary-foreground)]'
|
|
: 'bg-[var(--interactive-hover)] text-[var(--foreground)]'
|
|
)}>
|
|
{badge.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null;
|
|
})() : null}
|
|
|
|
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
|
|
{entry.message}
|
|
</p>
|
|
<div className="flex items-center gap-1 typography-meta text-muted-foreground">
|
|
<div className="flex items-center gap-1 min-w-0 truncate">
|
|
<span className="truncate min-w-[3ch]" title={entry.author_name}>
|
|
{entry.author_name}
|
|
</span>
|
|
<span className="shrink-0">·</span>
|
|
<span className="truncate min-w-0" title={formatCommitDate(entry.date, timeFormatPreference)}>
|
|
{formatCommitDate(entry.date, timeFormatPreference)}
|
|
</span>
|
|
</div>
|
|
<span className="shrink-0">·</span>
|
|
<code className="shrink-0 font-mono">
|
|
{entry.hash.slice(0, 8)}
|
|
</code>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1 shrink-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCopyHash(entry.hash);
|
|
}}
|
|
>
|
|
<Icon name="file-copy" className="size-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8}>{t('gitView.history.copySha')}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="px-3 pb-2 pl-8 border-t border-border/40">
|
|
{/* Action buttons */}
|
|
{isGraphMode && pendingAction ? (
|
|
/* Confirmation banner — replaces the button row while an action is pending */
|
|
<div className="flex items-center gap-2 py-2 border-b border-border/30 mb-2">
|
|
<span className="typography-micro text-muted-foreground flex-1 min-w-0">
|
|
{t(`gitView.history.actions.${pendingAction}Confirm` as never)}
|
|
</span>
|
|
<Button
|
|
variant="destructive" size="xs" className="h-6 shrink-0"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); void confirmPendingAction(); }}
|
|
>
|
|
{actionLoading !== null
|
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
|
: null}
|
|
{t('gitView.history.actions.confirmButton')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="xs" className="h-6 shrink-0"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction(null); }}
|
|
>
|
|
{t('gitView.history.actions.cancelButton')}
|
|
</Button>
|
|
</div>
|
|
) : isGraphMode ? (
|
|
<div className="flex flex-wrap items-center gap-1.5 py-2 border-b border-border/30 mb-2">
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction('checkout'); }}
|
|
>
|
|
{t('gitView.history.actions.checkout')}
|
|
</Button>
|
|
|
|
{showCreateBranch ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
autoFocus value={newBranchName}
|
|
onChange={(e) => setNewBranchName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void handleCreateBranch();
|
|
if (e.key === 'Escape') { setShowCreateBranch(false); setNewBranchName(''); }
|
|
}}
|
|
placeholder={t('gitView.history.actions.createBranchPlaceholder')}
|
|
className="h-6 text-xs px-2 rounded border border-border/60 bg-background min-w-0 w-32"
|
|
/>
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={!newBranchName.trim() || actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); void handleCreateBranch(); }}
|
|
>
|
|
{actionLoading === 'createBranch'
|
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
|
: null}
|
|
{t('gitView.history.actions.createBranchConfirm')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
onClick={(e) => { e.stopPropagation(); setShowCreateBranch(true); }}
|
|
>
|
|
{t('gitView.history.actions.createBranch')}
|
|
</Button>
|
|
)}
|
|
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction('cherryPick'); }}
|
|
>
|
|
{t('gitView.history.actions.cherryPick')}
|
|
</Button>
|
|
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction('revert'); }}
|
|
>
|
|
{t('gitView.history.actions.revert')}
|
|
</Button>
|
|
|
|
{/* Reset: dropdown first to pick mode, then confirmation banner */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{actionLoading === 'reset'
|
|
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
|
: null}
|
|
{t('gitView.history.actions.reset')}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="min-w-max">
|
|
{(['soft', 'mixed', 'hard'] as const).map((mode) => (
|
|
<DropdownMenuItem
|
|
key={mode}
|
|
disabled={actionLoading !== null}
|
|
onSelect={(e) => {
|
|
e.stopPropagation();
|
|
setPendingAction(`reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as PendingAction);
|
|
}}
|
|
>
|
|
{t(`gitView.history.actions.reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as never)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction('merge'); }}
|
|
>
|
|
{t('gitView.history.actions.merge')}
|
|
</Button>
|
|
|
|
<Button variant="outline" size="xs" className="h-6"
|
|
disabled={actionLoading !== null}
|
|
onClick={(e) => { e.stopPropagation(); setPendingAction('rebase'); }}
|
|
>
|
|
{t('gitView.history.actions.rebase')}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{isLoadingFiles ? (
|
|
<div className="flex items-center gap-2 py-2">
|
|
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
|
|
<span className="typography-micro text-muted-foreground">{t('gitView.history.loadingFiles')}</span>
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<p className="typography-micro text-muted-foreground py-2">{t('gitView.history.noFiles')}</p>
|
|
) : (
|
|
<ul className="space-y-0.5 py-2">
|
|
{files.map((file) => (
|
|
<li key={file.path}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFileDiff(file)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 typography-micro text-left cursor-pointer transition-colors rounded px-1',
|
|
openDiffPaths.has(file.path) ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'font-semibold w-3 text-center shrink-0',
|
|
getChangeTypeColor(file.changeType)
|
|
)}
|
|
>
|
|
{file.changeType}
|
|
</span>
|
|
<span className="truncate text-foreground min-w-0" title={file.path}>
|
|
{file.path}
|
|
</span>
|
|
{!file.isBinary && (
|
|
<span className="shrink-0">
|
|
<span style={{ color: 'var(--status-success)' }}>
|
|
+{file.insertions}
|
|
</span>
|
|
<span className="text-muted-foreground mx-0.5">/</span>
|
|
<span style={{ color: 'var(--status-error)' }}>
|
|
-{file.deletions}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{file.isBinary && (
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
{t('gitView.history.binary')}
|
|
</span>
|
|
)}
|
|
<Icon
|
|
name={openDiffPaths.has(file.path) ? 'arrow-down-s' : 'arrow-right-s'}
|
|
className="size-3 shrink-0 text-muted-foreground"
|
|
/>
|
|
</button>
|
|
|
|
{openDiffPaths.has(file.path) && (
|
|
<div className="max-h-[400px] overflow-y-auto rounded border border-border/40 mx-2 mb-1" data-diff-virtual-root data-diff-virtual-content>
|
|
{file.changeType === 'R' ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.renamedNoDiff')}</div>
|
|
) : file.isBinary ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.binaryNoDiff')}</div>
|
|
) : (() => {
|
|
const changedLines = file.insertions + file.deletions;
|
|
if (!forceRenderLargePaths.has(file.path) && changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES) {
|
|
return (
|
|
<div className="flex flex-col items-start gap-1 px-3 py-2 text-sm text-muted-foreground">
|
|
<div className="typography-ui-label font-semibold text-foreground">
|
|
{t('gitView.history.largeDiffTitle', { count: changedLines })}
|
|
</div>
|
|
<div className="typography-meta text-muted-foreground">
|
|
{t('gitView.history.largeDiffDescription')}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
className="h-6 px-0 text-primary hover:bg-transparent hover:underline"
|
|
onClick={() => {
|
|
setForceRenderLargePaths(prev => new Set(prev).add(file.path));
|
|
void loadFileDiff(file);
|
|
}}
|
|
>
|
|
{t('gitView.history.renderDiffAnyway')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const cached = diffCache.get(file.path);
|
|
if (cached === 'loading' || cached === undefined) {
|
|
return <div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.loadingDiff')}</div>;
|
|
}
|
|
if (cached === 'error') {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFileDiff(file)}
|
|
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-[var(--interactive-hover)] transition-colors"
|
|
>
|
|
{t('gitView.history.diffError')}
|
|
</button>
|
|
);
|
|
}
|
|
return (
|
|
<PierreDiffViewer
|
|
original={cached.original}
|
|
modified={cached.modified}
|
|
language={getLanguageFromExtension(file.path) || ''}
|
|
fileName={file.path}
|
|
renderSideBySide={false}
|
|
layout="inline"
|
|
enableComments={false}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
});
|