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): Map => { 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(); 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(null); const [showCreateBranch, setShowCreateBranch] = React.useState(false); const [newBranchName, setNewBranchName] = React.useState(''); const [pendingAction, setPendingAction] = React.useState(null); const [openDiffPaths, setOpenDiffPaths] = React.useState>(new Set()); const [diffCache, setDiffCache] = React.useState>(new Map()); const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState>(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((_, 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 (
  • {t('gitView.history.copySha')} {isExpanded && (
    {/* Action buttons */} {isGraphMode && pendingAction ? ( /* Confirmation banner — replaces the button row while an action is pending */
    {t(`gitView.history.actions.${pendingAction}Confirm` as never)}
    ) : isGraphMode ? (
    {showCreateBranch ? (
    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" />
    ) : ( )} {/* Reset: dropdown first to pick mode, then confirmation banner */} {(['soft', 'mixed', 'hard'] as const).map((mode) => ( { 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)} ))}
    ) : null} {isLoadingFiles ? (
    {t('gitView.history.loadingFiles')}
    ) : files.length === 0 ? (

    {t('gitView.history.noFiles')}

    ) : (
      {files.map((file) => (
    • {openDiffPaths.has(file.path) && (
      {file.changeType === 'R' ? (
      {t('gitView.history.renamedNoDiff')}
      ) : file.isBinary ? (
      {t('gitView.history.binaryNoDiff')}
      ) : (() => { const changedLines = file.insertions + file.deletions; if (!forceRenderLargePaths.has(file.path) && changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES) { return (
      {t('gitView.history.largeDiffTitle', { count: changedLines })}
      {t('gitView.history.largeDiffDescription')}
      ); } const cached = diffCache.get(file.path); if (cached === 'loading' || cached === undefined) { return
      {t('gitView.history.loadingDiff')}
      ; } if (cached === 'error') { return ( ); } return ( ); })()}
      )}
    • ))}
    )}
    )}
  • ); });