import { Check, CircleNotch as Loader2, FileText, Warning as AlertTriangle } from '@phosphor-icons/react' import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import type { ConflictFileState } from '../hooks/useConflictResolver' import { cn } from '@/lib/utils' type ConflictResolutionStrategy = 'ours' | 'theirs' type ConflictResolution = NonNullable const BINARY_FILE_EXTENSIONS = [ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot', ] const RESOLUTION_LABELS: Record = { manual: 'Edited manually', ours: 'Keeping mine', theirs: 'Keeping theirs', } const RESOLUTION_LABELS_BY_VALUE = new Map( Object.entries(RESOLUTION_LABELS) as Array<[ConflictResolution, string]>, ) const RESOLUTION_SHORTCUTS: Record = { k: 'ours', t: 'theirs', } interface ConflictResolverModalProps { open: boolean fileStates: ConflictFileState[] allResolved: boolean committing: boolean error: string | null onResolveFile: (file: string, strategy: 'ours' | 'theirs') => void onOpenInEditor: (file: string) => void onCommit: () => void onClose: () => void } function isBinaryFile(file: string): boolean { const normalizedFile = file.toLowerCase() return BINARY_FILE_EXTENSIONS.some(ext => normalizedFile.endsWith(ext)) } function fileName(path: string): string { return path.split('/').pop() ?? path } function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) { if (!resolution) return null return ( {RESOLUTION_LABELS_BY_VALUE.get(resolution)} ) } function ConflictFileRow({ state, focused, onResolve, onOpenInEditor, onFocus, }: { state: ConflictFileState focused: boolean onResolve: (strategy: 'ours' | 'theirs') => void onOpenInEditor: () => void onFocus: () => void }) { const rowRef = useRef(null) const binary = isBinaryFile(state.file) const resolved = state.resolution !== null useEffect(() => { if (focused) rowRef.current?.scrollIntoView({ block: 'nearest' }) }, [focused]) return ( {fileName(state.file)} {state.resolving ? ( ) : ( <> {!binary && ( )} )} ) } function clampFocusIndex(index: number, fileCount: number): number { if (fileCount === 0) return 0 return Math.min(Math.max(index, 0), fileCount - 1) } function useConflictFocus(fileCount: number) { const [focusIdx, setFocusIdx] = useState(0) const focusIdxRef = useRef(0) const visibleFocusIdx = clampFocusIndex(focusIdx, fileCount) const syncFocusIdx = useCallback((nextIndex: number) => { const clampedIndex = clampFocusIndex(nextIndex, fileCount) setFocusIdx(clampedIndex) focusIdxRef.current = clampedIndex }, [fileCount]) const moveFocus = useCallback((offset: number) => { const currentIndex = clampFocusIndex(focusIdxRef.current, fileCount) syncFocusIdx(currentIndex + offset) }, [fileCount, syncFocusIdx]) return { focusIdx: visibleFocusIdx, focusIdxRef, moveFocus, syncFocusIdx, } } function hasCommandModifier(event: KeyboardEvent): boolean { return event.metaKey || event.ctrlKey } function isNextRowKey(event: KeyboardEvent): boolean { if (event.key === 'ArrowDown') return true return event.key === 'Tab' && !event.shiftKey } function isPreviousRowKey(event: KeyboardEvent): boolean { if (event.key === 'ArrowUp') return true return event.key === 'Tab' && event.shiftKey } function handleNavigationKey(event: KeyboardEvent, moveFocus: (offset: number) => void): boolean { if (isNextRowKey(event)) { event.preventDefault() moveFocus(1) return true } if (isPreviousRowKey(event)) { event.preventDefault() moveFocus(-1) return true } return false } function handleResolutionShortcut( event: KeyboardEvent, file: ConflictFileState | undefined, onResolveFile: ConflictResolverModalProps['onResolveFile'], ): boolean { const strategy = RESOLUTION_SHORTCUTS[event.key.toLowerCase()] if (!strategy || !file || file.resolving || hasCommandModifier(event)) return false event.preventDefault() onResolveFile(file.file, strategy) return true } function handleOpenShortcut( event: KeyboardEvent, file: ConflictFileState | undefined, onOpenInEditor: ConflictResolverModalProps['onOpenInEditor'], ): boolean { if (event.key.toLowerCase() !== 'o' || !file || file.resolving || hasCommandModifier(event)) return false if (isBinaryFile(file.file)) return false event.preventDefault() onOpenInEditor(file.file) return true } function handleCommitShortcut({ allResolved, committing, event, onCommit, }: { allResolved: boolean committing: boolean event: KeyboardEvent onCommit: ConflictResolverModalProps['onCommit'] }): boolean { if (event.key !== 'Enter' || !allResolved || committing) return false event.preventDefault() onCommit() return true } function ConflictDialogHeader({ fileCount }: { fileCount: number }) { return (
Resolve Merge Conflicts
{fileCount} file{fileCount !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
) } function ConflictFileList({ fileStates, focusIdx, onFocusRow, onOpenInEditor, onResolveFile, }: { fileStates: ConflictFileState[] focusIdx: number onFocusRow: (index: number) => void onOpenInEditor: (file: string) => void onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void }) { return ( {fileStates.map((state, index) => ( onResolveFile(state.file, strategy)} onOpenInEditor={() => onOpenInEditor(state.file)} onFocus={() => onFocusRow(index)} /> ))}
) } function CommitButtonContent({ committing }: { committing: boolean }) { if (!committing) return 'Commit & continue' return ( <> Committing… ) } function ConflictDialogFooter({ allResolved, committing, onClose, onCommit, }: { allResolved: boolean committing: boolean onClose: () => void onCommit: () => void }) { return ( K = keep mine · T = keep theirs · O = open · Enter = commit
) } export function ConflictResolverModal({ open, onClose, ...contentProps }: ConflictResolverModalProps) { return ( { if (!isOpen) onClose() }}> {open ? ( ) : null} ) } function ConflictResolverDialogContent({ fileStates, allResolved, committing, error, onResolveFile, onOpenInEditor, onCommit, onClose, }: ConflictResolverModalProps) { const { focusIdx, focusIdxRef, moveFocus, syncFocusIdx, } = useConflictFocus(fileStates.length) const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose() return } if (handleNavigationKey(e, moveFocus)) return const focusedIndex = clampFocusIndex(focusIdxRef.current, fileStates.length) const file = fileStates.at(focusedIndex) if (handleResolutionShortcut(e, file, onResolveFile)) return if (handleOpenShortcut(e, file, onOpenInEditor)) return handleCommitShortcut({ allResolved, committing, event: e, onCommit }) }, [allResolved, committing, fileStates, focusIdxRef, moveFocus, onClose, onCommit, onOpenInEditor, onResolveFile]) return ( {error && (

{error}

)}
) }