修复文件管理器多个关键问题
- 修复侧边栏API路由问题:将数据API从fileManagerApi(8084)切换到authApi(8081) - 实现PIN功能:添加文件固定/取消固定功能,支持右键菜单操作 - 修复FileWindow组件props传递错误:正确传递file对象和sshHost参数 - 添加侧边栏数据刷新机制:PIN/Recent/Shortcut操作后自动更新显示 - 修复目录树展开问题:handleItemClick正确传递folderPath参数 - 新增FileManagerSidebar组件:支持Recent、Pinned、Shortcuts和目录树 主要修复: 1. API路由从localhost:8084/ssh/file_manager/* 修正为 localhost:8081/ssh/file_manager/* 2. 双击文件不再报错"Cannot read properties of undefined (reading 'name')" 3. 侧边栏实时同步数据更新,提升用户体验
This commit is contained in:
@@ -726,7 +726,10 @@
|
|||||||
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||||
"runningFile": "Running - {{file}}",
|
"runningFile": "Running - {{file}}",
|
||||||
"onlyRunExecutableFiles": "Can only run executable files",
|
"onlyRunExecutableFiles": "Can only run executable files",
|
||||||
"noHostSelected": "No host selected"
|
"noHostSelected": "No host selected",
|
||||||
|
"starred": "Starred",
|
||||||
|
"shortcuts": "Shortcuts",
|
||||||
|
"directories": "Directories"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH Tunnels",
|
"title": "SSH Tunnels",
|
||||||
|
|||||||
@@ -733,7 +733,10 @@
|
|||||||
"sshStatusCheckTimeout": "SSH 状态检查超时",
|
"sshStatusCheckTimeout": "SSH 状态检查超时",
|
||||||
"sshReconnectionTimeout": "SSH 重新连接超时",
|
"sshReconnectionTimeout": "SSH 重新连接超时",
|
||||||
"saveOperationTimeout": "保存操作超时",
|
"saveOperationTimeout": "保存操作超时",
|
||||||
"cannotSaveFile": "无法保存文件"
|
"cannotSaveFile": "无法保存文件",
|
||||||
|
"starred": "收藏",
|
||||||
|
"shortcuts": "快捷方式",
|
||||||
|
"directories": "目录"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH 隧道",
|
"title": "SSH 隧道",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
Share,
|
Share,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Terminal,
|
Terminal,
|
||||||
Play
|
Play,
|
||||||
|
Star,
|
||||||
|
Bookmark
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -54,6 +56,10 @@ interface ContextMenuProps {
|
|||||||
onDragToDesktop?: () => void;
|
onDragToDesktop?: () => void;
|
||||||
onOpenTerminal?: (path: string) => void;
|
onOpenTerminal?: (path: string) => void;
|
||||||
onRunExecutable?: (file: FileItem) => void;
|
onRunExecutable?: (file: FileItem) => void;
|
||||||
|
onPinFile?: (file: FileItem) => void;
|
||||||
|
onUnpinFile?: (file: FileItem) => void;
|
||||||
|
onAddShortcut?: (path: string) => void;
|
||||||
|
isPinned?: (file: FileItem) => boolean;
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +95,10 @@ export function FileManagerContextMenu({
|
|||||||
onDragToDesktop,
|
onDragToDesktop,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
onRunExecutable,
|
onRunExecutable,
|
||||||
|
onPinFile,
|
||||||
|
onUnpinFile,
|
||||||
|
onAddShortcut,
|
||||||
|
isPinned,
|
||||||
currentPath
|
currentPath
|
||||||
}: ContextMenuProps) {
|
}: ContextMenuProps) {
|
||||||
const { t } = useTranslation();
|
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: <Star className="w-4 h-4 fill-yellow-400" />,
|
||||||
|
label: "取消固定",
|
||||||
|
action: () => onUnpinFile(files[0])
|
||||||
|
});
|
||||||
|
} else if (!isCurrentlyPinned && onPinFile) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Star className="w-4 h-4" />,
|
||||||
|
label: "固定文件",
|
||||||
|
action: () => onPinFile(files[0])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文件夹快捷方式 - 仅对单个文件夹显示
|
||||||
|
if (isSingleFile && files[0].type === 'directory' && onAddShortcut) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Bookmark className="w-4 h-4" />,
|
||||||
|
label: "添加到快捷方式",
|
||||||
|
action: () => onAddShortcut(files[0].path)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
|
|
||||||
if (isSingleFile && onRename) {
|
if (isSingleFile && onRename) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { FileManagerGrid } from "./FileManagerGrid";
|
import { FileManagerGrid } from "./FileManagerGrid";
|
||||||
|
import { FileManagerSidebar } from "./FileManagerSidebar";
|
||||||
import { FileManagerContextMenu } from "./FileManagerContextMenu";
|
import { FileManagerContextMenu } from "./FileManagerContextMenu";
|
||||||
import { useFileSelection } from "./hooks/useFileSelection";
|
import { useFileSelection } from "./hooks/useFileSelection";
|
||||||
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
||||||
@@ -37,7 +38,12 @@ import {
|
|||||||
moveSSHItem,
|
moveSSHItem,
|
||||||
connectSSH,
|
connectSSH,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
identifySSHSymlink
|
identifySSHSymlink,
|
||||||
|
addRecentFile,
|
||||||
|
addPinnedFile,
|
||||||
|
removePinnedFile,
|
||||||
|
addFolderShortcut,
|
||||||
|
getPinnedFiles
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
@@ -1222,6 +1230,130 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
toast.success(t("fileManager.runningFile", { file: file.name }));
|
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) => (
|
||||||
|
<FileWindow
|
||||||
|
windowId={windowId}
|
||||||
|
file={file}
|
||||||
|
sshHost={currentHost!}
|
||||||
|
sshSessionId={sshSessionId!}
|
||||||
|
initialX={offsetX}
|
||||||
|
initialY={offsetY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 =>
|
let filteredFiles = files.filter(file =>
|
||||||
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
@@ -1347,8 +1479,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
<div className="flex-1 relative" {...dragHandlers}>
|
<div className="flex-1 flex" {...dragHandlers}>
|
||||||
<FileManagerGrid
|
{/* 左侧边栏 */}
|
||||||
|
<div className="w-64 flex-shrink-0">
|
||||||
|
<FileManagerSidebar
|
||||||
|
currentHost={currentHost}
|
||||||
|
currentPath={currentPath}
|
||||||
|
onPathChange={setCurrentPath}
|
||||||
|
onLoadDirectory={loadDirectory}
|
||||||
|
sshSessionId={sshSessionId}
|
||||||
|
refreshTrigger={sidebarRefreshTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧文件网格 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<FileManagerGrid
|
||||||
files={filteredFiles}
|
files={filteredFiles}
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
onFileSelect={() => {}} // 不再需要这个回调,使用onSelectionChange
|
onFileSelect={() => {}} // 不再需要这个回调,使用onSelectionChange
|
||||||
@@ -1407,8 +1553,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
|||||||
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
|
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
|
||||||
onOpenTerminal={(path) => handleOpenTerminal(path)}
|
onOpenTerminal={(path) => handleOpenTerminal(path)}
|
||||||
onRunExecutable={(file) => handleRunExecutable(file)}
|
onRunExecutable={(file) => handleRunExecutable(file)}
|
||||||
|
onPinFile={handlePinFile}
|
||||||
|
onUnpinFile={handleUnpinFile}
|
||||||
|
onAddShortcut={handleAddShortcut}
|
||||||
|
isPinned={isPinnedFile}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
293
src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx
Normal file
293
src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx
Normal file
@@ -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<SidebarItem[]>([]);
|
||||||
|
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
|
||||||
|
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
|
||||||
|
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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 (
|
||||||
|
<div key={item.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-2 py-1 text-sm cursor-pointer hover:bg-dark-hover rounded",
|
||||||
|
isActive && "bg-primary/20 text-primary",
|
||||||
|
"text-white"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${8 + level * 16}px` }}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
{item.type === 'folder' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFolder(item.id, item.path);
|
||||||
|
}}
|
||||||
|
className="p-0.5 hover:bg-dark-hover rounded"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.type === 'folder' ? (
|
||||||
|
isExpanded ? <FolderOpen className="w-4 h-4" /> : <Folder className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<File className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.type === 'folder' && isExpanded && item.children && (
|
||||||
|
<div>
|
||||||
|
{item.children.map((child) => renderSidebarItem(child, level + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (title: string, icon: React.ReactNode, items: SidebarItem[]) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{items.map((item) => renderSidebarItem(item))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-4">
|
||||||
|
{/* 快捷功能区域 */}
|
||||||
|
{renderSection(t("fileManager.recent"), <Clock className="w-3 h-3" />, recentItems)}
|
||||||
|
{renderSection(t("fileManager.pinned"), <Star className="w-3 h-3" />, pinnedItems)}
|
||||||
|
{renderSection(t("fileManager.folderShortcuts"), <Bookmark className="w-3 h-3" />, shortcuts)}
|
||||||
|
|
||||||
|
{/* 目录树 */}
|
||||||
|
<div className="border-t border-dark-border pt-4">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Folder className="w-3 h-3" />
|
||||||
|
{t("fileManager.directories")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
{directoryTree.map((item) => renderSidebarItem(item))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1204,6 +1204,148 @@ export async function moveSSHItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILE MANAGER DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Recent Files
|
||||||
|
export async function getRecentFiles(hostId: number): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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
|
// SERVER STATISTICS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user