'use client'; import { memo, useMemo, useState } from 'react'; import { DiffModeEnum, DiffView } from '@git-diff-view/react'; import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows, } from '@tabler/icons-react'; import { useTheme } from 'next-themes'; 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 { cn } from '@/lib/utils'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; 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 type { FileInfo } from '@/lib/state/slices'; import type { SelectedDiff } from './task-layout'; type TaskChangesPanelProps = { selectedDiff: SelectedDiff | null; onClearSelected: () => void; }; const DEFAULT_DIFF_MODE: 'unified' | 'split' = 'unified'; /** * Normalize diff content for the @git-diff-view/react library. * * Handles two edge cases from the backend: * 1. Multiple diffs concatenated with "--- Staged changes ---" separator * (we take the staged diff as it's what matters for the commit) * 2. Diffs missing the "diff --git" header line (for untracked files) * * The library expects each hunk to be a complete diff string with headers. */ function parseDiffIntoHunks(diffContent: string, filePath?: string): string[] { if (!diffContent || typeof diffContent !== 'string') return []; let trimmed = diffContent.trim(); if (!trimmed) return []; // Handle concatenated diffs with "--- Staged changes ---" separator // Take the staged diff (after the separator) as it's the authoritative one const stagedSeparator = '--- Staged changes ---'; if (trimmed.includes(stagedSeparator)) { const parts = trimmed.split(stagedSeparator); // Use the staged diff (second part) if available trimmed = (parts[1] || parts[0]).trim(); } // If diff is missing the "diff --git" header, add it // This happens for untracked files where backend generates incomplete diff if (!trimmed.startsWith('diff --git') && trimmed.includes('@@')) { const path = filePath || 'file'; const header = `diff --git a/${path} b/${path}\nnew file mode 100644\nindex 0000000..0000000\n`; trimmed = header + trimmed; } // Return as single hunk - the library handles parsing internally return [trimmed]; } const TaskChangesPanel = memo(function TaskChangesPanel({ selectedDiff, onClearSelected, }: TaskChangesPanelProps) { const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() => getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE) ); const { resolvedTheme } = useTheme(); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(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 diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null; // Use provided content if available, otherwise fall back to git status const selectedDiffContent = selectedDiff?.content ?? selectedFile?.diff ?? ''; const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile)); // Helper to get file language from extension const getFileLang = (path: string) => { const ext = path.split('.').pop() || ''; const langMap: Record = { ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', py: 'python', go: 'go', rs: 'rust', java: 'java', cpp: 'cpp', c: 'c', css: 'css', html: 'html', json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml', }; return langMap[ext] || 'plaintext'; }; const hasUncommitted = uncommittedFiles.length > 0; const hasCommitted = committedFiles.length > 0; const hasAnyChanges = hasUncommitted || hasCommitted; return (
{/* Header toolbar */}
{selectedDiffPath ?? 'All files'}
{selectedDiffPath && ( Show all uncommitted changes )} Copy diff Discard changes
Unified view Split view
{/* Diff content */} {isSingleDiffSelected && selectedDiffContent ? ( // Render single file diff (from commit, snapshot, or uncommitted) // Key includes content length to force re-render when diff updates
) : cumulativeLoading ? (
Loading changes...
) : !hasAnyChanges ? (
No changes
) : (
{/* Uncommitted changes section */} {hasUncommitted && (
Uncommitted ({uncommittedFiles.length})
{uncommittedFiles.map((file) => { if (!file.diff) return null; const fileLang = getFileLang(file.path); // Include diff length in key to force re-render when content changes const diffKey = `uncommitted-${file.path}-${file.diff.length}`; return (
{file.path} +{file.additions} {' / '} -{file.deletions}
); })}
)} {/* Committed changes section */} {hasCommitted && (
Committed ({committedFiles.length})
{committedFiles.map((file) => { if (!file.diff) return null; const fileLang = getFileLang(file.path); // Include diff length in key to force re-render when content changes const diffKey = `committed-${file.path}-${file.diff.length}`; return (
{file.path} +{file.additions} {' / '} -{file.deletions}
); })}
)}
)}
); }); export { TaskChangesPanel };