diff --git a/package-lock.json b/package-lock.json index 7510bd54..dc2db2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,12 +56,14 @@ "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", + "next-themes": "^0.4.6", "node-fetch": "^3.3.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", + "sonner": "^2.0.7", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", @@ -6970,6 +6972,16 @@ "node": ">= 0.6" } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-abi": { "version": "3.75.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", @@ -7877,6 +7889,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 792975c5..e53653c2 100644 --- a/package.json +++ b/package.json @@ -60,12 +60,14 @@ "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", + "next-themes": "^0.4.6", "node-fetch": "^3.3.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", + "sonner": "^2.0.7", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", diff --git a/src/App.tsx b/src/App.tsx index 45af031a..875aa6a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx" import axios from "axios" import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/Admin/AdminSettings"; +import { Toaster } from "@/components/ui/sonner"; const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; const API = axios.create({baseURL: apiBase}); @@ -222,6 +223,13 @@ function AppContent() { )} + ) } diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index e0c21fb9..e3eb0e33 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -10,7 +10,13 @@ app.use(cors({ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); -app.use(express.json()); + +// Increase JSON body parser limit for larger file uploads +app.use(express.json({ limit: '100mb' })); +app.use(express.urlencoded({ limit: '100mb', extended: true })); + +// Add raw body parser for very large files +app.use(express.raw({ limit: '200mb', type: 'application/octet-stream' })); const sshIconSymbol = '📁'; const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); @@ -303,40 +309,106 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { sshConn.lastActive = Date.now(); scheduleSessionCleanup(sessionId); - const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); - const escapedFilePath = filePath.replace(/'/g, "'\"'\"'"); - - const base64Content = Buffer.from(content, 'utf8').toString('base64'); - const commandTimeout = setTimeout(() => { logger.error(`SSH writeFile command timed out for session: ${sessionId}`); if (!res.headersSent) { res.status(500).json({error: 'SSH command timed out'}); } - }, 15000); + }, 60000); // Increased timeout to 60 seconds - const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`; + // Try SFTP first, fallback to command line if it fails + const trySFTP = () => { + try { + sshConn.client.sftp((err, sftp) => { + if (err) { + logger.warn(`SFTP failed, trying fallback method: ${err.message}`); + tryFallbackMethod(); + return; + } - sshConn.client.exec(checkCommand, (checkErr, checkStream) => { - if (checkErr) { - return res.status(500).json({error: `File check failed: ${checkErr.message}`}); + // Convert content to buffer + let fileBuffer; + try { + if (typeof content === 'string') { + fileBuffer = Buffer.from(content, 'utf8'); + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + clearTimeout(commandTimeout); + logger.error('Buffer conversion error:', bufferErr); + if (!res.headersSent) { + return res.status(500).json({error: 'Invalid file content format'}); + } + return; + } + + // Create write stream with error handling + const writeStream = sftp.createWriteStream(filePath); + + let hasError = false; + let hasFinished = false; + + writeStream.on('error', (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); + tryFallbackMethod(); + }); + + writeStream.on('finish', () => { + if (hasError || hasFinished) return; + hasFinished = true; + clearTimeout(commandTimeout); + logger.success(`File written successfully via SFTP: ${filePath}`); + if (!res.headersSent) { + res.json({message: 'File written successfully', path: filePath}); + } + }); + + writeStream.on('close', () => { + if (hasError || hasFinished) return; + hasFinished = true; + clearTimeout(commandTimeout); + logger.success(`File written successfully via SFTP: ${filePath}`); + if (!res.headersSent) { + res.json({message: 'File written successfully', path: filePath}); + } + }); + + // Write the buffer to the stream + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); + tryFallbackMethod(); + } + }); + } catch (sftpErr) { + logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); + tryFallbackMethod(); } + }; - let checkResult = ''; - checkStream.on('data', (chunk: Buffer) => { - checkResult += chunk.toString(); - }); - - checkStream.on('close', (checkCode) => { - const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`; + // Fallback method using command line + const tryFallbackMethod = () => { + try { + const base64Content = Buffer.from(content, 'utf8').toString('base64'); + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + sshConn.client.exec(writeCommand, (err, stream) => { if (err) { clearTimeout(commandTimeout); - logger.error('SSH writeFile error:', err); + logger.error('Fallback write command failed:', err); if (!res.headersSent) { - return res.status(500).json({error: err.message}); + return res.status(500).json({error: `Write failed: ${err.message}`}); } return; } @@ -350,76 +422,678 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => { stream.stderr.on('data', (chunk: Buffer) => { errorData += chunk.toString(); - - if (chunk.toString().includes('Permission denied')) { - clearTimeout(commandTimeout); - logger.error(`Permission denied writing to file: ${filePath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.` - }); - } - return; - } }); stream.on('close', (code) => { clearTimeout(commandTimeout); - + if (outputData.includes('SUCCESS')) { - const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`; - - sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => { - if (verifyErr) { - if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); - } - return; - } - - let verifyResult = ''; - verifyStream.on('data', (chunk: Buffer) => { - verifyResult += chunk.toString(); - }); - - verifyStream.on('close', (verifyCode) => { - const fileSize = Number(verifyResult.trim()); - - if (fileSize > 0) { - if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); - } - } else { - if (!res.headersSent) { - res.status(500).json({error: 'File write operation may have failed - file appears empty'}); - } - } - }); - }); - return; - } - - if (code !== 0) { - logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + logger.success(`File written successfully via fallback: ${filePath}`); if (!res.headersSent) { - return res.status(500).json({error: `Command failed: ${errorData}`}); + res.json({message: 'File written successfully', path: filePath}); + } + } else { + logger.error(`Fallback write failed with code ${code}: ${errorData}`); + if (!res.headersSent) { + res.status(500).json({error: `Write failed: ${errorData}`}); } - return; - } - - if (!res.headersSent) { - res.json({message: 'File written successfully', path: filePath}); } }); stream.on('error', (streamErr) => { clearTimeout(commandTimeout); - logger.error('SSH writeFile stream error:', streamErr); + logger.error('Fallback write stream error:', streamErr); if (!res.headersSent) { - res.status(500).json({error: `Stream error: ${streamErr.message}`}); + res.status(500).json({error: `Write stream error: ${streamErr.message}`}); } }); }); + } catch (fallbackErr) { + clearTimeout(commandTimeout); + logger.error('Fallback method failed:', fallbackErr); + if (!res.headersSent) { + res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`}); + } + } + }; + + // Start with SFTP + trySFTP(); +}); + +// Upload file route +app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { + const {sessionId, path: filePath, content, fileName} = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({error: 'Session ID is required'}); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({error: 'SSH connection not established'}); + } + + if (!filePath || !fileName || content === undefined) { + return res.status(400).json({error: 'File path, name, and content are required'}); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; + + const commandTimeout = setTimeout(() => { + logger.error(`SSH uploadFile command timed out for session: ${sessionId}`); + if (!res.headersSent) { + res.status(500).json({error: 'SSH command timed out'}); + } + }, 60000); // Increased timeout to 60 seconds + + // Try SFTP first, fallback to command line if it fails + const trySFTP = () => { + try { + sshConn.client.sftp((err, sftp) => { + if (err) { + logger.warn(`SFTP failed, trying fallback method: ${err.message}`); + tryFallbackMethod(); + return; + } + + // Convert content to buffer + let fileBuffer; + try { + if (typeof content === 'string') { + fileBuffer = Buffer.from(content, 'utf8'); + } else if (Buffer.isBuffer(content)) { + fileBuffer = content; + } else { + fileBuffer = Buffer.from(content); + } + } catch (bufferErr) { + clearTimeout(commandTimeout); + logger.error('Buffer conversion error:', bufferErr); + if (!res.headersSent) { + return res.status(500).json({error: 'Invalid file content format'}); + } + return; + } + + // Create write stream with error handling + const writeStream = sftp.createWriteStream(fullPath); + + let hasError = false; + let hasFinished = false; + + writeStream.on('error', (streamErr) => { + if (hasError || hasFinished) return; + hasError = true; + logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`); + tryFallbackMethod(); + }); + + writeStream.on('finish', () => { + if (hasError || hasFinished) return; + hasFinished = true; + clearTimeout(commandTimeout); + logger.success(`File uploaded successfully via SFTP: ${fullPath}`); + if (!res.headersSent) { + res.json({message: 'File uploaded successfully', path: fullPath}); + } + }); + + writeStream.on('close', () => { + if (hasError || hasFinished) return; + hasFinished = true; + clearTimeout(commandTimeout); + logger.success(`File uploaded successfully via SFTP: ${fullPath}`); + if (!res.headersSent) { + res.json({message: 'File uploaded successfully', path: fullPath}); + } + }); + + // Write the buffer to the stream + try { + writeStream.write(fileBuffer); + writeStream.end(); + } catch (writeErr) { + if (hasError || hasFinished) return; + hasError = true; + logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`); + tryFallbackMethod(); + } + }); + } catch (sftpErr) { + logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`); + tryFallbackMethod(); + } + }; + + // Fallback method using command line with chunked approach + const tryFallbackMethod = () => { + try { + // Convert content to base64 and split into smaller chunks if needed + const base64Content = Buffer.from(content, 'utf8').toString('base64'); + const chunkSize = 1000000; // 1MB chunks + const chunks = []; + + for (let i = 0; i < base64Content.length; i += chunkSize) { + chunks.push(base64Content.slice(i, i + chunkSize)); + } + + if (chunks.length === 1) { + // Single chunk - use simple approach + const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; + + sshConn.client.exec(writeCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('Fallback upload command failed:', err); + if (!res.headersSent) { + return res.status(500).json({error: `Upload failed: ${err.message}`}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + logger.success(`File uploaded successfully via fallback: ${fullPath}`); + if (!res.headersSent) { + res.json({message: 'File uploaded successfully', path: fullPath}); + } + } else { + logger.error(`Fallback upload failed with code ${code}: ${errorData}`); + if (!res.headersSent) { + res.status(500).json({error: `Upload failed: ${errorData}`}); + } + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('Fallback upload stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Upload stream error: ${streamErr.message}`}); + } + }); + }); + } else { + // Multiple chunks - use chunked approach + const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + let writeCommand = `> '${escapedPath}'`; // Start with empty file + + chunks.forEach((chunk, index) => { + writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`; + }); + + writeCommand += ` && echo "SUCCESS"`; + + sshConn.client.exec(writeCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('Chunked fallback upload failed:', err); + if (!res.headersSent) { + return res.status(500).json({error: `Chunked upload failed: ${err.message}`}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); + if (!res.headersSent) { + res.json({message: 'File uploaded successfully', path: fullPath}); + } + } else { + logger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`); + if (!res.headersSent) { + res.status(500).json({error: `Chunked upload failed: ${errorData}`}); + } + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('Chunked fallback upload stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`}); + } + }); + }); + } + } catch (fallbackErr) { + clearTimeout(commandTimeout); + logger.error('Fallback method failed:', fallbackErr); + if (!res.headersSent) { + res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`}); + } + } + }; + + // Start with SFTP + trySFTP(); +}); + +// Create new file route +app.post('/ssh/file_manager/ssh/createFile', (req, res) => { + const {sessionId, path: filePath, fileName, content = ''} = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({error: 'Session ID is required'}); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({error: 'SSH connection not established'}); + } + + if (!filePath || !fileName) { + return res.status(400).json({error: 'File path and name are required'}); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName; + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const commandTimeout = setTimeout(() => { + logger.error(`SSH createFile command timed out for session: ${sessionId}`); + if (!res.headersSent) { + res.status(500).json({error: 'SSH command timed out'}); + } + }, 15000); + + const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(createCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('SSH createFile error:', err); + if (!res.headersSent) { + return res.status(500).json({error: err.message}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes('Permission denied')) { + clearTimeout(commandTimeout); + logger.error(`Permission denied creating file: ${fullPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.` + }); + } + return; + } + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({message: 'File created successfully', path: fullPath}); + } + return; + } + + if (code !== 0) { + logger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + if (!res.headersSent) { + return res.status(500).json({error: `Command failed: ${errorData}`}); + } + return; + } + + if (!res.headersSent) { + res.json({message: 'File created successfully', path: fullPath}); + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('SSH createFile stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Stream error: ${streamErr.message}`}); + } + }); + }); +}); + +// Create folder route +app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { + const {sessionId, path: folderPath, folderName} = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({error: 'Session ID is required'}); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({error: 'SSH connection not established'}); + } + + if (!folderPath || !folderName) { + return res.status(400).json({error: 'Folder path and name are required'}); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName; + const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); + + const commandTimeout = setTimeout(() => { + logger.error(`SSH createFolder command timed out for session: ${sessionId}`); + if (!res.headersSent) { + res.status(500).json({error: 'SSH command timed out'}); + } + }, 15000); + + const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(createCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('SSH createFolder error:', err); + if (!res.headersSent) { + return res.status(500).json({error: err.message}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes('Permission denied')) { + clearTimeout(commandTimeout); + logger.error(`Permission denied creating folder: ${fullPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.` + }); + } + return; + } + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({message: 'Folder created successfully', path: fullPath}); + } + return; + } + + if (code !== 0) { + logger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + if (!res.headersSent) { + return res.status(500).json({error: `Command failed: ${errorData}`}); + } + return; + } + + if (!res.headersSent) { + res.json({message: 'Folder created successfully', path: fullPath}); + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('SSH createFolder stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Stream error: ${streamErr.message}`}); + } + }); + }); +}); + +// Delete file/folder route +app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { + const {sessionId, path: itemPath, isDirectory} = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({error: 'Session ID is required'}); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({error: 'SSH connection not established'}); + } + + if (!itemPath) { + return res.status(400).json({error: 'Item path is required'}); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); + + const commandTimeout = setTimeout(() => { + logger.error(`SSH deleteItem command timed out for session: ${sessionId}`); + if (!res.headersSent) { + res.status(500).json({error: 'SSH command timed out'}); + } + }, 15000); + + const deleteCommand = isDirectory + ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` + : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(deleteCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('SSH deleteItem error:', err); + if (!res.headersSent) { + return res.status(500).json({error: err.message}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes('Permission denied')) { + clearTimeout(commandTimeout); + logger.error(`Permission denied deleting: ${itemPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.` + }); + } + return; + } + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({message: 'Item deleted successfully', path: itemPath}); + } + return; + } + + if (code !== 0) { + logger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + if (!res.headersSent) { + return res.status(500).json({error: `Command failed: ${errorData}`}); + } + return; + } + + if (!res.headersSent) { + res.json({message: 'Item deleted successfully', path: itemPath}); + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('SSH deleteItem stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Stream error: ${streamErr.message}`}); + } + }); + }); +}); + +// Rename file/folder route +app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { + const {sessionId, oldPath, newName} = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({error: 'Session ID is required'}); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({error: 'SSH connection not established'}); + } + + if (!oldPath || !newName) { + return res.status(400).json({error: 'Old path and new name are required'}); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1); + const newPath = oldDir + newName; + const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); + const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); + + const commandTimeout = setTimeout(() => { + logger.error(`SSH renameItem command timed out for session: ${sessionId}`); + if (!res.headersSent) { + res.status(500).json({error: 'SSH command timed out'}); + } + }, 15000); + + const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(renameCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + logger.error('SSH renameItem error:', err); + if (!res.headersSent) { + return res.status(500).json({error: err.message}); + } + return; + } + + let outputData = ''; + let errorData = ''; + + stream.on('data', (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on('data', (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes('Permission denied')) { + clearTimeout(commandTimeout); + logger.error(`Permission denied renaming: ${oldPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.` + }); + } + return; + } + }); + + stream.on('close', (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes('SUCCESS')) { + if (!res.headersSent) { + res.json({message: 'Item renamed successfully', oldPath, newPath}); + } + return; + } + + if (code !== 0) { + logger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`); + if (!res.headersSent) { + return res.status(500).json({error: `Command failed: ${errorData}`}); + } + return; + } + + if (!res.headersSent) { + res.json({message: 'Item renamed successfully', oldPath, newPath}); + } + }); + + stream.on('error', (streamErr) => { + clearTimeout(commandTimeout); + logger.error('SSH renameItem stream error:', streamErr); + if (!res.headersSent) { + res.status(500).json({error: `Stream error: ${streamErr.message}`}); + } }); }); }); diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 00000000..cd62aff2 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/index.css b/src/index.css index fafaa8f0..c4fa881e 100644 --- a/src/index.css +++ b/src/index.css @@ -152,4 +152,29 @@ border-radius: 9999px; border: 2px solid transparent; background-clip: content-box; +} + +/* Thin scrollbar styles */ +.thin-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.thin-scrollbar::-webkit-scrollbar-track { + background: #18181b; +} + +.thin-scrollbar::-webkit-scrollbar-thumb { + background: #434345; + border-radius: 3px; +} + +.thin-scrollbar::-webkit-scrollbar-thumb:hover { + background: #5a5a5d; +} + +/* Custom scrollbar for Firefox */ +.thin-scrollbar { + scrollbar-width: thin; + scrollbar-color: #434345 #18181b; } \ No newline at end of file diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index 01c07bf8..8975727d 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -1,9 +1,18 @@ -import React from "react"; +import React, {useState} from "react"; import {useSidebar} from "@/components/ui/sidebar"; import {Button} from "@/components/ui/button.tsx"; -import {ChevronDown, ChevronUpIcon} from "lucide-react"; +import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react"; import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Checkbox} from "@/components/ui/checkbox.tsx"; +import {Separator} from "@/components/ui/separator.tsx"; interface TopNavbarProps { isTopbarOpen: boolean; @@ -15,6 +24,11 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const leftPosition = state === "collapsed" ? "26px" : "264px"; + // SSH Tools state + const [toolsSheetOpen, setToolsSheetOpen] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [selectedTabIds, setSelectedTabIds] = useState([]); + const handleTabActivate = (tabId: number) => { setCurrentTab(tabId); }; @@ -27,12 +41,188 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac removeTab(tabId); }; + const handleTabToggle = (tabId: number) => { + setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]); + }; + + const handleStartRecording = () => { + setIsRecording(true); + // Focus on the input when recording starts + setTimeout(() => { + const input = document.getElementById('ssh-tools-input') as HTMLInputElement; + if (input) input.focus(); + }, 100); + }; + + const handleStopRecording = () => { + setIsRecording(false); + setSelectedTabIds([]); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + // Don't handle input change for special keys - let onKeyDown handle them + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (selectedTabIds.length === 0) return; + + const value = e.currentTarget.value; + let commandToSend = ''; + + // Handle special keys and control sequences + if (e.ctrlKey || e.metaKey) { + // Control sequences + if (e.key === 'c') { + commandToSend = '\x03'; // Ctrl+C (SIGINT) + e.preventDefault(); + } else if (e.key === 'd') { + commandToSend = '\x04'; // Ctrl+D (EOF) + e.preventDefault(); + } else if (e.key === 'l') { + commandToSend = '\x0c'; // Ctrl+L (clear screen) + e.preventDefault(); + } else if (e.key === 'u') { + commandToSend = '\x15'; // Ctrl+U (clear line) + e.preventDefault(); + } else if (e.key === 'k') { + commandToSend = '\x0b'; // Ctrl+K (clear from cursor to end) + e.preventDefault(); + } else if (e.key === 'a') { + commandToSend = '\x01'; // Ctrl+A (move to beginning of line) + e.preventDefault(); + } else if (e.key === 'e') { + commandToSend = '\x05'; // Ctrl+E (move to end of line) + e.preventDefault(); + } else if (e.key === 'w') { + commandToSend = '\x17'; // Ctrl+W (delete word before cursor) + e.preventDefault(); + } + } else if (e.key === 'Enter') { + commandToSend = '\n'; + e.preventDefault(); + } else if (e.key === 'Backspace') { + commandToSend = '\x08'; // Backspace + e.preventDefault(); + } else if (e.key === 'Delete') { + commandToSend = '\x7f'; // Delete + e.preventDefault(); + } else if (e.key === 'Tab') { + commandToSend = '\x09'; // Tab + e.preventDefault(); + } else if (e.key === 'Escape') { + commandToSend = '\x1b'; // Escape + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + commandToSend = '\x1b[A'; // Up arrow + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + commandToSend = '\x1b[B'; // Down arrow + e.preventDefault(); + } else if (e.key === 'ArrowLeft') { + commandToSend = '\x1b[D'; // Left arrow + e.preventDefault(); + } else if (e.key === 'ArrowRight') { + commandToSend = '\x1b[C'; // Right arrow + e.preventDefault(); + } else if (e.key === 'Home') { + commandToSend = '\x1b[H'; // Home + e.preventDefault(); + } else if (e.key === 'End') { + commandToSend = '\x1b[F'; // End + e.preventDefault(); + } else if (e.key === 'PageUp') { + commandToSend = '\x1b[5~'; // Page Up + e.preventDefault(); + } else if (e.key === 'PageDown') { + commandToSend = '\x1b[6~'; // Page Down + e.preventDefault(); + } else if (e.key === 'Insert') { + commandToSend = '\x1b[2~'; // Insert + e.preventDefault(); + } else if (e.key === 'F1') { + commandToSend = '\x1bOP'; // F1 + e.preventDefault(); + } else if (e.key === 'F2') { + commandToSend = '\x1bOQ'; // F2 + e.preventDefault(); + } else if (e.key === 'F3') { + commandToSend = '\x1bOR'; // F3 + e.preventDefault(); + } else if (e.key === 'F4') { + commandToSend = '\x1bOS'; // F4 + e.preventDefault(); + } else if (e.key === 'F5') { + commandToSend = '\x1b[15~'; // F5 + e.preventDefault(); + } else if (e.key === 'F6') { + commandToSend = '\x1b[17~'; // F6 + e.preventDefault(); + } else if (e.key === 'F7') { + commandToSend = '\x1b[18~'; // F7 + e.preventDefault(); + } else if (e.key === 'F8') { + commandToSend = '\x1b[19~'; // F8 + e.preventDefault(); + } else if (e.key === 'F9') { + commandToSend = '\x1b[20~'; // F9 + e.preventDefault(); + } else if (e.key === 'F10') { + commandToSend = '\x1b[21~'; // F10 + e.preventDefault(); + } else if (e.key === 'F11') { + commandToSend = '\x1b[23~'; // F11 + e.preventDefault(); + } else if (e.key === 'F12') { + commandToSend = '\x1b[24~'; // F12 + e.preventDefault(); + } + + // Send the command to all selected terminals + if (commandToSend) { + selectedTabIds.forEach(tabId => { + const tab = tabs.find((t: any) => t.id === tabId); + if (tab?.terminalRef?.current?.sendInput) { + tab.terminalRef.current.sendInput(commandToSend); + } + }); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (selectedTabIds.length === 0) return; + + // Handle regular character input + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { + const char = e.key; + selectedTabIds.forEach(tabId => { + const tab = tabs.find((t: any) => t.id === tabId); + if (tab?.terminalRef?.current?.sendInput) { + tab.terminalRef.current.sendInput(char); + } + }); + } + }; + const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const currentTabObj = tabs.find((t: any) => t.id === currentTab); const currentTabIsHome = currentTabObj?.type === 'home'; const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager'; const currentTabIsAdmin = currentTabObj?.type === 'admin'; + // Get terminal tabs for selection + const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal'); + + function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); + } + + const updateRightClickCopyPaste = (checked: boolean) => { + document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`; + } + return (
-
+
{tabs.map((tab: any) => { const isActive = tab.id === currentTab; const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id); @@ -82,11 +272,20 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac })}
-
+
+ + @@ -100,6 +299,165 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
)} + + {/* Custom SSH Tools Overlay */} + {toolsSheetOpen && ( +
+
setToolsSheetOpen(false)} + style={{ cursor: 'pointer' }} + /> + +
e.stopPropagation()} + > +
+

