From a1a1971a1f8e3358e1a98692c29ed94b051127a1 Mon Sep 17 00:00:00 2001 From: backuppc Date: Tue, 20 Jan 2026 17:29:57 +0900 Subject: [PATCH] Implement Transfer Queue Enhancements, Smart Queue Clearing, and File Conflict Resolution --- App.tsx | 341 ++++++++++++++++++++++++++++------- components/ConflictModal.tsx | 104 +++++++++++ components/TransferQueue.tsx | 90 +++++++-- types.ts | 2 + 4 files changed, 456 insertions(+), 81 deletions(-) create mode 100644 components/ConflictModal.tsx diff --git a/App.tsx b/App.tsx index 0ea36d1..f62cd4a 100644 --- a/App.tsx +++ b/App.tsx @@ -8,6 +8,7 @@ import SettingsModal from './components/SettingsModal'; import SiteManagerModal from './components/SiteManagerModal'; import HelpModal from './components/HelpModal'; import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals'; +import ConflictModal from './components/ConflictModal'; import { formatBytes } from './utils/formatters'; const App: React.FC = () => { @@ -39,10 +40,16 @@ const App: React.FC = () => { const [savedSites, setSavedSites] = useState([]); // Modals State - const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | null>(null); + const [activeModal, setActiveModal] = useState<'create' | 'rename' | 'delete' | 'settings' | null>(null); const [modalTargetIsLocal, setModalTargetIsLocal] = useState(true); const [renameTargetName, setRenameTargetName] = useState(''); + // Conflict Resolution State + const [conflictQueue, setConflictQueue] = useState<{ source: FileItem, target: FileItem, direction: 'upload' | 'download' }[]>([]); + const [isConflictModalOpen, setIsConflictModalOpen] = useState(false); + const [activeConflict, setActiveConflict] = useState<{ source: FileItem, target: FileItem, direction: 'upload' | 'download' } | null>(null); + const [conflictResolution, setConflictResolution] = useState<'overwrite' | 'skip' | null>(null); + // Local File System const [local, setLocal] = useState({ path: localStorage.getItem('last_local_path') || '', @@ -215,6 +222,21 @@ const App: React.FC = () => { } break; + case 'error': + addLog('error', data.message); + setConnection(prev => ({ ...prev, connecting: false })); + + // Check for disconnection messages + if ( + data.message.includes('FTP 연결이 끊어져 있습니다') || + data.message.includes('NOT_CONNECTED') || + data.message.includes('closed') + ) { + setConnection(prev => ({ ...prev, connected: false, connecting: false })); + setRemote(prev => ({ ...prev, files: [], path: '/' })); + } + break; + case 'list': const sortedRemoteFiles = data.files .map((f: any) => ({ @@ -292,7 +314,7 @@ const App: React.FC = () => { case 'transfer_success': setQueue(prev => prev.map(item => { if (item.id === data.id) { - return { ...item, progress: 100, status: 'completed', speed: 'Completed' }; + return { ...item, progress: 100, status: 'completed', speed: 'Completed', completedAt: Date.now() }; } return item; })); @@ -309,7 +331,7 @@ const App: React.FC = () => { case 'transfer_error': setQueue(prev => prev.map(item => { if (item.id === data.id) { - return { ...item, status: 'failed', speed: data.message }; + return { ...item, status: 'failed', speed: data.message, completedAt: Date.now() }; } return item; })); @@ -409,71 +431,156 @@ const App: React.FC = () => { if (wsRef.current) wsRef.current.send(JSON.stringify({ command: 'LOCAL_LIST', path })); }; + const executeTransfer = (file: FileItem, direction: 'upload' | 'download', transferId: string) => { + // Determine paths based on current refs (assuming context hasn't changed too much, or we validly want current dir) + // Ideally we should store paths in the conflict object. For now this uses current view's path. + const localP = localRef.current.path; + const remoteP = remoteRef.current.path; + + const localSep = localP.includes('\\') ? '\\' : '/'; + const localClean = localP.endsWith(localSep) ? localP : localP + localSep; + const localFullPath = localClean + file.name; + + const remoteSep = remoteP === '/' ? '' : '/'; + const remoteFullPath = `${remoteP}${remoteSep}${file.name}`; + + if (direction === 'upload') { + addLog('command', `UPLOAD ${file.name} -> ${remoteFullPath}`); + wsRef.current?.send(JSON.stringify({ + command: 'UPLOAD', + localPath: localFullPath, + remotePath: remoteFullPath, + transferId + })); + } else { + addLog('command', `DOWNLOAD ${file.name} -> ${localFullPath}`); + wsRef.current?.send(JSON.stringify({ + command: 'DOWNLOAD', + localPath: localFullPath, + remotePath: remoteFullPath, + transferId + })); + } + }; + const handleDownload = () => { if (!connection.connected || selectedRemoteIds.size === 0) return; - selectedRemoteIds.forEach(id => { - const file = remote.files.find(f => f.id === id); - if (file && file.type === FileType.FILE) { - const transferId = `down-${Date.now()}-${Math.random()}`; - const separator = local.path.includes('\\') ? '\\' : '/'; - const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator; - const localTarget = cleanPath + file.name; - const remoteTarget = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`; + // Filter and Process + const selectedFiles = remote.files.filter(f => selectedRemoteIds.has(f.id)); + const conflicts: { source: FileItem, target: FileItem, direction: 'download' }[] = []; + const safeTransfers: FileItem[] = []; - // Add to Queue - setQueue(prev => [...prev, { - id: transferId, - direction: 'download', - filename: file.name, - progress: 0, - status: 'queued', - speed: 'Pending...' - }]); + selectedFiles.forEach(file => { + if (file.type !== FileType.FILE) return; - addLog('command', `DOWNLOAD ${file.name} -> ${localTarget}`); - wsRef.current?.send(JSON.stringify({ - command: 'DOWNLOAD', - localPath: localTarget, - remotePath: remoteTarget, - transferId - })); + // Check local for conflict + const existing = local.files.find(f => f.name === file.name); + if (existing) { + conflicts.push({ source: file, target: existing, direction: 'download' }); + } else { + safeTransfers.push(file); } }); + + // Process Safe + safeTransfers.forEach(file => { + const transferId = `down-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + setQueue(prev => [...prev, { + id: transferId, + direction: 'download', + filename: file.name, + progress: 0, + status: 'queued', + speed: 'Pending...', + requestedAt: Date.now() + }]); + executeTransfer(file, 'download', transferId); + }); + + // Process Conflicts + if (conflicts.length > 0) { + if (conflictResolution === 'overwrite') { + conflicts.forEach(c => { + const transferId = `down-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + setQueue(prev => [...prev, { + id: transferId, + direction: 'download', + filename: c.source.name, + progress: 0, + status: 'queued', + speed: 'Pending...', + requestedAt: Date.now() + }]); + executeTransfer(c.source, 'download', transferId); + }); + } else if (conflictResolution === 'skip') { + // Do nothing + } else { + // Ask user + setConflictQueue(prev => [...prev, ...conflicts]); + } + } + setSelectedRemoteIds(new Set()); }; const handleUpload = () => { if (!connection.connected || selectedLocalIds.size === 0) return; - selectedLocalIds.forEach(id => { - const file = local.files.find(f => f.id === id); - if (file && file.type === FileType.FILE) { - const transferId = `up-${Date.now()}-${Math.random()}`; - const separator = local.path.includes('\\') ? '\\' : '/'; - const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator; - const localSource = cleanPath + file.name; - const remoteTarget = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`; + const selectedFiles = local.files.filter(f => selectedLocalIds.has(f.id)); + const conflicts: { source: FileItem, target: FileItem, direction: 'upload' }[] = []; + const safeTransfers: FileItem[] = []; - // Add to Queue - setQueue(prev => [...prev, { - id: transferId, - direction: 'upload', - filename: file.name, - progress: 0, - status: 'queued', - speed: 'Pending...' - }]); + selectedFiles.forEach(file => { + if (file.type !== FileType.FILE) return; - addLog('command', `UPLOAD ${file.name} -> ${remoteTarget}`); - wsRef.current?.send(JSON.stringify({ - command: 'UPLOAD', - localPath: localSource, - remotePath: remoteTarget, - transferId - })); + const existing = remote.files.find(f => f.name === file.name); + if (existing) { + conflicts.push({ source: file, target: existing, direction: 'upload' }); + } else { + safeTransfers.push(file); } }); + + // Safe + safeTransfers.forEach(file => { + const transferId = `up-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + setQueue(prev => [...prev, { + id: transferId, + direction: 'upload', + filename: file.name, + progress: 0, + status: 'queued', + speed: 'Pending...', + requestedAt: Date.now() + }]); + executeTransfer(file, 'upload', transferId); + }); + + // Conflicts + if (conflicts.length > 0) { + if (conflictResolution === 'overwrite') { + conflicts.forEach(c => { + const transferId = `up-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + setQueue(prev => [...prev, { + id: transferId, + direction: 'upload', + filename: c.source.name, + progress: 0, + status: 'queued', + speed: 'Pending...', + requestedAt: Date.now() + }]); + executeTransfer(c.source, 'upload', transferId); + }); + } else if (conflictResolution === 'skip') { + // Skip + } else { + setConflictQueue(prev => [...prev, ...conflicts]); + } + } + setSelectedLocalIds(new Set()); }; @@ -571,31 +678,35 @@ const App: React.FC = () => { const handleDeleteConfirm = () => { if (modalTargetIsLocal) { const ids = selectedLocalIds; - addLog('command', `LOCAL_DELE [${ids.size} items]`); + const fileNames = Array.from(ids).map(id => localRef.current.files.find(f => f.id === id)?.name).filter(Boolean).join(', '); + addLog('command', `LOCAL_DELE ${fileNames}`); + if (wsRef.current) { - const separator = local.path.includes('\\') ? '\\' : '/'; - const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator; + const separator = localRef.current.path.includes('\\') ? '\\' : '/'; + const cleanPath = localRef.current.path.endsWith(separator) ? localRef.current.path : localRef.current.path + separator; ids.forEach(id => { - const file = local.files.find(f => f.id === id); + const file = localRef.current.files.find(f => f.id === id); if (file) { const targetPath = cleanPath + file.name; wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath })); } }); setTimeout(() => { - wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path })); - }, 500 + (ids.size * 50)); // Delay a bit more for multiple deletes + wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path })); + }, 500 + (ids.size * 50)); } setSelectedLocalIds(new Set()); } else { const ids = selectedRemoteIds; - addLog('command', `DELE [${ids.size} items]`); + const fileNames = Array.from(ids).map(id => remoteRef.current.files.find(f => f.id === id)?.name).filter(Boolean).join(', '); + addLog('command', `DELE ${fileNames}`); + if (wsRef.current) { ids.forEach(id => { - const file = remote.files.find(f => f.id === id); + const file = remoteRef.current.files.find(f => f.id === id); if (file) { - const targetPath = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`; + const targetPath = remoteRef.current.path === '/' ? `/${file.name}` : `${remoteRef.current.path}/${file.name}`; wsRef.current.send(JSON.stringify({ command: 'DELE', path: targetPath, @@ -604,7 +715,7 @@ const App: React.FC = () => { } }); setTimeout(() => { - wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remote.path })); + wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path })); }, 500 + (ids.size * 50)); } setSelectedRemoteIds(new Set()); @@ -612,7 +723,101 @@ const App: React.FC = () => { setActiveModal(null); }; + + + + + // Conflict Processing + useEffect(() => { + if (conflictQueue.length > 0 && !activeConflict && !isConflictModalOpen) { + // Take first + const next = conflictQueue[0]; + setActiveConflict(next); + setIsConflictModalOpen(true); + } + }, [conflictQueue, activeConflict, isConflictModalOpen]); + + const handleConflictOverwrite = (applyToAll: boolean) => { + if (!activeConflict) return; + + const { source, direction } = activeConflict; + + // Execute the transfer + const transferId = `${direction === 'upload' ? 'up' : 'down'}-${Date.now()}-${source.name}`; + setQueue(prev => [...prev, { + id: transferId, + direction, + filename: source.name, + progress: 0, + status: 'queued', + speed: '-', + requestedAt: Date.now() + }]); + executeTransfer(source, direction, transferId); + + // Handle Apply to All + if (applyToAll) { + setConflictResolution('overwrite'); + // Process remaining immediately + const remaining = conflictQueue.slice(1); + remaining.forEach(c => { + const tid = `${c.direction === 'upload' ? 'up' : 'down'}-${Date.now()}-${c.source.name}`; + setQueue(prev => [...prev, { + id: tid, + direction: c.direction, + filename: c.source.name, + progress: 0, + status: 'queued', + speed: '-', + requestedAt: Date.now() + }]); + executeTransfer(c.source, c.direction, tid); + }); + setConflictQueue([]); + } else { + setConflictQueue(prev => prev.slice(1)); + } + + setIsConflictModalOpen(false); + setActiveConflict(null); + }; + + const handleConflictSkip = (applyToAll: boolean) => { + if (!activeConflict) return; + + if (applyToAll) { + setConflictResolution('skip'); + setConflictQueue([]); + } else { + setConflictQueue(prev => prev.slice(1)); + } + + setIsConflictModalOpen(false); + setActiveConflict(null); + }; + + const handleCancelQueue = () => { + // Keep only completed/failed items + setQueue(prev => prev.filter(item => item.status === 'completed' || item.status === 'failed')); + }; + + const handleClearCompleted = () => { + // Keep only queued/transferring items + setQueue(prev => prev.filter(item => item.status === 'queued' || item.status === 'transferring')); + }; + + const lastConnectionRef = useRef<{ host: string; user: string } | null>(null); + const handleSiteConnect = (site: SiteConfig) => { + // Check if connecting to a different server + const newConnectionKey = `${site.host}:${site.user}`; + const oldConnectionKey = lastConnectionRef.current ? `${lastConnectionRef.current.host}:${lastConnectionRef.current.user}` : null; + + if (newConnectionKey !== oldConnectionKey) { + setQueue([]); // Clear queue for new server + } + lastConnectionRef.current = { host: site.host, user: site.user }; + // 1. Update State setConnection({ host: site.host, @@ -704,6 +909,14 @@ const App: React.FC = () => { onClose={() => setActiveModal(null)} onConfirm={handleDeleteConfirm} /> + handleConflictSkip(false)} // Treat close as skip single + /> {/* Header */}
@@ -897,7 +1110,11 @@ const App: React.FC = () => { {/* Queue */}
- +
{/* Footer */} diff --git a/components/ConflictModal.tsx b/components/ConflictModal.tsx new file mode 100644 index 0000000..f98cfde --- /dev/null +++ b/components/ConflictModal.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, X, Check, ArrowRight } from 'lucide-react'; +import { formatBytes } from '../utils/formatters'; + +interface ConflictModalProps { + isOpen: boolean; + sourceFile: { name: string; size: number; date: string }; + targetFile: { name: string; size: number; date: string }; + onOverwrite: (applyToAll: boolean) => void; + onSkip: (applyToAll: boolean) => void; + onClose: () => void; // Usually acts as cancel/skip logic if forced closed +} + +const ConflictModal: React.FC = ({ + isOpen, + sourceFile, + targetFile, + onOverwrite, + onSkip, + onClose +}) => { + const [applyToAll, setApplyToAll] = useState(false); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +

파일 중복 확인 (File Conflict)

+
+ +
+ + {/* Content */} +
+

+ 대상 위치에 동일한 이름의 파일이 이미 존재합니다. 어떻게 처리하시겠습니까? +

+ +
+ {/* Target (Existing) */} +
+
+ 기존 파일 (Target) + {targetFile.name} +
+ {formatBytes(targetFile.size)} + {new Date(targetFile.date).toLocaleString()} +
+
+ + {/* Source (New) */} +
+ 새 파일 (Source) + {sourceFile.name} +
+ {formatBytes(sourceFile.size)} + {new Date(sourceFile.date).toLocaleString()} +
+
+
+
+ +
+ setApplyToAll(e.target.checked)} + className="rounded border-slate-300 text-blue-600 focus:ring-blue-500" + /> + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default ConflictModal; diff --git a/components/TransferQueue.tsx b/components/TransferQueue.tsx index 322d764..47190dc 100644 --- a/components/TransferQueue.tsx +++ b/components/TransferQueue.tsx @@ -1,34 +1,78 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useState } from 'react'; import { TransferItem } from '../types'; -import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { ArrowUp, ArrowDown, CheckCircle, XCircle, Clock, Trash2 } from 'lucide-react'; interface TransferQueueProps { queue: TransferItem[]; + onCancelAll: () => void; + onClearCompleted: () => void; } -const TransferQueue: React.FC = ({ queue }) => { +const TransferQueue: React.FC = ({ queue, onCancelAll, onClearCompleted }) => { + const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active'); + + const filteredQueue = queue.filter(item => { + if (activeTab === 'active') { + return item.status === 'queued' || item.status === 'transferring'; + } else { + return item.status === 'completed' || item.status === 'failed'; + } + }); + return (
-
- 전송 대기열 (Queue) -
- {queue.filter(i => i.status === 'transferring').length} 개 진행 중 +
+
+ +
+ + {activeTab === 'active' && filteredQueue.length > 0 && ( + + )} + {activeTab === 'completed' && filteredQueue.length > 0 && ( + + )}
- +
- - + + + + + - {queue.map((item) => ( + {filteredQueue.map((item) => ( - + + + + ))} - {queue.length === 0 && ( + {filteredQueue.length === 0 && ( - )} diff --git a/types.ts b/types.ts index 1f1805f..9ce1021 100644 --- a/types.ts +++ b/types.ts @@ -27,6 +27,8 @@ export interface TransferItem { progress: number; // 0 to 100 status: 'queued' | 'transferring' | 'completed' | 'failed'; speed: string; + requestedAt?: number; // Timestamp + completedAt?: number; // Timestamp } export interface FileSystemState {
파일명구분진행률구분요청완료소요진행률 속도
{item.status === 'completed' && } @@ -38,20 +82,28 @@ const TransferQueue: React.FC = ({ queue }) => {
)}
{item.filename}{item.filename} {item.direction === 'upload' ? : } {item.direction === 'upload' ? '업로드' : '다운로드'} + {item.requestedAt ? new Date(item.requestedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'} + + {item.completedAt ? new Date(item.completedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'} + + {item.completedAt && item.requestedAt ? `${((item.completedAt - item.requestedAt) / 1000).toFixed(1)}s` : '-'} +
-
@@ -59,10 +111,10 @@ const TransferQueue: React.FC = ({ queue }) => {
{item.speed}
- 전송 대기열이 비어있습니다. + + {activeTab === 'active' ? '대기 중인 전송 파일이 없습니다.' : '완료된 전송 내역이 없습니다.'}