Files
openchamber/packages/ui/src/components/views/git/HistoryCommitRow.tsx
T
Bohdan Triapitsyn 253094cb1d fix: stabilize history diff loading and comments
Prevents History file loading from getting stuck
Disables inline comments in History diffs
Keeps review comments available in regular diff views
2026-06-17 02:02:01 +03:00

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>
);
});