SSH Tools

+ +
+ +
+
+

+ Key Recording +

+ +
+
+
+ {!isRecording ? ( + + ) : ( + + )} +
+ + {isRecording && ( + <> +
+ +
+ {terminalTabs.map(tab => ( + + ))} +
+
+ +
+ + +

+ Commands will be sent to {selectedTabIds.length} selected terminal(s). +

+
+ + )} +
+
+ + + +

+ Settings +

+ +
+ + +
+ + + +

+ Have ideas for what should come next for ssh tools? Share them on{" "} + + GitHub + + ! +

+
+
+
+
+ )}
) } \ No newline at end of file diff --git a/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx b/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx index 4c127031..84fb12c6 100644 --- a/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx +++ b/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx @@ -1,8 +1,24 @@ import React from "react"; import { FileManagerTabList } from "./FileManagerTabList.tsx"; -export function FIleManagerTopNavbar(props: any): React.ReactElement { +interface FileManagerTopNavbarProps { + tabs: {id: string | number, title: string}[]; + activeTab: string | number; + setActiveTab: (tab: string | number) => void; + closeTab: (tab: string | number) => void; + onHomeClick: () => void; +} + +export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement { + const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props; + return ( - - ) + + ); } \ No newline at end of file diff --git a/src/ui/apps/File Manager/FileManager.tsx b/src/ui/apps/File Manager/FileManager.tsx index 8af4d6b8..75d7d8af 100644 --- a/src/ui/apps/File Manager/FileManager.tsx +++ b/src/ui/apps/File Manager/FileManager.tsx @@ -3,9 +3,12 @@ import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSide import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx"; import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx"; import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx"; +import {FileManagerOperations} from "@/ui/apps/File Manager/FileManagerOperations.tsx"; import {Button} from '@/components/ui/button.tsx'; import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx"; import {cn} from '@/lib/utils.ts'; +import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; +import {toast} from 'sonner'; import { getFileManagerRecent, getFileManagerPinned, @@ -31,8 +34,6 @@ interface Tab { sshSessionId?: string; filePath?: string; loading?: boolean; - error?: string; - success?: string; dirty?: boolean; } @@ -73,6 +74,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} const [currentHost, setCurrentHost] = useState(null); const [isSaving, setIsSaving] = useState(false); + // New state for operations + const [showOperations, setShowOperations] = useState(false); + const [currentPath, setCurrentPath] = useState('/'); + + // Delete modal state + const [deletingItem, setDeletingItem] = useState(null); + const sidebarRef = useRef(null); useEffect(() => { @@ -131,7 +139,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} setTimeout(() => reject(new Error('Fetch home data timed out')), 15000) ); - const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]); + const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any]; const recentWithPinnedStatus = (recentRes || []).map(file => ({ ...file, @@ -211,7 +219,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} fetchHomeData(); } catch (err: any) { const errorMessage = formatErrorMessage(err, 'Cannot read file'); - setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t)); + toast.error(errorMessage); + setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t)); } } setActiveTab(tabId); @@ -365,7 +374,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} setTimeout(() => reject(new Error('SSH status check timed out')), 10000) ); - const status = await Promise.race([statusPromise, statusTimeoutPromise]); + const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean }; if (!status.connected) { const connectPromise = connectSSH(tab.sshSessionId, { @@ -395,13 +404,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} const result = await Promise.race([savePromise, timeoutPromise]); setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, - dirty: false, - success: 'File saved successfully' + loading: false } : t)); - setTimeout(() => { - setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t)); - }, 3000); + toast.success('File saved successfully'); Promise.allSettled([ (async () => { @@ -432,17 +438,11 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`; } - setTabs(tabs => { - const updatedTabs = tabs.map(t => t.id === tab.id ? { - ...t, - error: `Failed to save file: ${errorMessage}` - } : t); - return updatedTabs; - }); - - setTimeout(() => { - setTabs(currentTabs => [...currentTabs]); - }, 100); + toast.error(`Failed to save file: ${errorMessage}`); + setTabs(tabs => tabs.map(t => t.id === tab.id ? { + ...t, + loading: false + } : t)); } finally { setIsSaving(false); } @@ -452,6 +452,50 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} const handleHostChange = (_host: SSHHost | null) => { }; + const handleOperationComplete = () => { + // Refresh the sidebar files + if (sidebarRef.current && sidebarRef.current.fetchFiles) { + sidebarRef.current.fetchFiles(); + } + // Refresh home data + if (currentHost) { + fetchHomeData(); + } + }; + + const handleSuccess = (message: string) => { + toast.success(message); + }; + + const handleError = (error: string) => { + toast.error(error); + }; + + // Function to update current path from sidebar + const updateCurrentPath = (newPath: string) => { + setCurrentPath(newPath); + }; + + // Function to handle delete from sidebar + const handleDeleteFromSidebar = (item: any) => { + setDeletingItem(item); + }; + + // Function to perform the actual delete + const performDelete = async (item: any) => { + if (!currentHost?.id) return; + + try { + const { deleteSSHItem } = await import('@/ui/main-axios.ts'); + await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); + toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`); + setDeletingItem(null); + handleOperationComplete(); + } catch (error: any) { + handleError(error?.response?.data?.error || 'Failed to delete item'); + } + }; + if (!currentHost) { return (
@@ -463,6 +507,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} tabs={tabs} ref={sidebarRef} host={initialHost as SSHHost} + onOperationComplete={handleOperationComplete} + onError={handleError} + onSuccess={handleSuccess} + onPathChange={updateCurrentPath} />
-
-
+
+
{/* Tab list scrollable area */} -
-
- ({id: t.id, title: t.title}))} - activeTab={activeTab} - setActiveTab={setActiveTab} - closeTab={closeTab} - onHomeClick={() => { - setActiveTab('home'); - if (currentHost) { - fetchHomeData(); - } - }} - /> -
+
+ ({id: t.id, title: t.title}))} + activeTab={activeTab} + setActiveTab={setActiveTab} + closeTab={closeTab} + onHomeClick={() => { + setActiveTab('home'); + if (currentHost) { + fetchHomeData(); + } + }} + /> +
+
+ +
- {/* Save button - always visible */} -
+ {/* Success/Error Messages */} + {/* The custom alert divs are removed, so this block is no longer needed. */} + {activeTab === 'home' ? ( - +
+
+ +
+ {showOperations && ( +
+ +
+ )} +
) : ( (() => { const tab = tabs.find(t => t.id === activeTab); if (!tab) return null; return (
- {/* Error display */} - {tab.error && ( -
-
-
- ⚠️ - {tab.error} -
- -
-
- )} - {/* Success display */} - {tab.success && ( -
-
-
- - {tab.success} -
- -
-
- )}
+ + {/* Delete Confirmation Modal */} + {deletingItem && ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+

+ + Confirm Delete +

+

+ Are you sure you want to delete {deletingItem.name}? + {deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'} +

+

+ This action cannot be undone. +

+
+ + +
+
+
+
+ )}
); } \ No newline at end of file diff --git a/src/ui/apps/File Manager/FileManagerHomeView.tsx b/src/ui/apps/File Manager/FileManagerHomeView.tsx index eb6ab6f5..3ac98b16 100644 --- a/src/ui/apps/File Manager/FileManagerHomeView.tsx +++ b/src/ui/apps/File Manager/FileManagerHomeView.tsx @@ -128,7 +128,7 @@ export function FileManagerHomeView({ -
+
{recent.length === 0 ? (
No recent files. @@ -145,7 +145,7 @@ export function FileManagerHomeView({ -
+
{pinned.length === 0 ? (
No pinned files. @@ -190,7 +190,7 @@ export function FileManagerHomeView({ Add
-
+
{shortcuts.length === 0 ? (
No shortcuts. diff --git a/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx index 108d5cf3..28f5e08b 100644 --- a/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx +++ b/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx @@ -1,17 +1,22 @@ import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import {Separator} from '@/components/ui/separator.tsx'; -import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react'; +import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react'; import {ScrollArea} from '@/components/ui/scroll-area.tsx'; import {cn} from '@/lib/utils.ts'; import {Input} from '@/components/ui/input.tsx'; import {Button} from '@/components/ui/button.tsx'; +import {toast} from 'sonner'; import { listSSHFiles, - connectSSH, - getSSHStatus, + renameSSHItem, + deleteSSHItem, + getFileManagerRecent, getFileManagerPinned, addFileManagerPinned, - removeFileManagerPinned + removeFileManagerPinned, + readSSHFile, + getSSHStatus, + connectSSH } from '@/ui/main-axios.ts'; interface SSHHost { @@ -38,11 +43,16 @@ interface SSHHost { } const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( - {onSelectView, onOpenFile, tabs, host}: { + {onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: { onSelectView?: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[]; host: SSHHost; + onOperationComplete?: () => void; + onError?: (error: string) => void; + onSuccess?: (message: string) => void; + onPathChange?: (path: string) => void; + onDeleteItem?: (item: any) => void; }, ref ) { @@ -59,13 +69,13 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( return () => clearTimeout(handler); }, [search]); useEffect(() => { - const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200); + const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200); return () => clearTimeout(handler); }, [fileSearch]); const [sshSessionId, setSshSessionId] = useState(null); const [filesLoading, setFilesLoading] = useState(false); - const [filesError, setFilesError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const [connectingSSH, setConnectingSSH] = useState(false); const [connectionCache, setConnectionCache] = useState>({}); const [fetchingFiles, setFetchingFiles] = useState(false); + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + item: any; + }>({ + visible: false, + x: 0, + y: 0, + item: null + }); + + // Rename state + const [renamingItem, setRenamingItem] = useState<{ + item: any; + newName: string; + } | null>(null); + useEffect(() => { // when host changes, set path and connect const nextPath = host?.defaultPath || '/'; setCurrentPath(nextPath); + onPathChange?.(nextPath); (async () => { await connectToSSH(host); })(); @@ -100,7 +130,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( try { if (!server.password && !server.key) { - setFilesError('No authentication credentials available for this SSH host'); + toast.error('No authentication credentials available for this SSH host'); return null; } @@ -124,7 +154,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( return sessionId; } catch (err: any) { - setFilesError(err?.response?.data?.error || 'Failed to connect to SSH'); + toast.error(err?.response?.data?.error || 'Failed to connect to SSH'); setSshSessionId(null); return null; } finally { @@ -140,7 +170,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( setFetchingFiles(true); setFiles([]); setFilesLoading(true); - setFilesError(null); try { let pinnedFiles: any[] = []; @@ -193,7 +222,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( } } catch (err: any) { setFiles([]); - setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files'); + toast.error(err?.response?.data?.error || err?.message || 'Failed to list files'); } finally { setFilesLoading(false); setFetchingFiles(false); @@ -222,10 +251,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( setFetchingFiles(false); setFilesLoading(false); - setFilesError(null); setFiles([]); setCurrentPath(path); + onPathChange?.(path); if (!sshSessionId) { const sessionId = await connectToSSH(host); if (sessionId) setSshSessionId(sessionId); @@ -235,7 +264,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( if (host && sshSessionId) { fetchFiles(); } - } + }, + getCurrentPath: () => currentPath })); useEffect(() => { @@ -250,6 +280,112 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( return file.name.toLowerCase().includes(q); }); + const handleContextMenu = (e: React.MouseEvent, item: any) => { + e.preventDefault(); + + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Context menu dimensions (approximate) + const menuWidth = 160; // min-w-[160px] + const menuHeight = 80; // Approximate height for 2 menu items + + // Calculate position + let x = e.clientX; + let y = e.clientY; + + // Adjust X position if menu would go off right edge + if (x + menuWidth > viewportWidth) { + x = e.clientX - menuWidth; + } + + // Adjust Y position if menu would go off bottom edge + if (y + menuHeight > viewportHeight) { + y = e.clientY - menuHeight; + } + + // Ensure menu doesn't go off left edge + if (x < 0) { + x = 0; + } + + // Ensure menu doesn't go off top edge + if (y < 0) { + y = 0; + } + + setContextMenu({ + visible: true, + x, + y, + item + }); + }; + + const closeContextMenu = () => { + setContextMenu({ visible: false, x: 0, y: 0, item: null }); + }; + + const handleRename = async (item: any, newName: string) => { + if (!sshSessionId || !newName.trim() || newName === item.name) { + setRenamingItem(null); + return; + } + + try { + await renameSSHItem(sshSessionId, item.path, newName.trim()); + toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`); + setRenamingItem(null); + if (onOperationComplete) { + onOperationComplete(); + } else { + fetchFiles(); + } + } catch (error: any) { + toast.error(error?.response?.data?.error || 'Failed to rename item'); + } + }; + + const handleDelete = async (item: any) => { + if (!sshSessionId) return; + + try { + await deleteSSHItem(sshSessionId, item.path, item.type === 'directory'); + toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`); + if (onOperationComplete) { + onOperationComplete(); + } else { + fetchFiles(); + } + } catch (error: any) { + toast.error(error?.response?.data?.error || 'Failed to delete item'); + } + }; + + const startRename = (item: any) => { + setRenamingItem({ item, newName: item.name }); + closeContextMenu(); + }; + + const startDelete = (item: any) => { + // Call the parent's delete handler instead of managing locally + onDeleteItem?.(item); + closeContextMenu(); + }; + + // Close context menu when clicking outside + useEffect(() => { + const handleClickOutside = () => closeContextMenu(); + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, []); + + const handlePathChange = (newPath: string) => { + setCurrentPath(newPath); + onPathChange?.(newPath); + }; + return (
@@ -260,26 +396,26 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( setCurrentPath(e.target.value)} + onChange={e => handlePathChange(e.target.value)} className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]" />
@@ -297,76 +433,115 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{connectingSSH || filesLoading ? (
Loading...
- ) : filesError ? ( -
{filesError}
) : filteredFiles.length === 0 ? (
No files or folders found.
) : (
{filteredFiles.map((item: any) => { const isOpen = (tabs || []).some((t: any) => t.id === item.path); + const isRenaming = renamingItem?.item?.path === item.path; + const isDeleting = false; // Deletion is handled by parent + return (
!isOpen && handleContextMenu(e, item)} > -
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({ - name: item.name, - path: item.path, - isSSH: item.isSSH, - sshSessionId: item.sshSessionId - }))} - > - {item.type === 'directory' ? - : - } - {item.name} -
-
- {item.type === 'file' && ( - - )} -
+ {item.type === 'directory' ? + : + } + {item.name} +
+
+ {item.type === 'file' && ( + + )} + {!isOpen && ( + + )} +
+ + )}
); })} @@ -379,7 +554,34 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( )}
+ + {/* Context Menu */} + {contextMenu.visible && contextMenu.item && ( +
+ + +
+ )}
); }); + export {FileManagerLeftSidebar}; \ No newline at end of file diff --git a/src/ui/apps/File Manager/FileManagerOperations.tsx b/src/ui/apps/File Manager/FileManagerOperations.tsx new file mode 100644 index 00000000..385221f4 --- /dev/null +++ b/src/ui/apps/File Manager/FileManagerOperations.tsx @@ -0,0 +1,588 @@ +import React, { useState, useRef } from 'react'; +import { Button } from '@/components/ui/button.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import { Card } from '@/components/ui/card.tsx'; +import { Separator } from '@/components/ui/separator.tsx'; +import { + Upload, + FilePlus, + FolderPlus, + Trash2, + Edit3, + X, + Check, + AlertCircle, + FileText, + Folder +} from 'lucide-react'; +import { cn } from '@/lib/utils.ts'; + +interface FileManagerOperationsProps { + currentPath: string; + sshSessionId: string | null; + onOperationComplete: () => void; + onError: (error: string) => void; + onSuccess: (message: string) => void; +} + +export function FileManagerOperations({ + currentPath, + sshSessionId, + onOperationComplete, + onError, + onSuccess +}: FileManagerOperationsProps) { + const [showUpload, setShowUpload] = useState(false); + const [showCreateFile, setShowCreateFile] = useState(false); + const [showCreateFolder, setShowCreateFolder] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const [showRename, setShowRename] = useState(false); + + const [uploadFile, setUploadFile] = useState(null); + const [newFileName, setNewFileName] = useState(''); + const [newFolderName, setNewFolderName] = useState(''); + const [deletePath, setDeletePath] = useState(''); + const [deleteIsDirectory, setDeleteIsDirectory] = useState(false); + const [renamePath, setRenamePath] = useState(''); + const [renameIsDirectory, setRenameIsDirectory] = useState(false); + const [newName, setNewName] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + const fileInputRef = useRef(null); + + const handleFileUpload = async () => { + if (!uploadFile || !sshSessionId) return; + + setIsLoading(true); + try { + const content = await uploadFile.text(); + const { uploadSSHFile } = await import('@/ui/main-axios.ts'); + + await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); + onSuccess(`File "${uploadFile.name}" uploaded successfully`); + setShowUpload(false); + setUploadFile(null); + onOperationComplete(); + } catch (error: any) { + onError(error?.response?.data?.error || 'Failed to upload file'); + } finally { + setIsLoading(false); + } + }; + + const handleCreateFile = async () => { + if (!newFileName.trim() || !sshSessionId) return; + + setIsLoading(true); + try { + const { createSSHFile } = await import('@/ui/main-axios.ts'); + + await createSSHFile(sshSessionId, currentPath, newFileName.trim()); + onSuccess(`File "${newFileName.trim()}" created successfully`); + setShowCreateFile(false); + setNewFileName(''); + onOperationComplete(); + } catch (error: any) { + onError(error?.response?.data?.error || 'Failed to create file'); + } finally { + setIsLoading(false); + } + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim() || !sshSessionId) return; + + setIsLoading(true); + try { + const { createSSHFolder } = await import('@/ui/main-axios.ts'); + + await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); + onSuccess(`Folder "${newFolderName.trim()}" created successfully`); + setShowCreateFolder(false); + setNewFolderName(''); + onOperationComplete(); + } catch (error: any) { + onError(error?.response?.data?.error || 'Failed to create folder'); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!deletePath || !sshSessionId) return; + + setIsLoading(true); + try { + const { deleteSSHItem } = await import('@/ui/main-axios.ts'); + + await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); + onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`); + setShowDelete(false); + setDeletePath(''); + setDeleteIsDirectory(false); + onOperationComplete(); + } catch (error: any) { + onError(error?.response?.data?.error || 'Failed to delete item'); + } finally { + setIsLoading(false); + } + }; + + const handleRename = async () => { + if (!renamePath || !newName.trim() || !sshSessionId) return; + + setIsLoading(true); + try { + const { renameSSHItem } = await import('@/ui/main-axios.ts'); + + await renameSSHItem(sshSessionId, renamePath, newName.trim()); + onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`); + setShowRename(false); + setRenamePath(''); + setRenameIsDirectory(false); + setNewName(''); + onOperationComplete(); + } catch (error: any) { + onError(error?.response?.data?.error || 'Failed to rename item'); + } finally { + setIsLoading(false); + } + }; + + const openFileDialog = () => { + fileInputRef.current?.click(); + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setUploadFile(file); + } + }; + + const resetStates = () => { + setShowUpload(false); + setShowCreateFile(false); + setShowCreateFolder(false); + setShowDelete(false); + setShowRename(false); + setUploadFile(null); + setNewFileName(''); + setNewFolderName(''); + setDeletePath(''); + setDeleteIsDirectory(false); + setRenamePath(''); + setRenameIsDirectory(false); + setNewName(''); + }; + + if (!sshSessionId) { + return ( +
+ +

Connect to SSH to use file operations

+
+ ); + } + + return ( +
+ {/* Operation Buttons */} +
+ + + + + +
+ + {/* Current Path Display */} +
+
+ + Current Path: + {currentPath} +
+
+ + + + {/* Upload File Modal */} + {showUpload && ( + +
+
+

+ + Upload File +

+

+ Maximum file size: 100MB (JSON) / 200MB (Binary) +

+
+ +
+ +
+
+ {uploadFile ? ( +
+ +

{uploadFile.name}

+

+ {(uploadFile.size / 1024).toFixed(2)} KB +

+ +
+ ) : ( +
+ +

Click to select a file

+ +
+ )} +
+ + + +
+ + +
+
+
+ )} + + {/* Create File Modal */} + {showCreateFile && ( + +
+

+ + Create New File +

+ +
+ +
+
+ + setNewFileName(e.target.value)} + placeholder="Enter file name (e.g., example.txt)" + className="bg-[#23232a] border-2 border-[#434345] text-white" + onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()} + /> +
+ +
+ + +
+
+
+ )} + + {/* Create Folder Modal */} + {showCreateFolder && ( + +
+

+ + Create New Folder +

+ +
+ +
+
+ + setNewFolderName(e.target.value)} + placeholder="Enter folder name" + className="bg-[#23232a] border-2 border-[#434345] text-white" + onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()} + /> +
+ +
+ + +
+
+
+ )} + + {/* Delete Modal */} + {showDelete && ( + +
+

+ + Delete Item +

+ +
+ +
+
+
+ + Warning: This action cannot be undone +
+
+ +
+ + setDeletePath(e.target.value)} + placeholder="Enter full path to item (e.g., /path/to/file.txt)" + className="bg-[#23232a] border-2 border-[#434345] text-white" + /> +
+ +
+ setDeleteIsDirectory(e.target.checked)} + className="rounded border-[#434345] bg-[#23232a]" + /> + +
+ +
+ + +
+
+
+ )} + + {/* Rename Modal */} + {showRename && ( + +
+

+ + Rename Item +

+ +
+ +
+
+ + setRenamePath(e.target.value)} + placeholder="Enter current path to item" + className="bg-[#23232a] border-2 border-[#434345] text-white" + /> +
+ +
+ + setNewName(e.target.value)} + placeholder="Enter new name" + className="bg-[#23232a] border-2 border-[#434345] text-white" + onKeyDown={(e) => e.key === 'Enter' && handleRename()} + /> +
+ +
+ setRenameIsDirectory(e.target.checked)} + className="rounded border-[#434345] bg-[#23232a]" + /> + +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/ui/apps/File Manager/FileManagerTabList.tsx b/src/ui/apps/File Manager/FileManagerTabList.tsx index 31e676a4..b43c1cdc 100644 --- a/src/ui/apps/File Manager/FileManagerTabList.tsx +++ b/src/ui/apps/File Manager/FileManagerTabList.tsx @@ -17,38 +17,33 @@ interface FileManagerTabList { export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) { return ( -
+
- {tabs.map((tab, index) => { + {tabs.map((tab) => { const isActive = tab.id === activeTab; return ( -
-
- +
+ - -
+
); })} diff --git a/src/ui/apps/Terminal/OldTerminal.tsx b/src/ui/apps/Terminal/OldTerminal.tsx new file mode 100644 index 00000000..ba0be63b --- /dev/null +++ b/src/ui/apps/Terminal/OldTerminal.tsx @@ -0,0 +1,440 @@ +import React, {useState} from 'react'; + +import { + CornerDownLeft, + Hammer, Pin, Menu +} from "lucide-react" + +import { + Button +} from "@/components/ui/button.tsx" + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem, SidebarProvider, +} from "@/components/ui/sidebar.tsx" + +import { + Separator, +} from "@/components/ui/separator.tsx" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger +} from "@/components/ui/sheet.tsx"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion.tsx"; +import {ScrollArea} from "@/components/ui/scroll-area.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {getSSHHosts} from "@/apps/SSH/ssh-axios"; +import {Checkbox} from "@/components/ui/checkbox.tsx"; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; +} + +export interface SidebarProps { + onSelectView: (view: string) => void; + onHostConnect: (hostConfig: any) => void; + allTabs: { id: number; title: string; terminalRef: React.RefObject }[]; + runCommandOnTabs: (tabIds: number[], command: string) => void; + onCloseSidebar?: () => void; + onAddHostSubmit?: (data: any) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function TerminalSidebar({ + onSelectView, + onHostConnect, + allTabs, + runCommandOnTabs, + onCloseSidebar, + open, + onOpenChange + }: SidebarProps): React.ReactElement { + const [hosts, setHosts] = useState([]); + const [hostsLoading, setHostsLoading] = useState(false); + const [hostsError, setHostsError] = useState(null); + const prevHostsRef = React.useRef([]); + + const fetchHosts = React.useCallback(async () => { + setHostsLoading(true); + setHostsError(null); + try { + const newHosts = await getSSHHosts(); + const terminalHosts = newHosts.filter(host => host.enableTerminal); + + const prevHosts = prevHostsRef.current; + const isSame = + terminalHosts.length === prevHosts.length && + terminalHosts.every((h: SSHHost, i: number) => { + const prev = prevHosts[i]; + if (!prev) return false; + return ( + h.id === prev.id && + h.name === prev.name && + h.folder === prev.folder && + h.ip === prev.ip && + h.port === prev.port && + h.username === prev.username && + h.password === prev.password && + h.authType === prev.authType && + h.key === prev.key && + h.pin === prev.pin && + JSON.stringify(h.tags) === JSON.stringify(prev.tags) + ); + }); + if (!isSame) { + setHosts(terminalHosts); + prevHostsRef.current = terminalHosts; + } + } catch (err: any) { + setHostsError('Failed to load hosts'); + } finally { + setHostsLoading(false); + } + }, []); + + React.useEffect(() => { + fetchHosts(); + const interval = setInterval(fetchHosts, 10000); + return () => clearInterval(interval); + }, [fetchHosts]); + + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + React.useEffect(() => { + const handler = setTimeout(() => setDebouncedSearch(search), 200); + return () => clearTimeout(handler); + }, [search]); + + const filteredHosts = React.useMemo(() => { + if (!debouncedSearch.trim()) return hosts; + const q = debouncedSearch.trim().toLowerCase(); + return hosts.filter(h => { + const searchableText = [ + h.name || '', + h.username, + h.ip, + h.folder || '', + ...(h.tags || []), + h.authType, + h.defaultPath || '' + ].join(' ').toLowerCase(); + return searchableText.includes(q); + }); + }, [hosts, debouncedSearch]); + + const hostsByFolder = React.useMemo(() => { + const map: Record = {}; + filteredHosts.forEach(h => { + const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; + if (!map[folder]) map[folder] = []; + map[folder].push(h); + }); + return map; + }, [filteredHosts]); + + const sortedFolders = React.useMemo(() => { + const folders = Object.keys(hostsByFolder); + folders.sort((a, b) => { + if (a === 'No Folder') return -1; + if (b === 'No Folder') return 1; + return a.localeCompare(b); + }); + return folders; + }, [hostsByFolder]); + + const getSortedHosts = (arr: SSHHost[]) => { + const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + return [...pinned, ...rest]; + }; + + const [toolsSheetOpen, setToolsSheetOpen] = useState(false); + const [toolsCommand, setToolsCommand] = useState(""); + const [selectedTabIds, setSelectedTabIds] = useState([]); + + const handleTabToggle = (tabId: number) => { + setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]); + }; + + const handleRunCommand = () => { + if (selectedTabIds.length && toolsCommand.trim()) { + let cmd = toolsCommand; + if (!cmd.endsWith("\n")) cmd += "\n"; + runCommandOnTabs(selectedTabIds, cmd); + setToolsCommand(""); + } + }; + + function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); + } + + const updateRightClickCopyPaste = (checked) => { + document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`; + } + + return ( + + + + + + Termix / Terminal + + + + + + + + + + + + +
+
+ setSearch(e.target.value)} + placeholder="Search hosts by name, username, IP, folder, tags..." + className="w-full h-8 text-sm bg-background border border-border rounded" + autoComplete="off" + /> +
+
+ +
+ {hostsError && ( +
+
{hostsError}
+
+ )} +
+ + 0 ? sortedFolders : undefined}> + {sortedFolders.map((folder, idx) => ( + + + {folder} + + {getSortedHosts(hostsByFolder[folder]).map(host => ( +
+ +
+ ))} +
+
+ {idx < sortedFolders.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+
+
+
+
+
+
+ + + + + + + Tools + +
+ + + Run multiwindow + commands + +