Implement Transfer Queue Enhancements, Smart Queue Clearing, and File Conflict Resolution
This commit is contained in:
321
App.tsx
321
App.tsx
@@ -8,6 +8,7 @@ import SettingsModal from './components/SettingsModal';
|
|||||||
import SiteManagerModal from './components/SiteManagerModal';
|
import SiteManagerModal from './components/SiteManagerModal';
|
||||||
import HelpModal from './components/HelpModal';
|
import HelpModal from './components/HelpModal';
|
||||||
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
|
import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileActionModals';
|
||||||
|
import ConflictModal from './components/ConflictModal';
|
||||||
import { formatBytes } from './utils/formatters';
|
import { formatBytes } from './utils/formatters';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
@@ -39,10 +40,16 @@ const App: React.FC = () => {
|
|||||||
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
|
const [savedSites, setSavedSites] = useState<SiteConfig[]>([]);
|
||||||
|
|
||||||
// Modals State
|
// 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 [modalTargetIsLocal, setModalTargetIsLocal] = useState(true);
|
||||||
const [renameTargetName, setRenameTargetName] = useState('');
|
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
|
// Local File System
|
||||||
const [local, setLocal] = useState<FileSystemState>({
|
const [local, setLocal] = useState<FileSystemState>({
|
||||||
path: localStorage.getItem('last_local_path') || '',
|
path: localStorage.getItem('last_local_path') || '',
|
||||||
@@ -215,6 +222,21 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
break;
|
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':
|
case 'list':
|
||||||
const sortedRemoteFiles = data.files
|
const sortedRemoteFiles = data.files
|
||||||
.map((f: any) => ({
|
.map((f: any) => ({
|
||||||
@@ -292,7 +314,7 @@ const App: React.FC = () => {
|
|||||||
case 'transfer_success':
|
case 'transfer_success':
|
||||||
setQueue(prev => prev.map(item => {
|
setQueue(prev => prev.map(item => {
|
||||||
if (item.id === data.id) {
|
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;
|
return item;
|
||||||
}));
|
}));
|
||||||
@@ -309,7 +331,7 @@ const App: React.FC = () => {
|
|||||||
case 'transfer_error':
|
case 'transfer_error':
|
||||||
setQueue(prev => prev.map(item => {
|
setQueue(prev => prev.map(item => {
|
||||||
if (item.id === data.id) {
|
if (item.id === data.id) {
|
||||||
return { ...item, status: 'failed', speed: data.message };
|
return { ...item, status: 'failed', speed: data.message, completedAt: Date.now() };
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
@@ -409,71 +431,156 @@ const App: React.FC = () => {
|
|||||||
if (wsRef.current) wsRef.current.send(JSON.stringify({ command: 'LOCAL_LIST', path }));
|
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 = () => {
|
const handleDownload = () => {
|
||||||
if (!connection.connected || selectedRemoteIds.size === 0) return;
|
if (!connection.connected || selectedRemoteIds.size === 0) return;
|
||||||
|
|
||||||
selectedRemoteIds.forEach(id => {
|
// Filter and Process
|
||||||
const file = remote.files.find(f => f.id === id);
|
const selectedFiles = remote.files.filter(f => selectedRemoteIds.has(f.id));
|
||||||
if (file && file.type === FileType.FILE) {
|
const conflicts: { source: FileItem, target: FileItem, direction: 'download' }[] = [];
|
||||||
const transferId = `down-${Date.now()}-${Math.random()}`;
|
const safeTransfers: FileItem[] = [];
|
||||||
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}`;
|
|
||||||
|
|
||||||
// Add to Queue
|
selectedFiles.forEach(file => {
|
||||||
|
if (file.type !== FileType.FILE) return;
|
||||||
|
|
||||||
|
// 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, {
|
setQueue(prev => [...prev, {
|
||||||
id: transferId,
|
id: transferId,
|
||||||
direction: 'download',
|
direction: 'download',
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
speed: 'Pending...'
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
}]);
|
}]);
|
||||||
|
executeTransfer(file, 'download', transferId);
|
||||||
addLog('command', `DOWNLOAD ${file.name} -> ${localTarget}`);
|
|
||||||
wsRef.current?.send(JSON.stringify({
|
|
||||||
command: 'DOWNLOAD',
|
|
||||||
localPath: localTarget,
|
|
||||||
remotePath: remoteTarget,
|
|
||||||
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());
|
setSelectedRemoteIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = () => {
|
||||||
if (!connection.connected || selectedLocalIds.size === 0) return;
|
if (!connection.connected || selectedLocalIds.size === 0) return;
|
||||||
|
|
||||||
selectedLocalIds.forEach(id => {
|
const selectedFiles = local.files.filter(f => selectedLocalIds.has(f.id));
|
||||||
const file = local.files.find(f => f.id === id);
|
const conflicts: { source: FileItem, target: FileItem, direction: 'upload' }[] = [];
|
||||||
if (file && file.type === FileType.FILE) {
|
const safeTransfers: FileItem[] = [];
|
||||||
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}`;
|
|
||||||
|
|
||||||
// Add to Queue
|
selectedFiles.forEach(file => {
|
||||||
|
if (file.type !== FileType.FILE) return;
|
||||||
|
|
||||||
|
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, {
|
setQueue(prev => [...prev, {
|
||||||
id: transferId,
|
id: transferId,
|
||||||
direction: 'upload',
|
direction: 'upload',
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
speed: 'Pending...'
|
speed: 'Pending...',
|
||||||
|
requestedAt: Date.now()
|
||||||
}]);
|
}]);
|
||||||
|
executeTransfer(file, 'upload', transferId);
|
||||||
addLog('command', `UPLOAD ${file.name} -> ${remoteTarget}`);
|
|
||||||
wsRef.current?.send(JSON.stringify({
|
|
||||||
command: 'UPLOAD',
|
|
||||||
localPath: localSource,
|
|
||||||
remotePath: remoteTarget,
|
|
||||||
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());
|
setSelectedLocalIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,31 +678,35 @@ const App: React.FC = () => {
|
|||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (modalTargetIsLocal) {
|
if (modalTargetIsLocal) {
|
||||||
const ids = selectedLocalIds;
|
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) {
|
if (wsRef.current) {
|
||||||
const separator = local.path.includes('\\') ? '\\' : '/';
|
const separator = localRef.current.path.includes('\\') ? '\\' : '/';
|
||||||
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
|
const cleanPath = localRef.current.path.endsWith(separator) ? localRef.current.path : localRef.current.path + separator;
|
||||||
|
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const file = local.files.find(f => f.id === id);
|
const file = localRef.current.files.find(f => f.id === id);
|
||||||
if (file) {
|
if (file) {
|
||||||
const targetPath = cleanPath + file.name;
|
const targetPath = cleanPath + file.name;
|
||||||
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath }));
|
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_DELE', path: targetPath }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
|
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
|
||||||
}, 500 + (ids.size * 50)); // Delay a bit more for multiple deletes
|
}, 500 + (ids.size * 50));
|
||||||
}
|
}
|
||||||
setSelectedLocalIds(new Set());
|
setSelectedLocalIds(new Set());
|
||||||
} else {
|
} else {
|
||||||
const ids = selectedRemoteIds;
|
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) {
|
if (wsRef.current) {
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const file = remote.files.find(f => f.id === id);
|
const file = remoteRef.current.files.find(f => f.id === id);
|
||||||
if (file) {
|
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({
|
wsRef.current.send(JSON.stringify({
|
||||||
command: 'DELE',
|
command: 'DELE',
|
||||||
path: targetPath,
|
path: targetPath,
|
||||||
@@ -604,7 +715,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
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));
|
}, 500 + (ids.size * 50));
|
||||||
}
|
}
|
||||||
setSelectedRemoteIds(new Set());
|
setSelectedRemoteIds(new Set());
|
||||||
@@ -612,7 +723,101 @@ const App: React.FC = () => {
|
|||||||
setActiveModal(null);
|
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) => {
|
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
|
// 1. Update State
|
||||||
setConnection({
|
setConnection({
|
||||||
host: site.host,
|
host: site.host,
|
||||||
@@ -704,6 +909,14 @@ const App: React.FC = () => {
|
|||||||
onClose={() => setActiveModal(null)}
|
onClose={() => setActiveModal(null)}
|
||||||
onConfirm={handleDeleteConfirm}
|
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 */}
|
||||||
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
<header className="bg-white border-b border-slate-200 p-2 shadow-sm z-20">
|
||||||
@@ -897,7 +1110,11 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Queue */}
|
{/* Queue */}
|
||||||
<div className="h-48 shrink-0 p-2 pt-0">
|
<div className="h-48 shrink-0 p-2 pt-0">
|
||||||
<TransferQueue queue={queue} />
|
<TransferQueue
|
||||||
|
queue={queue}
|
||||||
|
onCancelAll={handleCancelQueue}
|
||||||
|
onClearCompleted={handleClearCompleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
104
components/ConflictModal.tsx
Normal file
104
components/ConflictModal.tsx
Normal file
@@ -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<ConflictModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
sourceFile,
|
||||||
|
targetFile,
|
||||||
|
onOverwrite,
|
||||||
|
onSkip,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [applyToAll, setApplyToAll] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-[500px] max-w-full m-4 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-yellow-50 px-4 py-3 border-b border-yellow-100 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-700">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<h3 className="font-bold text-sm">파일 중복 확인 (File Conflict)</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 flex flex-col gap-4">
|
||||||
|
<p className="text-slate-700 text-sm">
|
||||||
|
대상 위치에 동일한 이름의 파일이 이미 존재합니다. 어떻게 처리하시겠습니까?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded p-4 text-xs flex flex-col gap-3">
|
||||||
|
{/* Target (Existing) */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col gap-1 w-1/2 pr-2 border-r border-slate-200">
|
||||||
|
<span className="font-semibold text-slate-500">기존 파일 (Target)</span>
|
||||||
|
<span className="font-medium text-slate-800 truncate" title={targetFile.name}>{targetFile.name}</span>
|
||||||
|
<div className="flex gap-2 text-slate-500">
|
||||||
|
<span>{formatBytes(targetFile.size)}</span>
|
||||||
|
<span>{new Date(targetFile.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source (New) */}
|
||||||
|
<div className="flex flex-col gap-1 w-1/2 pl-2 text-right">
|
||||||
|
<span className="font-semibold text-blue-600">새 파일 (Source)</span>
|
||||||
|
<span className="font-medium text-slate-800 truncate" title={sourceFile.name}>{sourceFile.name}</span>
|
||||||
|
<div className="flex gap-2 justify-end text-slate-500">
|
||||||
|
<span>{formatBytes(sourceFile.size)}</span>
|
||||||
|
<span>{new Date(sourceFile.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="applyToAll"
|
||||||
|
checked={applyToAll}
|
||||||
|
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||||
|
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="applyToAll" className="text-sm text-slate-600 cursor-pointer select-none">
|
||||||
|
이후 모든 중복 파일에 대해 동일하게 적용
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-slate-50 px-4 py-3 border-t border-slate-200 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onSkip(applyToAll)}
|
||||||
|
className="px-4 py-2 bg-white border border-slate-300 rounded text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
건너뛰기 (Skip)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOverwrite(applyToAll)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
덮어쓰기 (Overwrite)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConflictModal;
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { TransferItem } from '../types';
|
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 {
|
interface TransferQueueProps {
|
||||||
queue: TransferItem[];
|
queue: TransferItem[];
|
||||||
|
onCancelAll: () => void;
|
||||||
|
onClearCompleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
const TransferQueue: React.FC<TransferQueueProps> = ({ 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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
|
<div className="flex flex-col h-full bg-white border border-slate-300 rounded-lg overflow-hidden shadow-sm">
|
||||||
<div className="bg-slate-50 px-3 py-2 border-b border-slate-200 flex justify-between items-center">
|
<div className="bg-slate-50 px-3 py-1 border-b border-slate-200 flex justify-between items-center">
|
||||||
<span className="text-slate-600 text-xs font-semibold uppercase tracking-wider">전송 대기열 (Queue)</span>
|
<div className="flex space-x-2">
|
||||||
<div className="text-xs text-slate-500">
|
<button
|
||||||
{queue.filter(i => i.status === 'transferring').length} 개 진행 중
|
className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'active' ? 'bg-white text-blue-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
>
|
||||||
|
전송 대기 ({queue.filter(i => i.status === 'queued' || i.status === 'transferring').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 text-xs font-semibold rounded-t-md transition-colors ${activeTab === 'completed' ? 'bg-white text-emerald-600 border-t border-l border-r border-slate-200 -mb-[1px] relative z-10' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
onClick={() => setActiveTab('completed')}
|
||||||
|
>
|
||||||
|
전송 완료 ({queue.filter(i => i.status === 'completed' || i.status === 'failed').length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'active' && filteredQueue.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onCancelAll}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
모두 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeTab === 'completed' && filteredQueue.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onClearCompleted}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:bg-slate-200 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
목록 제거
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-white">
|
<div className="flex-1 overflow-y-auto bg-white">
|
||||||
@@ -22,13 +63,16 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 w-8"></th>
|
<th className="p-2 w-8"></th>
|
||||||
<th className="p-2">파일명</th>
|
<th className="p-2">파일명</th>
|
||||||
<th className="p-2 w-24">구분</th>
|
<th className="p-2 w-20">구분</th>
|
||||||
<th className="p-2 w-48">진행률</th>
|
<th className="p-2 w-40 text-center">요청</th>
|
||||||
|
<th className="p-2 w-40 text-center">완료</th>
|
||||||
|
<th className="p-2 w-32 text-center">소요</th>
|
||||||
|
<th className="p-2 w-32">진행률</th>
|
||||||
<th className="p-2 w-24 text-right">속도</th>
|
<th className="p-2 w-24 text-right">속도</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{queue.map((item) => (
|
{filteredQueue.map((item) => (
|
||||||
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
|
<tr key={item.id} className="border-b border-slate-100 hover:bg-slate-50 text-slate-700">
|
||||||
<td className="p-2 text-center">
|
<td className="p-2 text-center">
|
||||||
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
|
{item.status === 'completed' && <CheckCircle size={14} className="text-emerald-500" />}
|
||||||
@@ -38,18 +82,26 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
|
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mx-auto"></div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 truncate max-w-[200px] font-medium">{item.filename}</td>
|
<td className="p-2 truncate max-w-[150px] font-medium" title={item.filename}>{item.filename}</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
|
<span className={`flex items-center gap-1 ${item.direction === 'upload' ? 'text-blue-600' : 'text-green-600'}`}>
|
||||||
{item.direction === 'upload' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
|
{item.direction === 'upload' ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
|
||||||
{item.direction === 'upload' ? '업로드' : '다운로드'}
|
{item.direction === 'upload' ? '업로드' : '다운로드'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.requestedAt ? new Date(item.requestedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.completedAt ? new Date(item.completedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center text-xs text-slate-500">
|
||||||
|
{item.completedAt && item.requestedAt ? `${((item.completedAt - item.requestedAt) / 1000).toFixed(1)}s` : '-'}
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
|
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden border border-slate-200">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-all duration-200 ${
|
className={`h-full transition-all duration-200 ${item.status === 'completed' ? 'bg-emerald-500' :
|
||||||
item.status === 'completed' ? 'bg-emerald-500' :
|
|
||||||
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
|
item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${item.progress}%` }}
|
style={{ width: `${item.progress}%` }}
|
||||||
@@ -59,10 +111,10 @@ const TransferQueue: React.FC<TransferQueueProps> = ({ queue }) => {
|
|||||||
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
|
<td className="p-2 text-right font-mono text-slate-500">{item.speed}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{queue.length === 0 && (
|
{filteredQueue.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="p-8 text-center text-slate-400 italic">
|
<td colSpan={8} className="p-8 text-center text-slate-400 italic">
|
||||||
전송 대기열이 비어있습니다.
|
{activeTab === 'active' ? '대기 중인 전송 파일이 없습니다.' : '완료된 전송 내역이 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
2
types.ts
2
types.ts
@@ -27,6 +27,8 @@ export interface TransferItem {
|
|||||||
progress: number; // 0 to 100
|
progress: number; // 0 to 100
|
||||||
status: 'queued' | 'transferring' | 'completed' | 'failed';
|
status: 'queued' | 'transferring' | 'completed' | 'failed';
|
||||||
speed: string;
|
speed: string;
|
||||||
|
requestedAt?: number; // Timestamp
|
||||||
|
completedAt?: number; // Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileSystemState {
|
export interface FileSystemState {
|
||||||
|
|||||||
Reference in New Issue
Block a user