/** * SFTP Conflict Resolution Dialog */ import { AlertTriangle, GitMerge } from 'lucide-react'; import React, { memo, useEffect, useRef, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict'; import { Button } from '../ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; import type { FileConflictAction } from '../../domain/models'; interface ConflictItem { transferId: string; fileName: string; sourcePath: string; targetPath: string; isDirectory: boolean; existingType?: 'file' | 'directory' | 'symlink'; applyToAllCount?: number; existingSize: number; newSize: number; existingModified: number; newModified: number; } export const canReplaceConflict = (conflict: Pick): boolean => { return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType); }; export const getSftpConflictDialogPresentation = ( conflict: Pick, ) => { const isDestructiveDirectoryReplace = conflict.isDirectory && conflict.existingType === 'directory'; const descriptionKey = !conflict.isDirectory ? 'sftp.conflict.desc' : conflict.existingType === 'file' ? 'sftp.conflict.folderFileDesc' : conflict.existingType === 'symlink' ? 'sftp.conflict.folderSymlinkDesc' : conflict.existingType === 'directory' ? 'sftp.conflict.folderDesc' : 'sftp.conflict.folderUnknownDesc'; return { titleKey: conflict.isDirectory ? 'sftp.conflict.folderTitle' : 'sftp.conflict.title', descriptionKey, showFileMetadata: !conflict.isDirectory, showDirectoryReplaceWarning: isDestructiveDirectoryReplace, mergeVariant: isDestructiveDirectoryReplace ? 'default' : 'outline', replaceVariant: isDestructiveDirectoryReplace ? 'outline' : 'default', } as const; }; const getConflictTypeKey = (conflict: Pick): string => getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType); interface SftpConflictDialogProps { conflicts: ConflictItem[]; onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void; formatFileSize: (size: number) => string; } interface ConflictFileSummaryProps { title: string; sizeLabel: string; modifiedLabel: string; size: string; modified: string; } const ConflictFileSummary: React.FC = ({ title, sizeLabel, modifiedLabel, size, modified, }) => (
{title}
{sizeLabel}
{size}
{modifiedLabel}
{modified}
); const SftpConflictDialogInner: React.FC = ({ conflicts, onResolve, formatFileSize }) => { const { t } = useI18n(); const [applyToAll, setApplyToAll] = useState(false); const duplicateButtonRef = useRef(null); const mergeButtonRef = useRef(null); const replaceButtonRef = useRef(null); const previousConflictIdRef = useRef(undefined); const descriptionId = React.useId(); const directoryWarningId = React.useId(); const conflict = conflicts[0]; // Handle first conflict const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory'; const currentCanReplace = conflict ? canReplaceConflict(conflict) : false; useEffect(() => { const currentConflictId = conflict?.transferId; const previousConflictId = previousConflictIdRef.current; previousConflictIdRef.current = currentConflictId; if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return; const nextAction = currentCanMerge ? mergeButtonRef.current : currentCanReplace ? replaceButtonRef.current : duplicateButtonRef.current; if (!nextAction) return; // If the previously focused action disappears or becomes disabled, // Radix may restore focus to the first button after this effect. Focus // on the next frame so the current conflict's safe action wins. if (typeof globalThis.requestAnimationFrame === 'function') { const frame = globalThis.requestAnimationFrame(() => nextAction.focus()); return () => globalThis.cancelAnimationFrame(frame); } const timer = globalThis.setTimeout(() => nextAction.focus(), 0); return () => globalThis.clearTimeout(timer); }, [conflict?.transferId, currentCanMerge, currentCanReplace]); if (!conflict) return null; const formatDate = (timestamp: number) => { return new Date(timestamp).toLocaleString(); }; const sameTypeConflictCount = Math.max( conflict.applyToAllCount ?? 1, conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length, ); const canMerge = currentCanMerge; const canReplace = currentCanReplace; const presentation = getSftpConflictDialogPresentation(conflict); const showConflictDescription = !presentation.showDirectoryReplaceWarning; const describedBy = presentation.showDirectoryReplaceWarning ? `${descriptionId} ${directoryWarningId}` : descriptionId; const handleAction = (action: FileConflictAction) => { onResolve(conflict.transferId, action, applyToAll); setApplyToAll(false); }; return ( handleAction('skip')}> {conflict.fileName} {' '} {t('sftp.conflict.alreadyExistsSuffix')}
{t(presentation.descriptionKey)}
{presentation.showFileMetadata && (
)} {presentation.showDirectoryReplaceWarning && (

{t('sftp.conflict.folderMergeHint')}

{t('sftp.conflict.folderReplaceWarning')}

)} {sameTypeConflictCount > 1 && ( )}
{conflict.isDirectory && ( )} {canReplace && ( )}
); }; export const SftpConflictDialog = memo(SftpConflictDialogInner); SftpConflictDialog.displayName = 'SftpConflictDialog';