'use client'; import { memo, useMemo, useState, useCallback, useEffect } from 'react'; import { PanelRoot, PanelBody } from './panel-primitives'; import { IconArrowBackUp, IconExternalLink, IconPlus, IconMinus, IconCheck, IconChevronRight, IconGitCommit, IconLoader2, IconColumns, } from '@tabler/icons-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@kandev/ui/alert-dialog'; import { LineStat } from '@/components/diff-stat'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; import { useGitOperations } from '@/hooks/use-git-operations'; import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; import { hashDiff, normalizeDiffContent } from '@/components/review/types'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { FileInfo } from '@/lib/state/store'; import { useToast } from '@/components/toast-provider'; import { usePanelActions } from '@/hooks/use-panel-actions'; import { FileStatusIcon } from './file-status-icon'; const splitPath = (path: string) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) return { folder: '', file: path }; return { folder: path.slice(0, lastSlash), file: path.slice(lastSlash + 1), }; }; type ChangesPanelProps = { onSelectDiff: (path: string, content?: string) => void; }; const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelProps) { const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const gitOps = useGitOperations(activeSessionId ?? null); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId); // State for commit diffs const [expandedCommit, setExpandedCommit] = useState(null); const [commitDiffs, setCommitDiffs] = useState>>({}); const [loadingCommitSha, setLoadingCommitSha] = useState(null); const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); // Fetch commit diff const fetchCommitDiff = useCallback(async (commitSha: string) => { if (!activeSessionId || commitDiffs[commitSha]) return; setLoadingCommitSha(commitSha); try { const ws = getWebSocketClient(); if (!ws) return; const response = await ws.request<{ success: boolean; files: Record }>( 'session.commit_diff', { session_id: activeSessionId, commit_sha: commitSha }, 10000 ); if (response?.success && response.files) { setCommitDiffs((prev) => ({ ...prev, [commitSha]: response.files })); } } catch (err) { toast({ title: 'Failed to load commit diff', description: err instanceof Error ? err.message : 'An unexpected error occurred', variant: 'error', }); } finally { setLoadingCommitSha(null); } }, [activeSessionId, commitDiffs, toast]); // Toggle commit expansion const toggleCommit = useCallback((commitSha: string) => { if (expandedCommit === commitSha) { setExpandedCommit(null); } else { setExpandedCommit(commitSha); void fetchCommitDiff(commitSha); } }, [expandedCommit, fetchCommitDiff]); // Handle discard changes const handleDiscardClick = useCallback((filePath: string) => { setFileToDiscard(filePath); setShowDiscardDialog(true); }, []); const handleDiscardConfirm = useCallback(async () => { if (!fileToDiscard) return; try { const result = await gitOps.discard([fileToDiscard]); if (!result.success) { toast({ title: 'Failed to discard changes', description: result.error || 'An unknown error occurred', variant: 'error', }); } } catch (error) { toast({ title: 'Failed to discard changes', description: error instanceof Error ? error.message : 'An unknown error occurred', variant: 'error', }); } finally { setShowDiscardDialog(false); setFileToDiscard(null); } }, [fileToDiscard, gitOps, toast]); // Convert git status files to array for display const changedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return (Object.values(gitStatus.files) as FileInfo[]).map((file: FileInfo) => ({ path: file.path, status: file.status, staged: file.staged, plus: file.additions, minus: file.deletions, oldPath: file.old_path, })); }, [gitStatus]); // Compute review progress across all files (uncommitted + committed) const { reviewedCount, totalFileCount } = useMemo(() => { const paths = new Set(); // Uncommitted if (gitStatus?.files) { for (const [path, file] of Object.entries(gitStatus.files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ''; if (diff) paths.add(path); } } // Committed (skip duplicates) if (cumulativeDiff?.files) { for (const [path, file] of Object.entries(cumulativeDiff.files)) { if (paths.has(path)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ''; if (diff) paths.add(path); } } let reviewed = 0; for (const path of paths) { const state = reviews.get(path); if (!state?.reviewed) continue; // Check staleness: find the diff content for this path let diffContent = ''; const uncommittedFile = gitStatus?.files?.[path]; if (uncommittedFile?.diff) { diffContent = normalizeDiffContent(uncommittedFile.diff); } else if (cumulativeDiff?.files?.[path]?.diff) { diffContent = normalizeDiffContent(cumulativeDiff.files[path].diff!); } if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) { continue; // stale — don't count } reviewed++; } return { reviewedCount: reviewed, totalFileCount: paths.size }; }, [gitStatus, cumulativeDiff, reviews]); const reviewProgressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0; // Clear pending stage/unstage spinners when git status updates useEffect(() => { if (pendingStageFiles.size > 0) { setPendingStageFiles(new Set()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedFiles]); const handleStage = useCallback(async (path: string) => { setPendingStageFiles((prev) => new Set(prev).add(path)); try { await gitOps.stage([path]); } catch { setPendingStageFiles((prev) => { const next = new Set(prev); next.delete(path); return next; }); } }, [gitOps]); const handleUnstage = useCallback(async (path: string) => { setPendingStageFiles((prev) => new Set(prev).add(path)); try { await gitOps.unstage([path]); } catch { setPendingStageFiles((prev) => { const next = new Set(prev); next.delete(path); return next; }); } }, [gitOps]); // Open file in editor panel const { openFile: panelOpenFile } = usePanelActions(); const handleOpenFileInDocumentPanel = useCallback((path: string) => { if (!activeSessionId) return; panelOpenFile(path); }, [activeSessionId, panelOpenFile]); return (
{/* Uncommitted changes section */} {changedFiles.length > 0 && (
Uncommitted ({changedFiles.length})
    {changedFiles.map((file) => { const { folder, file: name } = splitPath(file.path); return (
  • onSelectDiff(file.path)} >
    {pendingStageFiles.has(file.path) ? (
    ) : file.staged ? ( ) : ( )}
    Discard changes Open side-by-side Open in external editor
  • ); })}
)} {/* Commits section */} {commits.length > 0 && (
Commits ({commits.length})
    {commits.map((commit) => { const isExpanded = expandedCommit === commit.commit_sha; const files = commitDiffs[commit.commit_sha]; const isLoading = loadingCommitSha === commit.commit_sha; return (
  • {isExpanded && files && (
      {Object.entries(files).map(([path, file]) => { const { folder, file: fileName } = splitPath(path); return (
    • onSelectDiff(path, file.diff)} > {folder && {folder}/} {fileName} +{file.additions ?? 0} {'/'} -{file.deletions ?? 0}
    • ); })}
    )}
  • ); })}
)} {/* Empty state */} {changedFiles.length === 0 && commits.length === 0 && (
Your changed files will appear here
)}
{/* Review progress — pushed to bottom of panel */} {totalFileCount > 0 && (
window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))} >
{reviewedCount}/{totalFileCount} reviewed
{reviewedCount} of {totalFileCount} files reviewed )} {/* Discard confirmation dialog */} Discard changes? This will permanently discard all changes to{' '} {fileToDiscard}. This action cannot be undone. Cancel Discard ); }); export { ChangesPanel };