Implement Transfer Queue Enhancements, Smart Queue Clearing, and File Conflict Resolution
This commit is contained in:
341
App.tsx
341
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<SiteConfig[]>([]);
|
||||
|
||||
// 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<FileSystemState>({
|
||||
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}
|
||||
/>
|
||||
<ConflictModal
|
||||
isOpen={isConflictModalOpen}
|
||||
sourceFile={activeConflict ? { name: activeConflict.source.name, size: activeConflict.source.size, date: activeConflict.source.date } : { name: '', size: 0, date: '' }}
|
||||
targetFile={activeConflict ? { name: activeConflict.target.name, size: activeConflict.target.size, date: activeConflict.target.date } : { name: '', size: 0, date: '' }}
|
||||
onOverwrite={handleConflictOverwrite}
|
||||
onSkip={handleConflictSkip}
|
||||
onClose={() => handleConflictSkip(false)} // Treat close as skip single
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
||||
@@ -897,7 +1110,11 @@ const App: React.FC = () => {
|
||||
|
||||
{/* Queue */}
|
||||
<div className="h-48 shrink-0 p-2 pt-0">
|
||||
<TransferQueue queue={queue} />
|
||||
<TransferQueue
|
||||
queue={queue}
|
||||
onCancelAll={handleCancelQueue}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user