diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 298cd551..63f11b57 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -6,6 +6,25 @@ import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { fileLogger } from "../utils/logger.js"; +// 可执行文件检测工具函数 +function isExecutableFile(permissions: string, fileName: string): boolean { + // 检查执行权限位 (user, group, other) + const hasExecutePermission = permissions[3] === 'x' || permissions[6] === 'x' || permissions[9] === 'x'; + + // 常见的脚本文件扩展名 + const scriptExtensions = ['.sh', '.py', '.pl', '.rb', '.js', '.php', '.bash', '.zsh', '.fish']; + const hasScriptExtension = scriptExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); + + // 常见的编译可执行文件(无扩展名或特定扩展名) + const executableExtensions = ['.bin', '.exe', '.out']; + const hasExecutableExtension = executableExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); + + // 无扩展名且有执行权限的文件通常是可执行文件 + const hasNoExtension = !fileName.includes('.') && hasExecutePermission; + + return hasExecutePermission && (hasScriptExtension || hasExecutableExtension || hasNoExtension); +} + const app = express(); app.use( @@ -349,7 +368,8 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { owner, group, linkTarget, // 符号链接的目标 - path: `${sshPath.endsWith('/') ? sshPath : sshPath + '/'}${actualName}` // 添加完整路径 + path: `${sshPath.endsWith('/') ? sshPath : sshPath + '/'}${actualName}`, // 添加完整路径 + executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false // 检测可执行文件 }); } } @@ -1856,6 +1876,108 @@ process.on("SIGTERM", () => { process.exit(0); }); +// 执行可执行文件 +app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { + const { sessionId, filePath, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sshConn || !sshConn.isConnected) { + fileLogger.error("SSH connection not found or not connected for executeFile", { + operation: "execute_file", + sessionId, + hasConnection: !!sshConn, + isConnected: sshConn?.isConnected + }); + return res.status(400).json({ error: "SSH connection not available" }); + } + + if (!filePath) { + return res.status(400).json({ error: "File path is required" }); + } + + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + + // 检查文件是否存在且可执行 + const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; + + sshConn.client.exec(checkCommand, (checkErr, checkStream) => { + if (checkErr) { + fileLogger.error("SSH executeFile check error:", checkErr); + return res.status(500).json({ error: "Failed to check file executability" }); + } + + let checkResult = ''; + checkStream.on("data", (data) => { + checkResult += data.toString(); + }); + + checkStream.on("close", (code) => { + if (!checkResult.includes("EXECUTABLE")) { + return res.status(400).json({ error: "File is not executable" }); + } + + // 执行文件 + const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; + + fileLogger.info("Executing file", { + operation: "execute_file", + sessionId, + filePath, + command: executeCommand.substring(0, 100) + "..." + }); + + sshConn.client.exec(executeCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH executeFile error:", err); + return res.status(500).json({ error: "Failed to execute file" }); + } + + let output = ''; + let errorOutput = ''; + + stream.on("data", (data) => { + output += data.toString(); + }); + + stream.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + stream.on("close", (code) => { + // 从输出中提取退出代码 + const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); + const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code; + const cleanOutput = output.replace(/EXIT_CODE:\d+$/, '').trim(); + + fileLogger.info("File execution completed", { + operation: "execute_file", + sessionId, + filePath, + exitCode: actualExitCode, + outputLength: cleanOutput.length, + errorLength: errorOutput.length + }); + + res.json({ + success: true, + exitCode: actualExitCode, + output: cleanOutput, + error: errorOutput, + timestamp: new Date().toISOString() + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH executeFile stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: "Execution stream error" }); + } + }); + }); + }); + }); +}); + const PORT = 8084; app.listen(PORT, () => { fileLogger.success("File Manager API server started", { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index a95bdabb..2e847a64 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -104,8 +104,18 @@ wss.on("connection", (ws: WebSocket) => { credentialId?: number; userId?: string; }; + initialPath?: string; + executeCommand?: string; }) { - const { cols, rows, hostConfig } = data; + const { cols, rows, hostConfig, initialPath, executeCommand } = data; + + sshLogger.debug("Terminal connection data received", { + operation: "terminal_connect_data", + hasInitialPath: !!initialPath, + initialPath, + hasExecuteCommand: !!executeCommand, + executeCommand + }); const { id, ip, @@ -302,6 +312,34 @@ wss.on("connection", (ws: WebSocket) => { setupPingInterval(); + // Change to initial path if specified + if (initialPath && initialPath.trim() !== "") { + sshLogger.debug(`Changing to initial path: ${initialPath}`, { + operation: "ssh_initial_path", + hostId: id, + path: initialPath, + }); + + // Send cd command to change directory + const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; + stream.write(cdCommand); + } + + // Execute command if specified + if (executeCommand && executeCommand.trim() !== "") { + sshLogger.debug(`Executing command: ${executeCommand}`, { + operation: "ssh_execute_command", + hostId: id, + command: executeCommand, + }); + + // Wait a moment for the cd command to complete, then execute the command + setTimeout(() => { + const command = `${executeCommand}\n`; + stream.write(command); + }, 500); + } + ws.send( JSON.stringify({ type: "connected", message: "SSH connected" }), ); diff --git a/src/types/index.ts b/src/types/index.ts index fa9c5e1f..b0ea7d37 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -190,6 +190,7 @@ export interface FileItem { owner?: string; group?: string; linkTarget?: string; + executable?: boolean; } export interface ShortcutItem { diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx index dbbbc72b..11f0d4c8 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx @@ -14,7 +14,9 @@ import { Clipboard, Eye, Share, - ExternalLink + ExternalLink, + Terminal, + Play } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -27,6 +29,7 @@ interface FileItem { permissions?: string; owner?: string; group?: string; + executable?: boolean; } interface ContextMenuProps { @@ -49,6 +52,9 @@ interface ContextMenuProps { onPreview?: (file: FileItem) => void; hasClipboard?: boolean; onDragToDesktop?: () => void; + onOpenTerminal?: (path: string) => void; + onRunExecutable?: (file: FileItem) => void; + currentPath?: string; } interface MenuItem { @@ -80,7 +86,10 @@ export function FileManagerContextMenu({ onPaste, onPreview, hasClipboard = false, - onDragToDesktop + onDragToDesktop, + onOpenTerminal, + onRunExecutable, + currentPath }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -181,12 +190,42 @@ export function FileManagerContextMenu({ const isMultipleFiles = files.length > 1; const hasFiles = files.some(f => f.type === 'file'); const hasDirectories = files.some(f => f.type === 'directory'); + const hasExecutableFiles = files.some(f => f.type === 'file' && f.executable); // 构建菜单项 const menuItems: MenuItem[] = []; if (isFileContext) { // 文件/文件夹选中时的菜单 + + // 打开终端功能 - 支持文件和文件夹 + if (onOpenTerminal) { + const targetPath = isSingleFile + ? (files[0].type === 'directory' ? files[0].path : files[0].path.substring(0, files[0].path.lastIndexOf('/'))) + : files[0].path.substring(0, files[0].path.lastIndexOf('/')); + + menuItems.push({ + icon: , + label: files[0].type === 'directory' ? "在此文件夹打开终端" : "在文件位置打开终端", + action: () => onOpenTerminal(targetPath), + shortcut: "Ctrl+T" + }); + } + + // 运行可执行文件功能 - 仅对单个可执行文件显示 + if (isSingleFile && hasExecutableFiles && onRunExecutable) { + menuItems.push({ + icon: , + label: "运行", + action: () => onRunExecutable(files[0]), + shortcut: "Enter" + }); + } + + if ((onOpenTerminal || (isSingleFile && hasExecutableFiles && onRunExecutable))) { + menuItems.push({ separator: true } as MenuItem); + } + if (hasFiles && onPreview) { menuItems.push({ icon: , @@ -278,6 +317,19 @@ export function FileManagerContextMenu({ } } else { // 空白区域右键菜单 + + // 在当前目录打开终端 + if (onOpenTerminal && currentPath) { + menuItems.push({ + icon: , + label: "在此处打开终端", + action: () => onOpenTerminal(currentPath), + shortcut: "Ctrl+T" + }); + + menuItems.push({ separator: true } as MenuItem); + } + if (onUpload) { menuItems.push({ icon: , diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx index 96beff88..d4fc3613 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -23,6 +23,7 @@ import { Eye, Settings } from "lucide-react"; +import { TerminalWindow } from "./components/TerminalWindow"; import type { SSHHost, FileItem } from "../../../types/index.js"; import { listSSHFiles, @@ -1137,6 +1138,100 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { } } + // 打开终端处理函数 + function handleOpenTerminal(path: string) { + if (!currentHost) { + toast.error("没有选择主机"); + return; + } + + console.log('Opening terminal at path:', path); + + // 创建终端窗口 + const windowCount = Date.now() % 10; + const offsetX = 200 + (windowCount * 40); + const offsetY = 150 + (windowCount * 40); + + const createTerminalComponent = (windowId: string) => ( + + ); + + openWindow({ + title: `终端 - ${currentHost.name}:${path}`, + x: offsetX, + y: offsetY, + width: 800, + height: 500, + isMaximized: false, + isMinimized: false, + component: createTerminalComponent + }); + + toast.success(`在 ${path} 打开终端`); + } + + // 运行可执行文件处理函数 + function handleRunExecutable(file: FileItem) { + if (!currentHost) { + toast.error("没有选择主机"); + return; + } + + if (file.type !== 'file' || !file.executable) { + toast.error("只能运行可执行文件"); + return; + } + + console.log('Running executable file:', file.path); + + // 获取文件所在目录 + const fileDir = file.path.substring(0, file.path.lastIndexOf('/')); + const fileName = file.name; + const executeCmd = `./${fileName}`; + + console.log('Execute command details:', { + filePath: file.path, + fileDir, + fileName, + executeCommand: executeCmd + }); + + // 创建执行用的终端窗口 + const windowCount = Date.now() % 10; + const offsetX = 250 + (windowCount * 40); + const offsetY = 200 + (windowCount * 40); + + const createExecutionTerminal = (windowId: string) => ( + + ); + + openWindow({ + title: `运行 - ${file.name}`, + x: offsetX, + y: offsetY, + width: 800, + height: 500, + isMaximized: false, + isMinimized: false, + component: createExecutionTerminal + }); + + toast.success(`正在运行: ${file.name}`); + } + // 过滤文件并添加新建的临时项目 let filteredFiles = files.filter(file => file.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -1320,6 +1415,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { onRefresh={() => loadDirectory(currentPath)} hasClipboard={!!clipboard} onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} + onOpenTerminal={(path) => handleOpenTerminal(path)} + onRunExecutable={(file) => handleRunExecutable(file)} + currentPath={currentPath} /> diff --git a/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx b/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx new file mode 100644 index 00000000..6af26512 --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/components/TerminalWindow.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { DraggableWindow } from './DraggableWindow'; +import { Terminal } from '../../Terminal/Terminal'; +import { useWindowManager } from './WindowManager'; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + authType: 'password' | 'key'; + credentialId?: number; + userId?: number; +} + +interface TerminalWindowProps { + windowId: string; + hostConfig: SSHHost; + initialPath?: string; + initialX?: number; + initialY?: number; + executeCommand?: string; +} + +export function TerminalWindow({ + windowId, + hostConfig, + initialPath, + initialX = 200, + initialY = 150, + executeCommand +}: TerminalWindowProps) { + console.log('TerminalWindow props:', { + windowId, + initialPath, + executeCommand, + hasExecuteCommand: !!executeCommand + }); + const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager(); + + // 获取当前窗口状态 + const currentWindow = windows.find(w => w.id === windowId); + if (!currentWindow) { + console.warn(`Window with id ${windowId} not found`); + return null; + } + + const handleClose = () => { + closeWindow(windowId); + }; + + const handleMinimize = () => { + minimizeWindow(windowId); + }; + + const handleMaximize = () => { + maximizeWindow(windowId); + }; + + const handleFocus = () => { + focusWindow(windowId); + }; + + const terminalTitle = executeCommand + ? `运行 - ${hostConfig.name}:${executeCommand}` + : initialPath + ? `终端 - ${hostConfig.name}:${initialPath}` + : `终端 - ${hostConfig.name}`; + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 0ce6db89..12f017cc 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -21,12 +21,21 @@ interface SSHTerminalProps { showTitle?: boolean; splitScreen?: boolean; onClose?: () => void; + initialPath?: string; + executeCommand?: string; } export const Terminal = forwardRef(function SSHTerminal( - { hostConfig, isVisible, splitScreen = false, onClose }, + { hostConfig, isVisible, splitScreen = false, onClose, initialPath, executeCommand }, ref, ) { + console.log('Terminal component props:', { + hasHostConfig: !!hostConfig, + isVisible, + initialPath, + executeCommand, + hasExecuteCommand: !!executeCommand + }); const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); const fitAddonRef = useRef(null); @@ -249,10 +258,13 @@ export const Terminal = forwardRef(function SSHTerminal( } }, 10000); + const connectionData = { cols, rows, hostConfig, initialPath, executeCommand }; + console.log('Sending terminal connection data:', connectionData); + ws.send( JSON.stringify({ type: "connectToHost", - data: { cols, rows, hostConfig }, + data: connectionData, }), ); terminal.onData((data) => {