diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c111363b..bf177ef1 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -726,7 +726,10 @@ "terminalWithPath": "Terminal - {{host}}:{{path}}", "runningFile": "Running - {{file}}", "onlyRunExecutableFiles": "Can only run executable files", - "noHostSelected": "No host selected" + "noHostSelected": "No host selected", + "starred": "Starred", + "shortcuts": "Shortcuts", + "directories": "Directories" }, "tunnels": { "title": "SSH Tunnels", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index dbaeb2f8..10889c2c 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -733,7 +733,10 @@ "sshStatusCheckTimeout": "SSH 状态检查超时", "sshReconnectionTimeout": "SSH 重新连接超时", "saveOperationTimeout": "保存操作超时", - "cannotSaveFile": "无法保存文件" + "cannotSaveFile": "无法保存文件", + "starred": "收藏", + "shortcuts": "快捷方式", + "directories": "目录" }, "tunnels": { "title": "SSH 隧道", diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx index 044f2f87..85437d0f 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx @@ -16,7 +16,9 @@ import { Share, ExternalLink, Terminal, - Play + Play, + Star, + Bookmark } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -54,6 +56,10 @@ interface ContextMenuProps { onDragToDesktop?: () => void; onOpenTerminal?: (path: string) => void; onRunExecutable?: (file: FileItem) => void; + onPinFile?: (file: FileItem) => void; + onUnpinFile?: (file: FileItem) => void; + onAddShortcut?: (path: string) => void; + isPinned?: (file: FileItem) => boolean; currentPath?: string; } @@ -89,6 +95,10 @@ export function FileManagerContextMenu({ onDragToDesktop, onOpenTerminal, onRunExecutable, + onPinFile, + onUnpinFile, + onAddShortcut, + isPinned, currentPath }: ContextMenuProps) { const { t } = useTranslation(); @@ -259,6 +269,34 @@ export function FileManagerContextMenu({ }); } + // PIN/UNPIN 功能 - 仅对单个文件显示 + if (isSingleFile && files[0].type === 'file') { + const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; + + if (isCurrentlyPinned && onUnpinFile) { + menuItems.push({ + icon: , + label: "取消固定", + action: () => onUnpinFile(files[0]) + }); + } else if (!isCurrentlyPinned && onPinFile) { + menuItems.push({ + icon: , + label: "固定文件", + action: () => onPinFile(files[0]) + }); + } + } + + // 添加文件夹快捷方式 - 仅对单个文件夹显示 + if (isSingleFile && files[0].type === 'directory' && onAddShortcut) { + menuItems.push({ + icon: , + label: "添加到快捷方式", + action: () => onAddShortcut(files[0].path) + }); + } + menuItems.push({ separator: true } as MenuItem); if (isSingleFile && onRename) { diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx index ed7e0c52..3c0f4f5d 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { FileManagerGrid } from "./FileManagerGrid"; +import { FileManagerSidebar } from "./FileManagerSidebar"; import { FileManagerContextMenu } from "./FileManagerContextMenu"; import { useFileSelection } from "./hooks/useFileSelection"; import { useDragAndDrop } from "./hooks/useDragAndDrop"; @@ -37,7 +38,12 @@ import { moveSSHItem, connectSSH, getSSHStatus, - identifySSHSymlink + identifySSHSymlink, + addRecentFile, + addPinnedFile, + removePinnedFile, + addFolderShortcut, + getPinnedFiles } from "@/ui/main-axios.ts"; @@ -59,6 +65,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { const [sshSessionId, setSshSessionId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [pinnedFiles, setPinnedFiles] = useState>(new Set()); + const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); // Context menu state const [contextMenu, setContextMenu] = useState<{ @@ -1222,6 +1230,130 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { toast.success(t("fileManager.runningFile", { file: file.name })); } + // 加载固定文件列表 + async function loadPinnedFiles() { + if (!currentHost?.id) return; + + try { + const pinnedData = await getPinnedFiles(currentHost.id); + const pinnedPaths = new Set(pinnedData.map((item: any) => item.path)); + setPinnedFiles(pinnedPaths); + } catch (error) { + console.error('Failed to load pinned files:', error); + } + } + + // PIN文件 + async function handlePinFile(file: FileItem) { + if (!currentHost?.id) return; + + try { + await addPinnedFile(currentHost.id, file.path, file.name); + setPinnedFiles(prev => new Set([...prev, file.path])); + setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新 + toast.success(`文件"${file.name}"已固定`); + } catch (error) { + console.error('Failed to pin file:', error); + toast.error('固定文件失败'); + } + } + + // UNPIN文件 + async function handleUnpinFile(file: FileItem) { + if (!currentHost?.id) return; + + try { + await removePinnedFile(currentHost.id, file.path); + setPinnedFiles(prev => { + const newSet = new Set(prev); + newSet.delete(file.path); + return newSet; + }); + setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新 + toast.success(`文件"${file.name}"已取消固定`); + } catch (error) { + console.error('Failed to unpin file:', error); + toast.error('取消固定失败'); + } + } + + // 添加文件夹快捷方式 + async function handleAddShortcut(path: string) { + if (!currentHost?.id) return; + + try { + const folderName = path.split('/').pop() || path; + await addFolderShortcut(currentHost.id, path, folderName); + setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新 + toast.success(`文件夹快捷方式"${folderName}"已添加`); + } catch (error) { + console.error('Failed to add shortcut:', error); + toast.error('添加快捷方式失败'); + } + } + + // 检查文件是否已固定 + function isPinnedFile(file: FileItem): boolean { + return pinnedFiles.has(file.path); + } + + // 记录最近访问的文件 + async function recordRecentFile(file: FileItem) { + if (!currentHost?.id || file.type === 'directory') return; + + try { + await addRecentFile(currentHost.id, file.path, file.name); + setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新 + } catch (error) { + console.error('Failed to record recent file:', error); + } + } + + // 处理文件打开 + async function handleFileOpen(file: FileItem) { + if (file.type === 'directory') { + // 如果是目录,切换到该目录 + setCurrentPath(file.path); + } else { + // 如果是文件,记录到最近访问并打开文件窗口 + await recordRecentFile(file); + + // 创建文件窗口 + const windowCount = Date.now() % 10; + const offsetX = 100 + (windowCount * 30); + const offsetY = 100 + (windowCount * 30); + + const createFileWindow = (windowId: string) => ( + + ); + + openWindow({ + title: file.name, + x: offsetX, + y: offsetY, + width: 800, + height: 600, + isMaximized: false, + isMinimized: false, + component: createFileWindow + }); + } + } + + // 加载固定文件列表(当主机或连接改变时) + useEffect(() => { + if (currentHost?.id) { + loadPinnedFiles(); + } + }, [currentHost?.id]); + // 过滤文件并添加新建的临时项目 let filteredFiles = files.filter(file => file.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -1347,8 +1479,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { {/* 主内容区域 */} -
- + {/* 左侧边栏 */} +
+ +
+ + {/* 右侧文件网格 */} +
+ {}} // 不再需要这个回调,使用onSelectionChange @@ -1407,8 +1553,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} onOpenTerminal={(path) => handleOpenTerminal(path)} onRunExecutable={(file) => handleRunExecutable(file)} + onPinFile={handlePinFile} + onUnpinFile={handleUnpinFile} + onAddShortcut={handleAddShortcut} + isPinned={isPinnedFile} currentPath={currentPath} /> +
diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx new file mode 100644 index 00000000..fe3a0270 --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx @@ -0,0 +1,293 @@ +import React, { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { + ChevronRight, + ChevronDown, + Folder, + File, + Star, + Clock, + Bookmark, + FolderOpen +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { SSHHost } from "../../../types/index.js"; +import { + getRecentFiles, + getPinnedFiles, + getFolderShortcuts, + listSSHFiles +} from "@/ui/main-axios.ts"; + +interface SidebarItem { + id: string; + name: string; + path: string; + type: 'recent' | 'pinned' | 'shortcut' | 'folder'; + lastAccessed?: string; + isExpanded?: boolean; + children?: SidebarItem[]; +} + +interface FileManagerSidebarProps { + currentHost: SSHHost; + currentPath: string; + onPathChange: (path: string) => void; + onLoadDirectory?: (path: string) => void; + sshSessionId?: string; + refreshTrigger?: number; // 用于触发数据刷新 +} + +export function FileManagerSidebar({ + currentHost, + currentPath, + onPathChange, + onLoadDirectory, + sshSessionId, + refreshTrigger +}: FileManagerSidebarProps) { + const { t } = useTranslation(); + const [recentItems, setRecentItems] = useState([]); + const [pinnedItems, setPinnedItems] = useState([]); + const [shortcuts, setShortcuts] = useState([]); + const [directoryTree, setDirectoryTree] = useState([]); + const [expandedFolders, setExpandedFolders] = useState>(new Set(['/'])); + + // 加载快捷功能数据 + useEffect(() => { + loadQuickAccessData(); + }, [currentHost, refreshTrigger]); + + // 加载目录树(依赖sshSessionId) + useEffect(() => { + if (sshSessionId) { + loadDirectoryTree(); + } + }, [sshSessionId]); + + const loadQuickAccessData = async () => { + if (!currentHost?.id) return; + + try { + // 加载最近访问文件(限制5个) + const recentData = await getRecentFiles(currentHost.id); + const recentItems = recentData.slice(0, 5).map((item: any) => ({ + id: `recent-${item.id}`, + name: item.name, + path: item.path, + type: 'recent' as const, + lastAccessed: item.lastOpened + })); + setRecentItems(recentItems); + + // 加载固定文件 + const pinnedData = await getPinnedFiles(currentHost.id); + const pinnedItems = pinnedData.map((item: any) => ({ + id: `pinned-${item.id}`, + name: item.name, + path: item.path, + type: 'pinned' as const + })); + setPinnedItems(pinnedItems); + + // 加载文件夹快捷方式 + const shortcutData = await getFolderShortcuts(currentHost.id); + const shortcutItems = shortcutData.map((item: any) => ({ + id: `shortcut-${item.id}`, + name: item.name, + path: item.path, + type: 'shortcut' as const + })); + setShortcuts(shortcutItems); + } catch (error) { + console.error('Failed to load quick access data:', error); + // 如果加载失败,保持空数组 + setRecentItems([]); + setPinnedItems([]); + setShortcuts([]); + } + }; + + const loadDirectoryTree = async () => { + if (!sshSessionId) return; + + try { + // 加载根目录 + const rootFiles = await listSSHFiles(sshSessionId, '/'); + const rootFolders = rootFiles.filter((item: any) => item.type === 'directory'); + + const rootTreeItems = rootFolders.map((folder: any) => ({ + id: `folder-${folder.name}`, + name: folder.name, + path: folder.path, + type: 'folder' as const, + isExpanded: false, + children: [] // 子目录将按需加载 + })); + + setDirectoryTree([ + { + id: 'root', + name: '/', + path: '/', + type: 'folder' as const, + isExpanded: true, + children: rootTreeItems + } + ]); + } catch (error) { + console.error('Failed to load directory tree:', error); + // 如果加载失败,显示简单的根目录 + setDirectoryTree([ + { + id: 'root', + name: '/', + path: '/', + type: 'folder' as const, + isExpanded: false, + children: [] + } + ]); + } + }; + + const handleItemClick = (item: SidebarItem) => { + if (item.type === 'folder') { + toggleFolder(item.id, item.path); + } + onPathChange(item.path); + }; + + const toggleFolder = async (folderId: string, folderPath?: string) => { + const newExpanded = new Set(expandedFolders); + + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId); + } else { + newExpanded.add(folderId); + + // 按需加载子目录 + if (sshSessionId && folderPath && folderPath !== '/') { + try { + const subFiles = await listSSHFiles(sshSessionId, folderPath); + const subFolders = subFiles.filter((item: any) => item.type === 'directory'); + + const subTreeItems = subFolders.map((folder: any) => ({ + id: `folder-${folder.path.replace(/\//g, '-')}`, + name: folder.name, + path: folder.path, + type: 'folder' as const, + isExpanded: false, + children: [] + })); + + // 更新目录树,为当前文件夹添加子目录 + setDirectoryTree(prevTree => { + const updateChildren = (items: SidebarItem[]): SidebarItem[] => { + return items.map(item => { + if (item.id === folderId) { + return { ...item, children: subTreeItems }; + } else if (item.children) { + return { ...item, children: updateChildren(item.children) }; + } + return item; + }); + }; + return updateChildren(prevTree); + }); + } catch (error) { + console.error('Failed to load subdirectory:', error); + } + } + } + + setExpandedFolders(newExpanded); + }; + + const renderSidebarItem = (item: SidebarItem, level: number = 0) => { + const isExpanded = expandedFolders.has(item.id); + const isActive = currentPath === item.path; + + return ( +
+
handleItemClick(item)} + > + {item.type === 'folder' && ( + + )} + + {item.type === 'folder' ? ( + isExpanded ? : + ) : ( + + )} + + {item.name} +
+ + {item.type === 'folder' && isExpanded && item.children && ( +
+ {item.children.map((child) => renderSidebarItem(child, level + 1))} +
+ )} +
+ ); + }; + + const renderSection = (title: string, icon: React.ReactNode, items: SidebarItem[]) => { + if (items.length === 0) return null; + + return ( +
+
+ {icon} + {title} +
+
+ {items.map((item) => renderSidebarItem(item))} +
+
+ ); + }; + + return ( +
+
+ {/* 快捷功能区域 */} + {renderSection(t("fileManager.recent"), , recentItems)} + {renderSection(t("fileManager.pinned"), , pinnedItems)} + {renderSection(t("fileManager.folderShortcuts"), , shortcuts)} + + {/* 目录树 */} +
+
+ + {t("fileManager.directories")} +
+
+ {directoryTree.map((item) => renderSidebarItem(item))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 2cba9213..91b71422 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1204,6 +1204,148 @@ export async function moveSSHItem( } } +// ============================================================================ +// FILE MANAGER DATA +// ============================================================================ + +// Recent Files +export async function getRecentFiles(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/recent", { + params: { hostId } + }); + return response.data; + } catch (error) { + handleApiError(error, "get recent files"); + throw error; + } +} + +export async function addRecentFile( + hostId: number, + path: string, + name?: string +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/recent", { + hostId, + path, + name + }); + return response.data; + } catch (error) { + handleApiError(error, "add recent file"); + throw error; + } +} + +export async function removeRecentFile( + hostId: number, + path: string +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/recent", { + data: { hostId, path } + }); + return response.data; + } catch (error) { + handleApiError(error, "remove recent file"); + throw error; + } +} + +// Pinned Files +export async function getPinnedFiles(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/pinned", { + params: { hostId } + }); + return response.data; + } catch (error) { + handleApiError(error, "get pinned files"); + throw error; + } +} + +export async function addPinnedFile( + hostId: number, + path: string, + name?: string +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/pinned", { + hostId, + path, + name + }); + return response.data; + } catch (error) { + handleApiError(error, "add pinned file"); + throw error; + } +} + +export async function removePinnedFile( + hostId: number, + path: string +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/pinned", { + data: { hostId, path } + }); + return response.data; + } catch (error) { + handleApiError(error, "remove pinned file"); + throw error; + } +} + +// Folder Shortcuts +export async function getFolderShortcuts(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/shortcuts", { + params: { hostId } + }); + return response.data; + } catch (error) { + handleApiError(error, "get folder shortcuts"); + throw error; + } +} + +export async function addFolderShortcut( + hostId: number, + path: string, + name?: string +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/shortcuts", { + hostId, + path, + name + }); + return response.data; + } catch (error) { + handleApiError(error, "add folder shortcut"); + throw error; + } +} + +export async function removeFolderShortcut( + hostId: number, + path: string +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/shortcuts", { + data: { hostId, path } + }); + return response.data; + } catch (error) { + handleApiError(error, "remove folder shortcut"); + throw error; + } +} + // ============================================================================ // SERVER STATISTICS // ============================================================================