import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Settings, WifiOff, ArrowRight, ArrowLeft, BookOpen, Download, MousePointerClick, HelpCircle } from 'lucide-react';
import { FileItem, FileType, LogEntry, TransferItem, FileSystemState, SiteConfig } from './types';
import FilePane from './components/FilePane';
import LogConsole from './components/LogConsole';
import TransferQueue from './components/TransferQueue';
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 AdSenseBanner: React.FC = () => {
useEffect(() => {
try {
// @ts-ignore
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error("AdSense error", e);
}
}, []);
return (
);
};
const App: React.FC = () => {
// --- State ---
const savedPref = localStorage.getItem('save_connection_info') !== 'false';
const [saveConnectionInfo, setSaveConnectionInfo] = useState(savedPref);
const [connection, setConnection] = useState({
host: savedPref ? (localStorage.getItem('last_host') || '') : '',
user: savedPref ? (localStorage.getItem('last_user') || '') : '',
pass: '',
port: savedPref ? (localStorage.getItem('last_port') || '21') : '21',
protocol: savedPref ? ((localStorage.getItem('last_protocol') as 'ftp' | 'sftp') || 'ftp') : 'ftp',
passive: true,
initialPath: '', // New field for Session-specific initial path
connected: false,
connecting: false
});
const [isBackendConnected, setIsBackendConnected] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); // To force FilePane input revert on error
const [logs, setLogs] = useState([]);
const [queue, setQueue] = useState([]);
const [showSettings, setShowSettings] = useState(false);
const [showConnectionHelp, setShowConnectionHelp] = useState(false);
const [showSiteManager, setShowSiteManager] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpInitialTab, setHelpInitialTab] = useState<'sites' | 'connection' | 'files' | 'backend'>('sites');
const [savedSites, setSavedSites] = useState([]);
// Modals State
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') || '',
files: [],
isLoading: false
});
const [selectedLocalIds, setSelectedLocalIds] = useState>(new Set());
// Remote File System
const [remote, setRemote] = useState({
path: '/',
files: [],
isLoading: false
});
const [selectedRemoteIds, setSelectedRemoteIds] = useState>(new Set());
// WebSocket
const wsRef = useRef(null);
const connectionRef = useRef(connection);
const localRef = useRef(local);
const remoteRef = useRef(remote);
// --- Helpers ---
const addLog = useCallback((type: LogEntry['type'], message: string) => {
setLogs(prev => [...prev, {
id: Math.random().toString(36),
timestamp: new Date().toISOString(),
type,
message
}]);
}, []);
const getSelectedFiles = (allFiles: FileItem[], ids: Set) => {
return allFiles.filter(f => ids.has(f.id));
};
// --- Persistence Effects ---
useEffect(() => {
localStorage.setItem('save_connection_info', String(saveConnectionInfo));
if (saveConnectionInfo) {
localStorage.setItem('last_host', connection.host);
localStorage.setItem('last_user', connection.user);
localStorage.setItem('last_port', connection.port);
localStorage.setItem('last_protocol', connection.protocol);
} else {
localStorage.removeItem('last_host');
localStorage.removeItem('last_user');
localStorage.removeItem('last_port');
localStorage.removeItem('last_protocol');
}
}, [connection.host, connection.user, connection.port, connection.protocol, saveConnectionInfo]);
useEffect(() => {
if (local.path) localStorage.setItem('last_local_path', local.path);
}, [local.path]);
// Sync connection state to ref for WS callbacks
useEffect(() => {
connectionRef.current = connection;
localRef.current = local;
remoteRef.current = remote;
}, [connection, local, remote]);
// --- WebSocket Setup ---
useEffect(() => {
let ws: WebSocket | null = null;
let connectTimer: any = null;
const connectWS = () => {
// Debounce connection to avoid Strict Mode double-invocation issues
connectTimer = setTimeout(() => {
// Check if already connected/connecting to avoid dupes
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
addLog('system', '백엔드 프록시 서버(ws://localhost:8090) 연결 시도 중...');
try {
ws = new WebSocket('ws://localhost:8090');
wsRef.current = ws;
ws.onopen = () => {
addLog('success', '백엔드 프록시 서버에 연결되었습니다.');
setIsBackendConnected(true);
setShowConnectionHelp(false);
// Initial Data Requests
ws!.send(JSON.stringify({ command: 'GET_SITES' }));
const storedLocalPath = localStorage.getItem('last_local_path') || '';
ws!.send(JSON.stringify({ command: 'LOCAL_LIST', path: storedLocalPath }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'status':
if (data.status === 'connected') {
setConnection(prev => ({ ...prev, connected: true, connecting: false }));
addLog('success', data.message || 'FTP 연결 성공');
// Use Ref for latest state (especially initialPath from Site Manager)
const currentConn = connectionRef.current;
// 1. Initial Directory from Site Config
if (currentConn.initialPath) {
ws?.send(JSON.stringify({ command: 'LIST', path: currentConn.initialPath }));
}
// 2. Last Visited Path (Persistence)
else {
const lastRemote = localStorage.getItem(`last_remote_path_${currentConn.host}`);
const initialPath = lastRemote || '/';
ws?.send(JSON.stringify({ command: 'LIST', path: initialPath }));
}
} else if (data.status === 'disconnected') {
setConnection(prev => ({ ...prev, connected: false, connecting: false }));
addLog('info', 'FTP 연결 종료');
setRemote(prev => ({ ...prev, files: [], path: '/' }));
}
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) => ({
...f,
type: f.type === 'FOLDER' ? FileType.FOLDER : FileType.FILE
}))
.sort((a: any, b: any) => {
if (a.type !== b.type) return a.type === FileType.FOLDER ? -1 : 1;
return a.name.localeCompare(b.name);
});
setRemote({
path: data.path,
files: sortedRemoteFiles,
isLoading: false
});
if (connectionRef.current.host) {
localStorage.setItem(`last_remote_path_${connectionRef.current.host}`, data.path);
}
addLog('success', `목록 조회 완료: ${data.path}`);
break;
case 'local_list':
const sortedLocalFiles = data.files
.map((f: any) => ({
...f,
type: f.type === 'FOLDER' ? FileType.FOLDER : FileType.FILE
}))
.sort((a: any, b: any) => {
if (a.type !== b.type) return a.type === FileType.FOLDER ? -1 : 1;
return a.name.localeCompare(b.name);
});
setLocal({
path: data.path,
files: sortedLocalFiles,
isLoading: false
});
addLog('success', `[로컬] 이동 완료: ${data.path}`);
break;
case 'error':
addLog('error', data.message);
window.alert(data.message); // Show error dialog
setConnection(prev => ({ ...prev, connecting: false }));
setRemote(prev => ({ ...prev, isLoading: false }));
setLocal(prev => ({ ...prev, isLoading: false }));
setRefreshKey(prev => prev + 1); // Revert invalid inputs
break;
case 'success':
addLog('success', data.message);
if (connectionRef.current.connected) {
ws?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
}
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
break;
case 'sites_list':
setSavedSites(data.sites);
break;
case 'transfer_progress':
setQueue(prev => prev.map(item => {
if (item.id === data.id) {
const total = data.bytesOverall;
const current = data.bytes;
const progress = total > 0 ? Math.round((current / total) * 100) : 0;
return { ...item, progress, status: 'transferring', speed: `${formatBytes(current)} transferred` };
}
return item;
}));
break;
case 'transfer_success':
setQueue(prev => prev.map(item => {
if (item.id === data.id) {
return { ...item, progress: 100, status: 'completed', speed: 'Completed', completedAt: Date.now() };
}
return item;
}));
addLog('success', data.message);
// Refresh lists
if (data.path) {
ws?.send(JSON.stringify({ command: 'LOCAL_LIST', path: localRef.current.path }));
if (connectionRef.current.connected) {
ws?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
}
}
break;
case 'transfer_error':
setQueue(prev => prev.map(item => {
if (item.id === data.id) {
return { ...item, status: 'failed', speed: data.message, completedAt: Date.now() };
}
return item;
}));
addLog('error', data.message);
break;
}
} catch (e) {
console.error("WS Message Error", e);
}
};
ws.onclose = (e) => {
setIsBackendConnected(false);
setConnection(prev => ({ ...prev, connected: false, connecting: false }));
if (e.code !== 1000) { // Not normal closure
addLog('error', '백엔드 서버와 연결이 끊어졌습니다. 재연결 시도 중...');
if (window.location.protocol === 'https:') {
setShowConnectionHelp(true);
}
// Retry loop
setTimeout(() => {
// Only retry if component still mounted (implied by effect cleanup)
// But cleaner to just call connectWS which checks state
connectWS();
}, 3000);
}
};
ws.onerror = (err) => {
console.error("WS Error", err);
addLog('error', '백엔드 소켓 연결 오류');
if (window.location.protocol === 'https:') {
setShowConnectionHelp(true);
}
};
} catch (e) {
console.error("WS Setup Error", e);
if (window.location.protocol === 'https:') {
setShowConnectionHelp(true);
}
}
}, 300);
};
connectWS();
return () => {
clearTimeout(connectTimer);
if (wsRef.current) {
wsRef.current.close(1000);
wsRef.current = null;
}
};
}, [addLog]); // Intentionally minimal deps
// --- Connection Handlers ---
const handleConnect = () => {
if (connection.connected) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ command: 'DISCONNECT' }));
}
return;
}
setConnection(prev => ({ ...prev, connecting: true }));
addLog('command', `CONNECT [${connection.protocol.toUpperCase()}] ${connection.user}@${connection.host}:${connection.port} ${connection.passive ? '(Passive)' : ''}`);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
command: 'CONNECT',
host: connection.host,
user: connection.user,
pass: connection.pass,
port: connection.port,
protocol: connection.protocol,
passive: connection.passive
}));
} else {
addLog('error', '백엔드 서버에 연결되어 있지 않습니다. 잠시 후 다시 시도하세요.');
setConnection(prev => ({ ...prev, connecting: false }));
}
};
const handleRemoteNavigate = (path: string) => {
if (!connection.connected) return;
setRemote(prev => ({ ...prev, isLoading: true }));
setSelectedRemoteIds(new Set());
if (wsRef.current) wsRef.current.send(JSON.stringify({ command: 'LIST', path }));
};
const handleLocalNavigate = (path: string) => {
setLocal(prev => ({ ...prev, isLoading: true }));
setSelectedLocalIds(new Set());
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;
// Filter and Process
const selectedFiles = remote.files.filter(f => selectedRemoteIds.has(f.id));
const conflicts: { source: FileItem, target: FileItem, direction: 'download' }[] = [];
const safeTransfers: FileItem[] = [];
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, {
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;
const selectedFiles = local.files.filter(f => selectedLocalIds.has(f.id));
const conflicts: { source: FileItem, target: FileItem, direction: 'upload' }[] = [];
const safeTransfers: FileItem[] = [];
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, {
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());
};
// --- File Action Handlers ---
const initiateCreateFolder = (isLocal: boolean) => {
if (!isLocal && !connection.connected) return;
setModalTargetIsLocal(isLocal);
setActiveModal('create');
};
const initiateRename = (isLocal: boolean) => {
if (!isLocal && !connection.connected) return;
const selectedIds = isLocal ? selectedLocalIds : selectedRemoteIds;
if (selectedIds.size !== 1) return;
const files = isLocal ? local.files : remote.files;
const file = files.find(f => selectedIds.has(f.id));
if (file) {
setRenameTargetName(file.name);
setModalTargetIsLocal(isLocal);
setActiveModal('rename');
}
};
const initiateDelete = (isLocal: boolean) => {
if (!isLocal && !connection.connected) return;
setModalTargetIsLocal(isLocal);
setActiveModal('delete');
};
// --- Modal Confirms ---
const handleCreateFolderConfirm = (name: string) => {
if (!name.trim()) return;
if (modalTargetIsLocal) {
addLog('command', `LOCAL_MKD ${name}`);
if (wsRef.current) {
// Simple path join, assuming separators are handled by Node or we use /
const separator = local.path.includes('\\') ? '\\' : '/';
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
const targetPath = cleanPath + name;
wsRef.current.send(JSON.stringify({ command: 'LOCAL_MKD', path: targetPath }));
// Refresh list after short delay or wait for success response?
// Success response triggers nothing specific yet, but we can hook into success message or just refresh manually here?
// Better: success handler in onmessage triggers reload.
// For now, let's trigger reload after delay or optimistic?
// Let's rely on success message from backend to trigger reload?
// Currently 'success' message just logs.
// I'll add a reload request in the success handler logic or just request it here?
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
}, 500);
}
} else {
addLog('command', `MKD ${name}`);
if (wsRef.current) {
const targetPath = remote.path === '/' ? `/${name}` : `${remote.path}/${name}`;
wsRef.current.send(JSON.stringify({ command: 'MKD', path: targetPath }));
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remote.path }));
}, 500);
}
}
setActiveModal(null);
};
const handleRenameConfirm = (newName: string) => {
if (!newName.trim()) return;
if (modalTargetIsLocal) {
const selectedIds = selectedLocalIds;
const file = local.files.find(f => selectedIds.has(f.id));
if (file && wsRef.current) {
const separator = local.path.includes('\\') ? '\\' : '/';
const cleanPath = local.path.endsWith(separator) ? local.path : local.path + separator;
const from = cleanPath + file.name;
const to = cleanPath + newName;
addLog('command', `LOCAL_RENAME ${file.name} -> ${newName}`);
wsRef.current.send(JSON.stringify({ command: 'LOCAL_RENAME', from, to }));
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LOCAL_LIST', path: local.path }));
}, 500);
}
} else {
const selectedIds = selectedRemoteIds;
const file = remote.files.find(f => selectedIds.has(f.id));
if (wsRef.current && file) {
const from = remote.path === '/' ? `/${file.name}` : `${remote.path}/${file.name}`;
const to = remote.path === '/' ? `/${newName}` : `${remote.path}/${newName}`;
wsRef.current.send(JSON.stringify({ command: 'RENAME', from, to }));
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remote.path }));
}, 500);
}
}
setActiveModal(null);
};
const handleDeleteConfirm = () => {
if (modalTargetIsLocal) {
const ids = selectedLocalIds;
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 = localRef.current.path.includes('\\') ? '\\' : '/';
const cleanPath = localRef.current.path.endsWith(separator) ? localRef.current.path : localRef.current.path + separator;
ids.forEach(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: localRef.current.path }));
}, 500 + (ids.size * 50));
}
setSelectedLocalIds(new Set());
} else {
const ids = selectedRemoteIds;
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 = remoteRef.current.files.find(f => f.id === id);
if (file) {
const targetPath = remoteRef.current.path === '/' ? `/${file.name}` : `${remoteRef.current.path}/${file.name}`;
wsRef.current.send(JSON.stringify({
command: 'DELE',
path: targetPath,
isFolder: file.type === FileType.FOLDER
}));
}
});
setTimeout(() => {
wsRef.current?.send(JSON.stringify({ command: 'LIST', path: remoteRef.current.path }));
}, 500 + (ids.size * 50));
}
setSelectedRemoteIds(new Set());
}
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,
port: site.port,
user: site.user,
pass: site.pass || '',
protocol: site.protocol,
passive: site.passiveMode !== false,
initialPath: site.initialPath || '',
connected: false,
connecting: true
});
setShowSiteManager(false);
// 2. Trigger Connection Immediately
addLog('command', `CONNECT [${site.protocol.toUpperCase()}] ${site.user}@${site.host}:${site.port} ${site.passiveMode !== false ? '(Passive)' : ''}`);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
command: 'CONNECT',
host: site.host,
user: site.user,
pass: site.pass || '',
port: site.port,
protocol: site.protocol,
passive: site.passiveMode !== false
}));
} else {
addLog('error', '백엔드 서버에 연결되어 있지 않습니다. 도움말을 참고하여 백엔드를 실행하세요.');
setConnection(prev => ({ ...prev, connecting: false }));
// Open Help on Backend Tab
setHelpInitialTab('backend');
setShowHelp(true);
}
};
const handleSaveSites = (sites: SiteConfig[]) => {
setSavedSites(sites);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
command: 'SAVE_SITES',
sites: sites
}));
}
};
const getDeleteModalData = () => {
const isLocal = modalTargetIsLocal;
const ids = isLocal ? selectedLocalIds : selectedRemoteIds;
const files = isLocal ? local.files : remote.files;
const selectedFiles = files.filter(f => ids.has(f.id));
return { count: ids.size, names: selectedFiles.map(f => f.name) };
};
return (
{/* Modals */}
setShowSettings(false)}
saveConnectionInfo={saveConnectionInfo}
onToggleSaveConnectionInfo={setSaveConnectionInfo}
/>
setShowHelp(false)} initialTab={helpInitialTab} />
setShowSiteManager(false)}
initialSites={savedSites}
onSaveSites={handleSaveSites}
onConnect={handleSiteConnect}
/>
setActiveModal(null)}
onConfirm={handleCreateFolderConfirm}
/>
setActiveModal(null)}
onConfirm={handleRenameConfirm}
/>
setActiveModal(null)}
onConfirm={handleDeleteConfirm}
/>
handleConflictSkip(false)} // Treat close as skip single
/>
{/* Header */}
{/* Log View */}
{/* Placeholder for Sponsored tag if needed, or remove it */}
SPONSORED
{/* Main Split View */}
{/* Local Pane */}
handleLocalNavigate(local.path.split(/\/|\\/).slice(0, -1).join(local.path.includes('\\') ? '\\' : '/') || (local.path.includes('\\') ? 'C:\\' : '/'))}
onSelectionChange={setSelectedLocalIds}
selectedIds={selectedLocalIds}
connected={isBackendConnected}
refreshKey={refreshKey}
onCreateFolder={() => initiateCreateFolder(true)}
onDelete={() => initiateDelete(true)}
onRename={() => initiateRename(true)}
/>
{/* Middle Actions */}
{/* Remote Pane */}
handleRemoteNavigate(remote.path.split('/').slice(0, -1).join('/') || '/')}
onSelectionChange={setSelectedRemoteIds}
selectedIds={selectedRemoteIds}
connected={connection.connected}
refreshKey={refreshKey}
onCreateFolder={() => initiateCreateFolder(false)}
onDelete={() => initiateDelete(false)}
onRename={() => initiateRename(false)}
/>
{/* Queue */}
{/* Footer */}
);
};
export default App;