import { useState, useEffect, useMemo, useCallback } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Undo2, FilePlus, FileX, FilePen, FileText, File, ChevronDown, ChevronRight, AlertTriangle, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { FileStatus } from '@/types/electron'; interface WorktreeInfo { path: string; branch: string; isMain: boolean; hasChanges?: boolean; changedFilesCount?: number; } interface DiscardWorktreeChangesDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; onDiscarded: () => void; } interface ParsedDiffHunk { header: string; lines: { type: 'context' | 'addition' | 'deletion' | 'header'; content: string; lineNumber?: { old?: number; new?: number }; }[]; } interface ParsedFileDiff { filePath: string; hunks: ParsedDiffHunk[]; isNew?: boolean; isDeleted?: boolean; isRenamed?: boolean; } const getFileIcon = (status: string) => { switch (status) { case 'A': case '?': return ; case 'D': return ; case 'M': case 'U': return ; case 'R': case 'C': return ; default: return ; } }; const getStatusLabel = (status: string) => { switch (status) { case 'A': return 'Added'; case '?': return 'Untracked'; case 'D': return 'Deleted'; case 'M': return 'Modified'; case 'U': return 'Updated'; case 'R': return 'Renamed'; case 'C': return 'Copied'; default: return 'Changed'; } }; const getStatusBadgeColor = (status: string) => { switch (status) { case 'A': case '?': return 'bg-green-500/20 text-green-400 border-green-500/30'; case 'D': return 'bg-red-500/20 text-red-400 border-red-500/30'; case 'M': case 'U': return 'bg-amber-500/20 text-amber-400 border-amber-500/30'; case 'R': case 'C': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; default: return 'bg-muted text-muted-foreground border-border'; } }; /** * Parse unified diff format into structured data */ function parseDiff(diffText: string): ParsedFileDiff[] { if (!diffText) return []; const files: ParsedFileDiff[] = []; const lines = diffText.split('\n'); let currentFile: ParsedFileDiff | null = null; let currentHunk: ParsedDiffHunk | null = null; let oldLineNum = 0; let newLineNum = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('diff --git')) { if (currentFile) { if (currentHunk) currentFile.hunks.push(currentHunk); files.push(currentFile); } const match = line.match(/diff --git a\/(.*?) b\/(.*)/); currentFile = { filePath: match ? match[2] : 'unknown', hunks: [], }; currentHunk = null; continue; } if (line.startsWith('new file mode')) { if (currentFile) currentFile.isNew = true; continue; } if (line.startsWith('deleted file mode')) { if (currentFile) currentFile.isDeleted = true; continue; } if (line.startsWith('rename from') || line.startsWith('rename to')) { if (currentFile) currentFile.isRenamed = true; continue; } if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) { continue; } if (line.startsWith('@@')) { if (currentHunk && currentFile) currentFile.hunks.push(currentHunk); const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1; newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1; currentHunk = { header: line, lines: [{ type: 'header', content: line }], }; continue; } if (currentHunk) { if (line.startsWith('+')) { currentHunk.lines.push({ type: 'addition', content: line.substring(1), lineNumber: { new: newLineNum }, }); newLineNum++; } else if (line.startsWith('-')) { currentHunk.lines.push({ type: 'deletion', content: line.substring(1), lineNumber: { old: oldLineNum }, }); oldLineNum++; } else if (line.startsWith(' ') || line === '') { currentHunk.lines.push({ type: 'context', content: line.substring(1) || '', lineNumber: { old: oldLineNum, new: newLineNum }, }); oldLineNum++; newLineNum++; } } } if (currentFile) { if (currentHunk) currentFile.hunks.push(currentHunk); files.push(currentFile); } return files; } function DiffLine({ type, content, lineNumber, }: { type: 'context' | 'addition' | 'deletion' | 'header'; content: string; lineNumber?: { old?: number; new?: number }; }) { const bgClass = { context: 'bg-transparent', addition: 'bg-green-500/10', deletion: 'bg-red-500/10', header: 'bg-blue-500/10', }; const textClass = { context: 'text-foreground-secondary', addition: 'text-green-400', deletion: 'text-red-400', header: 'text-blue-400', }; const prefix = { context: ' ', addition: '+', deletion: '-', header: '', }; if (type === 'header') { return (
{content}
); } return (
{lineNumber?.old ?? ''} {lineNumber?.new ?? ''} {prefix[type]} {content || '\u00A0'}
); } export function DiscardWorktreeChangesDialog({ open, onOpenChange, worktree, onDiscarded, }: DiscardWorktreeChangesDialogProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // File selection state const [files, setFiles] = useState([]); const [diffContent, setDiffContent] = useState(''); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [expandedFile, setExpandedFile] = useState(null); const [isLoadingDiffs, setIsLoadingDiffs] = useState(false); // Parse diffs const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]); // Create a map of file path to parsed diff for quick lookup const diffsByFile = useMemo(() => { const map = new Map(); for (const diff of parsedDiffs) { map.set(diff.filePath, diff); } return map; }, [parsedDiffs]); // Load diffs when dialog opens useEffect(() => { if (open && worktree) { setIsLoadingDiffs(true); setFiles([]); setDiffContent(''); setSelectedFiles(new Set()); setExpandedFile(null); setError(null); let cancelled = false; const loadDiffs = async () => { try { const api = getElectronAPI(); if (api?.git?.getDiffs) { const result = await api.git.getDiffs(worktree.path); if (result.success) { const fileList = result.files ?? []; if (!cancelled) setFiles(fileList); if (!cancelled) setDiffContent(result.diff ?? ''); if (!cancelled) setSelectedFiles(new Set()); } } } catch (err) { if (cancelled) return; console.warn('Failed to load diffs for discard dialog:', err); setError(err instanceof Error ? err.message : String(err)); } finally { if (!cancelled) setIsLoadingDiffs(false); } }; loadDiffs(); return () => { cancelled = true; }; } }, [open, worktree]); const handleToggleFile = useCallback((filePath: string) => { setSelectedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); } return next; }); }, []); const handleToggleAll = useCallback(() => { setSelectedFiles((prev) => { if (prev.size === files.length) { return new Set(); } return new Set(files.map((f) => f.path)); }); }, [files]); const handleFileClick = useCallback((filePath: string) => { setExpandedFile((prev) => (prev === filePath ? null : filePath)); }, []); const handleDiscard = async () => { if (!worktree || selectedFiles.size === 0) return; setIsLoading(true); setError(null); try { const api = getHttpApiClient(); // Pass selected files if not all files are selected const filesToDiscard = selectedFiles.size === files.length ? undefined : Array.from(selectedFiles); const result = await api.worktree.discardChanges(worktree.path, filesToDiscard); if (result.success && result.result) { if (result.result.discarded) { const fileCount = filesToDiscard ? filesToDiscard.length : selectedFiles.size; toast.success('Changes discarded', { description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`, }); onDiscarded(); onOpenChange(false); } else { toast.info('No changes to discard', { description: result.result.message, }); } } else { setError(result.error || 'Failed to discard changes'); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to discard changes'); } finally { setIsLoading(false); } }; if (!worktree) return null; const allSelected = selectedFiles.size === files.length && files.length > 0; return ( Discard Changes Select which changes to discard in the{' '} {worktree.branch} worktree. This action cannot be undone.
{/* File Selection */}
{files.length > 0 && ( )}
{isLoadingDiffs ? (
Loading changes...
) : files.length === 0 ? (
No changes detected
) : (
{files.map((file) => { const isChecked = selectedFiles.has(file.path); const isExpanded = expandedFile === file.path; const fileDiff = diffsByFile.get(file.path); const additions = fileDiff ? fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length, 0 ) : 0; const deletions = fileDiff ? fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length, 0 ) : 0; return (
{/* Checkbox */} handleToggleFile(file.path)} className="flex-shrink-0" /> {/* Clickable file row to show diff */}
{/* Expanded diff view */} {isExpanded && fileDiff && (
{fileDiff.hunks.map((hunk, hunkIndex) => (
{hunk.lines.map((line, lineIndex) => ( ))}
))}
)} {isExpanded && !fileDiff && (
{file.status === '?' ? ( New file - diff preview not available ) : file.status === 'D' ? ( File deleted ) : ( Diff content not available )}
)}
); })}
)}
{/* Warning message */}

This will permanently discard the selected changes. Staged changes will be unstaged, modifications to tracked files will be reverted, and untracked files will be deleted. This action cannot be undone.

{error &&

{error}

}
); }