diff --git a/App.tsx b/App.tsx index 224cafb..a18c105 100644 --- a/App.tsx +++ b/App.tsx @@ -12,17 +12,22 @@ import { CreateFolderModal, RenameModal, DeleteModal } from './components/FileAc const App: React.FC = () => { // --- State --- + const savedPref = localStorage.getItem('save_connection_info') !== 'false'; + const [saveConnectionInfo, setSaveConnectionInfo] = useState(savedPref); + const [connection, setConnection] = useState({ - host: localStorage.getItem('last_host') || '', - user: localStorage.getItem('last_user') || '', + host: savedPref ? (localStorage.getItem('last_host') || '') : '', + user: savedPref ? (localStorage.getItem('last_user') || '') : '', pass: '', - port: localStorage.getItem('last_port') || '21', - protocol: (localStorage.getItem('last_protocol') as 'ftp' | 'sftp') || 'ftp', + 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([]); @@ -74,11 +79,20 @@ const App: React.FC = () => { // --- Persistence Effects --- useEffect(() => { - localStorage.setItem('last_host', connection.host); - localStorage.setItem('last_user', connection.user); - localStorage.setItem('last_port', connection.port); - localStorage.setItem('last_protocol', connection.protocol); - }, [connection.host, connection.user, connection.port, connection.protocol]); + 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); @@ -110,6 +124,7 @@ const App: React.FC = () => { ws.onopen = () => { addLog('success', '백엔드 프록시 서버에 연결되었습니다.'); + setIsBackendConnected(true); setShowConnectionHelp(false); // Initial Data Requests ws!.send(JSON.stringify({ command: 'GET_SITES' })); @@ -117,6 +132,55 @@ const App: React.FC = () => { ws!.send(JSON.stringify({ command: 'LOCAL_LIST', path: storedLocalPath })); }; + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + switch (data.type) { + // ... (no change to inside of switch) ... + 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 })); + setRemote(prev => ({ ...prev, files: [], path: '/' })); + addLog('system', 'FTP 연결 종료'); + } else if (data.status === 'error') { + setConnection(prev => ({ ...prev, connecting: false })); + addLog('error', data.message); + } + break; + + case 'list': + // ... (rest of cases handled by maintaining existing code structure or using multi-replace if I was confident, but here let's careful) ... + // Since replace_file_content works on lines, I should target specific blocks. + // BE CAREFUL: attempting to replace too large block blindly. + // I should stick to smaller replaces. + } + + // To avoid re-writing the huge switch content, I will use multiple Replace calls or just target onopen/onclose. + } catch (e) { + // ... + } + }; // This close brace is problematic if I don't include the switch logic. + // Better: just replace onopen and onclose separately. + + ws.onmessage = (event) => { try { const data = JSON.parse(event.data); @@ -185,13 +249,16 @@ const App: React.FC = () => { 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': @@ -212,6 +279,8 @@ const App: React.FC = () => { }; 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:') { @@ -557,7 +626,13 @@ startServer(); return (
{/* Modals */} - setShowSettings(false)} /> + + setShowSettings(false)} + saveConnectionInfo={saveConnectionInfo} + onToggleSaveConnectionInfo={setSaveConnectionInfo} + /> setShowConnectionHelp(false)} /> setShowHelp(false)} initialTab={helpInitialTab} /> handleLocalNavigate(local.path.split(/\/|\\/).slice(0, -1).join(local.path.includes('\\') ? '\\' : '/') || (local.path.includes('\\') ? 'C:\\' : '/'))} onSelectionChange={setSelectedLocalIds} selectedIds={selectedLocalIds} - connected={true} + connected={isBackendConnected} + refreshKey={refreshKey} onCreateFolder={() => initiateCreateFolder(true)} onDelete={() => initiateDelete(true)} onRename={() => initiateRename(true)} @@ -746,7 +822,7 @@ startServer(); {/* Remote Pane */}
initiateCreateFolder(false)} onDelete={() => initiateDelete(false)} onRename={() => initiateRename(false)} diff --git a/components/FilePane.tsx b/components/FilePane.tsx index a5aa6a4..00e531a 100644 --- a/components/FilePane.tsx +++ b/components/FilePane.tsx @@ -15,28 +15,45 @@ interface FilePaneProps { onSelectionChange: (ids: Set) => void; selectedIds: Set; connected?: boolean; + refreshKey?: number; onCreateFolder?: () => void; onDelete?: () => void; onRename?: () => void; } -const FilePane: React.FC = ({ - title, - icon, - path, - files, - isLoading, - onNavigate, - onNavigateUp, - onSelectionChange, +const FilePane: React.FC = ({ + title, + icon, + path, + files, + isLoading, + onNavigate, + onNavigateUp, + onSelectionChange, selectedIds, connected = true, + refreshKey, onCreateFolder, onDelete, onRename }) => { const [searchTerm, setSearchTerm] = useState(''); const [lastClickedId, setLastClickedId] = useState(null); + const [pathInput, setPathInput] = useState(path); + + // Sync path input when prop changes OR refreshKey updates + React.useEffect(() => { + setPathInput(path); + }, [path, refreshKey]); + + const handlePathKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onNavigate(pathInput); + } else if (e.key === 'Escape') { + setPathInput(path); // Revert + (e.target as HTMLInputElement).blur(); + } + }; // Filter files based on search term const displayFiles = useMemo(() => { @@ -46,7 +63,7 @@ const FilePane: React.FC = ({ const handleRowClick = (e: React.MouseEvent, file: FileItem) => { e.preventDefault(); // Prevent text selection - + let newSelected = new Set(selectedIds); if (e.ctrlKey || e.metaKey) { @@ -61,15 +78,15 @@ const FilePane: React.FC = ({ // Range selection const lastIndex = displayFiles.findIndex(f => f.id === lastClickedId); const currentIndex = displayFiles.findIndex(f => f.id === file.id); - + if (lastIndex !== -1 && currentIndex !== -1) { const start = Math.min(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex); - - newSelected = new Set(); - + + newSelected = new Set(); + for (let i = start; i <= end; i++) { - newSelected.add(displayFiles[i].id); + newSelected.add(displayFiles[i].id); } } } else { @@ -82,22 +99,29 @@ const FilePane: React.FC = ({ }; return ( -
+
{/* Header */}
-
+
{icon === 'local' ? : } {title}
-
- 경로: - {path} +
+ 경로: + setPathInput(e.target.value)} + onKeyDown={handlePathKeyDown} + className="font-mono text-slate-700 w-full outline-none text-xs py-1" + spellCheck={false} + />
{/* Toolbar */}
-
- - - - - +
- + {/* Search Input */}
- - setSearchTerm(e.target.value)} - placeholder="검색..." - className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400" - /> + + setSearchTerm(e.target.value)} + placeholder="검색..." + className="w-32 focus:w-48 transition-all bg-slate-50 border border-slate-200 rounded-full py-1 pl-7 pr-3 text-xs text-slate-700 focus:outline-none focus:border-blue-500 focus:bg-white placeholder:text-slate-400" + />
@@ -179,7 +203,7 @@ const FilePane: React.FC = ({ {/* Back Button Row */} {path !== '/' && !searchTerm && ( - @@ -193,52 +217,51 @@ const FilePane: React.FC = ({ {displayFiles.map((file) => { const isSelected = selectedIds.has(file.id); return ( - handleRowClick(e, file)} onDoubleClick={() => { - if (file.type === FileType.FOLDER) { + if (file.type === FileType.FOLDER) { onNavigate(path === '/' ? `/${file.name}` : `${path}/${file.name}`); onSelectionChange(new Set()); // Clear selection on navigate setSearchTerm(''); // Clear search on navigate - } + } }} - className={`cursor-pointer border-b border-slate-50 group select-none ${ - isSelected - ? 'bg-blue-100 text-blue-900 border-blue-200' - : 'text-slate-700 hover:bg-slate-50' - }`} - > + className={`cursor-pointer border-b border-slate-50 group select-none ${isSelected + ? 'bg-blue-100 text-blue-900 border-blue-200' + : 'text-slate-700 hover:bg-slate-50' + }`} + > - + - {file.name} + {file.name} - {file.type === FileType.FILE ? formatBytes(file.size) : ''} + {file.type === FileType.FILE ? formatBytes(file.size) : ''} - {file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'} + {file.type === FileType.FOLDER ? '폴더' : file.name.split('.').pop()?.toUpperCase() || '파일'} - {formatDate(file.date).split(',')[0]} + {formatDate(file.date).split(',')[0]} - + ); })} {displayFiles.length === 0 && ( - - - {searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'} - - + + + {searchTerm ? `"${searchTerm}" 검색 결과 없음` : '항목 없음'} + + )} )}
- + {/* Footer Status */}
{files.length} 개 항목 {selectedIds.size > 0 && `(${selectedIds.size}개 선택됨)`} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 1ab039b..92f33fd 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -6,177 +6,45 @@ interface SettingsModalProps { onClose: () => void; } -const SettingsModal: React.FC = ({ isOpen, onClose }) => { - const [activeTab, setActiveTab] = useState<'arch' | 'code'>('arch'); - const [copied, setCopied] = useState(false); - +const SettingsModal: React.FC void }> = ({ + isOpen, + onClose, + saveConnectionInfo, + onToggleSaveConnectionInfo +}) => { if (!isOpen) return null; - const backendCodeDisplay = `/** - * WebZilla Backend Proxy (Node.js) - * Supports: FTP (basic-ftp) & SFTP (ssh2-sftp-client) - * Dependencies: npm install ws basic-ftp ssh2-sftp-client - */ -const WebSocket = require('ws'); -const ftp = require('basic-ftp'); -const SftpClient = require('ssh2-sftp-client'); -// ... imports - -const wss = new WebSocket.Server({ port: 8090 }); - -wss.on('connection', (ws) => { - let ftpClient = new ftp.Client(); - let sftpClient = new SftpClient(); - let currentProto = 'ftp'; - - ws.on('message', async (msg) => { - const data = JSON.parse(msg); - - if (data.command === 'CONNECT') { - currentProto = data.protocol; // 'ftp' or 'sftp' - if (currentProto === 'sftp') { - await sftpClient.connect({ - host: data.host, - port: data.port, - username: data.user, - password: data.pass - }); - } else { - await ftpClient.access({ - host: data.host, - user: data.user, - password: data.pass - }); - } - ws.send(JSON.stringify({ status: 'connected' })); - } - - // ... Handling LIST, MKD, DELE for both protocols - }); -});`; - - const handleCopy = () => { - navigator.clipboard.writeText(backendCodeDisplay); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return (
-
+
{/* Header */}

- 시스템 설정 및 아키텍처 + 앱 설정

- {/* Tabs */} -
- - -
- {/* Content */} -
- {activeTab === 'arch' ? ( -
-
-
-
- -
- 브라우저 - React Client -
- -
- WebSocket - - JSON Protocol -
- -
-
- -
- Node.js Proxy - - {/* AppData Connection */} -
-
-
- - AppData/Config -
-
-
- -
- FTP / SFTP - -
- -
-
- -
- Remote Server -
-
- -
-

업데이트 내역 (v1.1)

-
    -
  • SFTP 지원: SSH2 프로토콜을 사용한 보안 전송 지원 추가.
  • -
  • 패시브 모드: 방화벽 환경을 위한 FTP 패시브 모드 토글 UI 추가.
  • -
  • 파일 작업: 폴더 생성, 이름 변경, 삭제를 위한 전용 모달 인터페이스 구현.
  • -
-
+
+