'use client'; import { memo, useMemo, useState } from 'react'; import { IconArrowBackUp } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; import { SessionPanelContent } from '@kandev/ui/pannel-session'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@kandev/ui/alert-dialog'; import { useToast } from '@/components/toast-provider'; import { useAppStore } from '@/components/state-provider'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; import { useGitOperations } from '@/hooks/use-git-operations'; import { FileDiffViewer } from '@/components/diff'; import type { FileInfo } from '@/lib/state/slices'; import type { SelectedDiff } from './task-layout'; type TaskChangesPanelProps = { selectedDiff: SelectedDiff | null; onClearSelected: () => void; /** Callback to open file in editor */ onOpenFile?: (filePath: string) => void; }; /** * Normalize diff content by handling edge cases from the backend. * * Handles: * 1. Multiple diffs concatenated with "--- Staged changes ---" separator * 2. Diffs missing headers */ function normalizeDiffContent(diffContent: string): string { if (!diffContent || typeof diffContent !== 'string') return ''; let trimmed = diffContent.trim(); if (!trimmed) return ''; // Handle concatenated diffs with "--- Staged changes ---" separator const stagedSeparator = '--- Staged changes ---'; if (trimmed.includes(stagedSeparator)) { const parts = trimmed.split(stagedSeparator); trimmed = (parts[1] || parts[0]).trim(); } return trimmed; } const TaskChangesPanel = memo(function TaskChangesPanel({ selectedDiff, onClearSelected, onOpenFile, }: TaskChangesPanelProps) { const [showDiscardDialog, setShowDiscardDialog] = useState(false); const { toast } = useToast(); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { discard, isLoading: isDiscarding } = useGitOperations(activeSessionId); // Fetch cumulative diff (all commits combined) when no specific file is selected const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff( selectedDiff ? null : activeSessionId ); const selectedDiffPath = selectedDiff?.path ?? null; // Use provided diff content (from commit or snapshot) or look up from current git status const hasProvidedContent = Boolean(selectedDiff?.content); // Convert git status files to array for display (uncommitted changes) const uncommittedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } return Object.values(gitStatus.files) as FileInfo[]; }, [gitStatus]); // Convert cumulative diff files to array (committed changes) const committedFiles = useMemo>(() => { if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) { return []; } return Object.entries(cumulativeDiff.files) .filter(([, file]) => file.diff) // Only include files with diffs .map(([path, file]) => ({ path, diff: file.diff!, additions: file.additions ?? 0, deletions: file.deletions ?? 0, })); }, [cumulativeDiff]); const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null; // Prioritize current git status for uncommitted files, use provided content only for historical diffs // This ensures that when a file is modified by the agent, we show the latest diff const selectedDiffContent = selectedFile?.diff ?? selectedDiff?.content ?? ''; const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile)); // Discard only works for uncommitted changes (not committed/historical diffs) const canDiscard = Boolean(selectedDiffPath && selectedFile && !hasProvidedContent); const hasUncommitted = uncommittedFiles.length > 0; const hasCommitted = committedFiles.length > 0; const hasAnyChanges = hasUncommitted || hasCommitted; const handleDiscardClick = () => { setShowDiscardDialog(true); }; const handleDiscardConfirm = async () => { if (!selectedDiffPath) { return; } try { const result = await discard([selectedDiffPath]); if (result.success) { toast({ title: 'Changes discarded', description: `Successfully discarded changes to ${selectedDiffPath}`, variant: 'success', }); onClearSelected(); } else { 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); } }; return (
{/* Header */}
{selectedDiffPath && ( <> Show all changes {canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'} )}
{/* Diff content */} {isSingleDiffSelected && selectedDiffContent ? ( // Render single file diff (from commit, snapshot, or uncommitted) // Use timestamp from git status to ensure re-render when file changes
) : cumulativeLoading ? (
Loading changes...
) : !hasAnyChanges ? (
No changes
) : (
{/* Uncommitted changes section */} {hasUncommitted && (
Uncommitted ({uncommittedFiles.length})
{uncommittedFiles.map((file) => { if (!file.diff) return null; const normalizedDiff = normalizeDiffContent(file.diff); const diffKey = `uncommitted-${file.path}-${normalizedDiff.length}`; return (
); })}
)} {/* Committed changes section */} {hasCommitted && (
Committed ({committedFiles.length})
{committedFiles.map((file) => { if (!file.diff) return null; const normalizedDiff = normalizeDiffContent(file.diff); const diffKey = `committed-${file.path}-${normalizedDiff.length}`; return (
{file.path} +{file.additions} {' / '} -{file.deletions}
); })}
)}
)}
{/* Discard confirmation dialog */} Discard changes? This will permanently discard all changes to{' '} {selectedDiffPath}. This action cannot be undone. Cancel Discard
); }); export { TaskChangesPanel };