diff --git a/src/components/file-system/Sidebar/Sidebar.tsx b/src/components/file-system/Sidebar/Sidebar.tsx index 5236563..a8ba7d1 100644 --- a/src/components/file-system/Sidebar/Sidebar.tsx +++ b/src/components/file-system/Sidebar/Sidebar.tsx @@ -216,7 +216,7 @@ const SidebarContent = ({ )}
diff --git a/src/components/tabs/TabContentCache/TabContentCache.tsx b/src/components/tabs/TabContentCache/TabContentCache.tsx index e76d77a..e1ac657 100644 --- a/src/components/tabs/TabContentCache/TabContentCache.tsx +++ b/src/components/tabs/TabContentCache/TabContentCache.tsx @@ -4,6 +4,8 @@ import type { TOCItem } from '@/lib/utils' import { matchModule } from '@/lib/module-registry' import { MarkdownTabPage } from '../MarkdownTabPage' import { RemoteTabPage } from '@/modules/remote/RemoteTabPage' +import { FileTransferPage } from '@/modules/remote/components/file-transfer/FileTransferPage' +import { useTabStore } from '@/stores' interface TabContentCacheProps { openFiles: FileItem[] @@ -18,6 +20,8 @@ export const TabContentCache: React.FC = ({ containerClassName = '', onTocUpdated }) => { + const { closeFile } = useTabStore() + const handleTocUpdate = (filePath: string) => (toc: TOCItem[]) => { if (activeFile?.path === filePath) { onTocUpdated?.(filePath, toc) @@ -31,6 +35,21 @@ export const TabContentCache: React.FC = ({ return } + // 检查是否是文件传输标签页 + if (file.path.startsWith('file-transfer-panel')) { + const queryString = file.path.includes('?') ? file.path.split('?')[1] : '' + const urlParams = new URLSearchParams(queryString) + const serverHost = urlParams.get('host') || '' + const port = parseInt(urlParams.get('port') || '3000', 10) + return ( + closeFile(file)} + /> + ) + } + // 检查是否是远程桌面标签页 if (file.path.startsWith('remote-desktop://') || file.path.startsWith('remote-git://')) { const urlParams = new URLSearchParams(file.path.split('?')[1]) @@ -46,12 +65,15 @@ export const TabContentCache: React.FC = ({
{openFiles.map((file) => { const isActive = activeFile?.path === file.path + + const isFileTransfer = file.path.startsWith('file-transfer-panel') + const paddingClass = isFileTransfer ? '' : containerClassName return (
diff --git a/src/hooks/ui/useSidebarResize.ts b/src/hooks/ui/useSidebarResize.ts index 26acccd..cae74c2 100644 --- a/src/hooks/ui/useSidebarResize.ts +++ b/src/hooks/ui/useSidebarResize.ts @@ -1,11 +1,14 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' export const useSidebarResize = (initialWidth: number = 250) => { const [sidebarWidth, setSidebarWidth] = useState(initialWidth) const [isResizing, setIsResizing] = useState(false) + const offsetX = useRef(0) const startResizing = useCallback((e: React.MouseEvent) => { e.preventDefault() + const rect = (e.target as HTMLElement).getBoundingClientRect() + offsetX.current = e.clientX - rect.left setIsResizing(true) }, []) @@ -14,7 +17,7 @@ export const useSidebarResize = (initialWidth: number = 250) => { const stopResizing = () => setIsResizing(false) const resize = (e: MouseEvent) => { - const newWidth = e.clientX + const newWidth = e.clientX - offsetX.current if (newWidth > 150 && newWidth < 600) { setSidebarWidth(newWidth) } diff --git a/src/modules/remote/RemotePage.tsx b/src/modules/remote/RemotePage.tsx index 4f9e648..705ed56 100644 --- a/src/modules/remote/RemotePage.tsx +++ b/src/modules/remote/RemotePage.tsx @@ -187,9 +187,14 @@ export const RemotePage: React.FC = () => { setShowConfig(true) return } - const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/files` - const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : '' - const fileItem = createRemoteDesktopFileItem(url, `文件传输${deviceName}`, selectedConfig.deviceName) + const url = `file-transfer-panel?host=${encodeURIComponent(selectedConfig.serverHost)}&port=${selectedConfig.desktopPort}` + const fileItem: FileItem = { + name: `文件传输 - ${selectedConfig.deviceName}`, + path: url, + type: 'file', + size: 0, + modified: new Date().toISOString(), + } selectFile(fileItem) } diff --git a/src/modules/remote/api.ts b/src/modules/remote/api.ts index 225cfaa..dc23729 100644 --- a/src/modules/remote/api.ts +++ b/src/modules/remote/api.ts @@ -1,6 +1,10 @@ import { getModuleApi } from '@/lib/module-registry' import { type RemoteEndpoints } from '@shared/modules/remote' -import type { RemoteConfig } from './types' +import type { RemoteConfig, RemoteFileItem } from './types' +import { fetchFiles as fetchSystemFiles } from '@/lib/api' + +export type { RemoteFileItem } from './types' +export { fetchSystemFiles } const getApi = () => { const api = getModuleApi('remote') @@ -37,3 +41,77 @@ export const getDeviceData = async (deviceName?: string): Promise<{ lastConnecte export const saveDeviceData = async (deviceName: string, lastConnected: string): Promise => { await getApi().post('saveData', { deviceName, lastConnected }) } + +export const fetchRemoteFiles = async ( + serverHost: string, + port: number, + path: string, + password?: string +): Promise => { + const url = `http://${serverHost}:${port}/api/files?path=${encodeURIComponent(path)}` + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch remote files: ${response.statusText}`) + } + const data = await response.json() + return data.items || [] +} + +export const uploadFileToRemote = async ( + serverHost: string, + port: number, + file: File, + remotePath: string, + password?: string, + onProgress?: (progress: number) => void +): Promise => { + const url = `http://${serverHost}:${port}/api/files/upload` + const formData = new FormData() + formData.append('file', file) + if (remotePath) { + formData.append('path', remotePath) + } + + const xhr = new XMLHttpRequest() + + return new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable && onProgress) { + const progress = Math.round((event.loaded / event.total) * 100) + onProgress(progress) + } + }) + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject(new Error(`Upload failed: ${xhr.statusText}`)) + } + }) + + xhr.addEventListener('error', () => { + reject(new Error('Upload failed')) + }) + + xhr.open('POST', url) + xhr.send(formData) + }) +} + +export const downloadFileFromRemote = async ( + serverHost: string, + port: number, + fileName: string, + remotePath: string, + password?: string, + onProgress?: (progress: number) => void +): Promise => { + const url = `http://${serverHost}:${port}/api/files/download?path=${encodeURIComponent(remotePath)}&name=${encodeURIComponent(fileName)}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`) + } + return await response.blob() +} diff --git a/src/modules/remote/components/file-transfer/FileTransferPanel.tsx b/src/modules/remote/components/file-transfer/FileTransferPage.tsx similarity index 52% rename from src/modules/remote/components/file-transfer/FileTransferPanel.tsx rename to src/modules/remote/components/file-transfer/FileTransferPage.tsx index d1c3f47..6463020 100644 --- a/src/modules/remote/components/file-transfer/FileTransferPanel.tsx +++ b/src/modules/remote/components/file-transfer/FileTransferPage.tsx @@ -1,31 +1,28 @@ -import React from 'react' -import { X } from 'lucide-react' +import React, { useState, useCallback, useRef, useEffect } from 'react' +import type { FileItem } from '@/lib/api' +import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api' +import { type TransferItem } from '../../types' import { LocalFilePanel } from './LocalFilePanel' import { RemoteFilePanel } from './RemoteFilePanel' import { TransferQueue } from './TransferQueue' -import type { FileItem } from '@/lib/api' -import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api' -import type { TransferItem } from '../../types' -interface FileTransferPanelProps { +interface FileTransferPageProps { serverHost: string port: number - password?: string onClose: () => void } -export const FileTransferPanel: React.FC = ({ - serverHost, - port, - password, - onClose, -}) => { - const [localSelected, setLocalSelected] = React.useState(null) - const [remoteSelected, setRemoteSelected] = React.useState(null) - const [transfers, setTransfers] = React.useState([]) - const [transferring, setTransferring] = React.useState(false) +export const FileTransferPage: React.FC = ({ serverHost, port, onClose }) => { + const [localSelected, setLocalSelected] = useState(null) + const [remoteSelected, setRemoteSelected] = useState(null) + const [transfers, setTransfers] = useState([]) + const [transferring, setTransferring] = useState(false) + const [transferQueueHeight, setTransferQueueHeight] = useState(128) + const [isDragging, setIsDragging] = useState(false) + const dragStartY = useRef(0) + const dragStartHeight = useRef(128) - const handleUpload = async () => { + const handleUpload = useCallback(async () => { if (!localSelected || !localSelected.path) return setTransferring(true) @@ -45,7 +42,7 @@ export const FileTransferPanel: React.FC = ({ const blob = await response.blob() const file = new File([blob], localSelected.name, { type: blob.type }) - await uploadFileToRemote(serverHost, port, file, '', password, (progress) => { + await uploadFileToRemote(serverHost, port, file, '', undefined, (progress) => { setTransfers((prev) => prev.map((t) => (t.id === transferId ? { ...t, progress } : t)) ) @@ -69,9 +66,9 @@ export const FileTransferPanel: React.FC = ({ } finally { setTransferring(false) } - } + }, [localSelected, serverHost, port]) - const handleDownload = async () => { + const handleDownload = useCallback(async () => { if (!remoteSelected) return setTransferring(true) @@ -87,7 +84,7 @@ export const FileTransferPanel: React.FC = ({ setTransfers((prev) => [...prev, newTransfer]) try { - await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', password, (progress) => { + await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', undefined, (progress) => { setTransfers((prev) => prev.map((t) => (t.id === transferId ? { ...t, progress } : t)) ) @@ -111,7 +108,7 @@ export const FileTransferPanel: React.FC = ({ } finally { setTransferring(false) } - } + }, [remoteSelected, serverHost, port]) const handleClearTransfers = () => { setTransfers((prev) => prev.filter((t) => t.status === 'transferring')) @@ -121,43 +118,64 @@ export const FileTransferPanel: React.FC = ({ setTransfers((prev) => prev.filter((t) => t.id !== id)) } + const handleDragStart = useCallback((e: React.MouseEvent) => { + setIsDragging(true) + dragStartY.current = e.clientY + dragStartHeight.current = transferQueueHeight + }, [transferQueueHeight]) + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return + const deltaY = dragStartY.current - e.clientY + const newHeight = Math.max(60, Math.min(400, dragStartHeight.current + deltaY)) + setTransferQueueHeight(newHeight) + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, [isDragging]) + return ( -
-
-
-

- 文件传输 -

- +
+
+
+
- -
-
- -
-
- -
+
+
+
+
+ +
= ({ onUpload, disabled, }) => { - const [currentPath, setCurrentPath] = React.useState('') - const [files, setFiles] = React.useState([]) - const [loading, setLoading] = React.useState(false) - const [pathHistory, setPathHistory] = React.useState(['']) + const [currentPath, setCurrentPath] = useState('') + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [pathHistory, setPathHistory] = useState(['']) - const loadFiles = React.useCallback(async (systemPath: string) => { + const loadFiles = useCallback(async (systemPath: string) => { setLoading(true) try { const items = await fetchSystemFiles(systemPath) @@ -34,7 +34,7 @@ export const LocalFilePanel: React.FC = ({ } }, []) - React.useEffect(() => { + useEffect(() => { loadFiles(currentPath) }, [currentPath, loadFiles]) @@ -49,9 +49,9 @@ export const LocalFilePanel: React.FC = ({ } } - const handleGoInto = (folder: FileItem) => { - setPathHistory([...pathHistory, folder.path]) - setCurrentPath(folder.path) + const handleGoInto = (file: FileItem) => { + setPathHistory([...pathHistory, file.path]) + setCurrentPath(file.path) onSelect(null) } @@ -66,14 +66,9 @@ export const LocalFilePanel: React.FC = ({ return parts[parts.length - 1] || currentPath } - const getFullPathDisplay = () => { - if (!currentPath) return '' - return currentPath - } - return ( -
-
+
+
- + {getDisplayPath()}
-
+
{ + if ((e.target as HTMLElement).classList.contains('space-y-1') || (e.target as HTMLElement).classList.contains('p-2')) { + onSelect(null) + } + }}> {loading ? (
加载中... @@ -111,28 +110,23 @@ export const LocalFilePanel: React.FC = ({ {files.map((file) => (
onSelect(file.type === 'dir' ? null : file)} + onClick={() => onSelect(selectedFile?.path === file.path ? null : file)} onDoubleClick={() => file.type === 'dir' && handleGoInto(file)} className={clsx( - 'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors', + 'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors text-sm', selectedFile?.path === file.path - ? 'bg-blue-100 dark:bg-blue-900/30' - : 'hover:bg-gray-100 dark:hover:bg-gray-700/50' + ? 'bg-gray-100 text-gray-800 dark:bg-gray-700/60 dark:text-gray-200' + : 'hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-200' )} > {file.type === 'dir' ? ( - + ) : ( - + )} - + {file.name} - {selectedFile?.path === file.path && ( -
- -
- )}
))}
@@ -143,9 +137,9 @@ export const LocalFilePanel: React.FC = ({
diff --git a/src/modules/remote/components/file-transfer/RemoteFilePanel.tsx b/src/modules/remote/components/file-transfer/RemoteFilePanel.tsx index 6d5156e..667ec24 100644 --- a/src/modules/remote/components/file-transfer/RemoteFilePanel.tsx +++ b/src/modules/remote/components/file-transfer/RemoteFilePanel.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { Folder, FileText, ArrowDown, ChevronRight, ChevronLeft, RefreshCw } from 'lucide-react' +import React, { useState, useEffect, useCallback } from 'react' +import { Folder, FileText, ChevronLeft, RefreshCw } from 'lucide-react' import { clsx } from 'clsx' import { fetchRemoteFiles, type RemoteFileItem } from '../../api' @@ -22,12 +22,12 @@ export const RemoteFilePanel: React.FC = ({ onDownload, disabled, }) => { - const [currentPath, setCurrentPath] = React.useState('') - const [files, setFiles] = React.useState([]) - const [loading, setLoading] = React.useState(false) - const [pathHistory, setPathHistory] = React.useState(['']) + const [currentPath, setCurrentPath] = useState('') + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [pathHistory, setPathHistory] = useState(['']) - const loadFiles = React.useCallback(async (path: string) => { + const loadFiles = useCallback(async (path: string) => { setLoading(true) try { const items = await fetchRemoteFiles(serverHost, port, path, password) @@ -40,7 +40,7 @@ export const RemoteFilePanel: React.FC = ({ } }, [serverHost, port, password]) - React.useEffect(() => { + useEffect(() => { loadFiles(currentPath) }, [currentPath, loadFiles]) @@ -55,8 +55,8 @@ export const RemoteFilePanel: React.FC = ({ } } - const handleGoInto = (folder: RemoteFileItem) => { - const newPath = currentPath ? `${currentPath}/${folder.name}` : folder.name + const handleGoInto = (file: RemoteFileItem) => { + const newPath = currentPath ? `${currentPath}/${file.name}` : file.name setPathHistory([...pathHistory, newPath]) setCurrentPath(newPath) onSelect(null) @@ -73,8 +73,8 @@ export const RemoteFilePanel: React.FC = ({ } return ( -
-
+
+
- + {getDisplayPath()}
-
+
{ + if ((e.target as HTMLElement).classList.contains('space-y-1') || (e.target as HTMLElement).classList.contains('p-2')) { + onSelect(null) + } + }}> {loading ? (
加载中... @@ -112,28 +116,23 @@ export const RemoteFilePanel: React.FC = ({ {files.map((file) => (
onSelect(file.type === 'dir' ? null : file)} + onClick={() => onSelect(selectedFile?.path === file.path ? null : file)} onDoubleClick={() => file.type === 'dir' && handleGoInto(file)} className={clsx( - 'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors', + 'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors text-sm', selectedFile?.path === file.path - ? 'bg-blue-100 dark:bg-blue-900/30' - : 'hover:bg-gray-100 dark:hover:bg-gray-700/50' + ? 'bg-gray-100 text-gray-800 dark:bg-gray-700/60 dark:text-gray-200' + : 'hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-200' )} > {file.type === 'dir' ? ( - + ) : ( - + )} - + {file.name} - {selectedFile?.path === file.path && ( -
- -
- )}
))}
@@ -144,9 +143,9 @@ export const RemoteFilePanel: React.FC = ({
diff --git a/src/modules/remote/components/file-transfer/TransferQueue.tsx b/src/modules/remote/components/file-transfer/TransferQueue.tsx index 90cfd21..a5bc0dd 100644 --- a/src/modules/remote/components/file-transfer/TransferQueue.tsx +++ b/src/modules/remote/components/file-transfer/TransferQueue.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { X, ArrowUp, ArrowDown, CheckCircle, XCircle, Loader } from 'lucide-react' +import { X, CheckCircle, XCircle, Loader } from 'lucide-react' import type { TransferItem } from '../../types' interface TransferQueueProps { @@ -9,13 +9,22 @@ interface TransferQueueProps { } export const TransferQueue: React.FC = ({ transfers, onClear, onRemove }) => { + const hasActiveTransfers = transfers.some(t => t.status === 'transferring') + const hasCompletedOrErrorTransfers = transfers.some(t => t.status === 'completed' || t.status === 'error') + if (transfers.length === 0) { - return null + return ( +
+ + 传输队列 + +
+ ) } return ( -
-
+
+
传输队列 ({transfers.length}) @@ -26,17 +35,15 @@ export const TransferQueue: React.FC = ({ transfers, onClear 清空
-
+
{transfers.map((transfer) => (
- {transfer.type === 'upload' ? ( - - ) : ( - - )} + + {transfer.type === 'upload' ? '↑' : '↓'} + {transfer.name}
= ({ transfers, onClear transfer.status === 'error' ? 'bg-red-500' : transfer.status === 'completed' - ? 'bg-green-500' - : 'bg-blue-500' + ? 'bg-gray-500' + : 'bg-gray-600' }`} style={{ width: `${transfer.progress}%` }} /> @@ -57,8 +64,8 @@ export const TransferQueue: React.FC = ({ transfers, onClear ? '失败' : `${transfer.progress}%`} - {transfer.status === 'transferring' && } - {transfer.status === 'completed' && } + {transfer.status === 'transferring' && } + {transfer.status === 'completed' && } {transfer.status === 'error' && }