Added config editor operations and re-added ssh tools with recording feature

This commit is contained in:
LukeGus
2025-08-17 22:57:25 -05:00
parent 7d904c4a2c
commit 2df2c4e73d
15 changed files with 2791 additions and 294 deletions

22
package-lock.json generated
View File

@@ -56,12 +56,14 @@
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.3", "react-resizable-panels": "^3.0.3",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"sonner": "^2.0.7",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
@@ -6970,6 +6972,16 @@
"node": ">= 0.6" "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": { "node_modules/node-abi": {
"version": "3.75.0", "version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
@@ -7877,6 +7889,16 @@
"simple-concat": "^1.0.0" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -60,12 +60,14 @@
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.3", "react-resizable-panels": "^3.0.3",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"sonner": "^2.0.7",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",

View File

@@ -7,6 +7,7 @@ import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
import axios from "axios" import axios from "axios"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings"; import { AdminSettings } from "@/ui/Admin/AdminSettings";
import { Toaster } from "@/components/ui/sonner";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({baseURL: apiBase}); const API = axios.create({baseURL: apiBase});
@@ -222,6 +223,13 @@ function AppContent() {
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/> <TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar> </LeftSidebar>
)} )}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div> </div>
) )
} }

View File

@@ -10,7 +10,13 @@ app.use(cors({
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'] 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 sshIconSymbol = '📁';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); 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(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); 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(() => { const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`); logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'}); 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) => { // Convert content to buffer
if (checkErr) { let fileBuffer;
return res.status(500).json({error: `File check failed: ${checkErr.message}`}); 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 = ''; // Fallback method using command line
checkStream.on('data', (chunk: Buffer) => { const tryFallbackMethod = () => {
checkResult += chunk.toString(); try {
}); const base64Content = Buffer.from(content, 'utf8').toString('base64');
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
checkStream.on('close', (checkCode) => {
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { sshConn.client.exec(writeCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err); logger.error('Fallback write command failed:', err);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({error: err.message}); return res.status(500).json({error: `Write failed: ${err.message}`});
} }
return; return;
} }
@@ -350,76 +422,678 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
stream.stderr.on('data', (chunk: Buffer) => { stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString(); 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) => { stream.on('close', (code) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`; logger.success(`File written successfully via fallback: ${filePath}`);
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()}`);
if (!res.headersSent) { 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) => { stream.on('error', (streamErr) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr); logger.error('Fallback write stream error:', streamErr);
if (!res.headersSent) { 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}`});
}
}); });
}); });
}); });

View File

@@ -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 (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -152,4 +152,29 @@
border-radius: 9999px; border-radius: 9999px;
border: 2px solid transparent; border: 2px solid transparent;
background-clip: content-box; 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;
} }

View File

@@ -1,9 +1,18 @@
import React from "react"; import React, {useState} from "react";
import {useSidebar} from "@/components/ui/sidebar"; import {useSidebar} from "@/components/ui/sidebar";
import {Button} from "@/components/ui/button.tsx"; 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 {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.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 { interface TopNavbarProps {
isTopbarOpen: boolean; isTopbarOpen: boolean;
@@ -15,6 +24,11 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px"; const leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabActivate = (tabId: number) => { const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId); setCurrentTab(tabId);
}; };
@@ -27,12 +41,188 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
removeTab(tabId); 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<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab); const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home'; const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager'; const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin'; 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 ( return (
<div> <div>
<div <div
@@ -47,7 +237,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
padding: "0" padding: "0"
}} }}
> >
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-3rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar"> <div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => { {tabs.map((tab: any) => {
const isActive = tab.id === currentTab; const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id); const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
@@ -82,11 +272,20 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
})} })}
</div> </div>
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center gap-2 flex-1 px-2">
<Button
variant="outline"
className="w-[30px] h-[30px]"
title="SSH Tools"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="h-4 w-4"/>
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsTopbarOpen(false)} onClick={() => setIsTopbarOpen(false)}
className="w-[28px] h-[28px]" className="w-[30px] h-[30px]"
> >
<ChevronUpIcon/> <ChevronUpIcon/>
</Button> </Button>
@@ -100,6 +299,165 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<ChevronDown size={10} /> <ChevronDown size={10} />
</div> </div>
)} )}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && (
<div
className="fixed inset-0 z-[999999] flex justify-end"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
>
<div
className="flex-1"
onClick={() => setToolsSheetOpen(false)}
style={{ cursor: 'pointer' }}
/>
<div
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">SSH Tools</h2>
<Button
variant="outline"
size="sm"
onClick={() => setToolsSheetOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close SSH Tools"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h1 className="font-semibold">
Key Recording
</h1>
<div className="space-y-4">
<div className="space-y-4">
<div className="flex gap-2">
{!isRecording ? (
<Button
onClick={handleStartRecording}
className="flex-1"
variant="outline"
>
Start Key Recording
</Button>
) : (
<Button
onClick={handleStopRecording}
className="flex-1"
variant="destructive"
>
Stop Key Recording
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Select terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label>
<Input
id="ssh-tools-input"
placeholder="Type here"
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono mt-2"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected terminal(s).
</p>
</div>
</>
)}
</div>
</div>
<Separator className="my-4"/>
<h1 className="font-semibold">
Settings
</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
>
Enable rightclick copy/paste
</label>
</div>
<Separator className="my-4"/>
<p className="pt-2 pb-2 text-sm text-gray-500">
Have ideas for what should come next for ssh tools? Share them on{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,8 +1,24 @@
import React from "react"; import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx"; 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 ( return (
<FileManagerTabList {...props} /> <FileManagerTabList
) tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
} }

View File

@@ -3,9 +3,12 @@ import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSide
import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx"; import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx";
import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx"; import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx";
import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.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 {Button} from '@/components/ui/button.tsx';
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx"; import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
import {toast} from 'sonner';
import { import {
getFileManagerRecent, getFileManagerRecent,
getFileManagerPinned, getFileManagerPinned,
@@ -31,8 +34,6 @@ interface Tab {
sshSessionId?: string; sshSessionId?: string;
filePath?: string; filePath?: string;
loading?: boolean; loading?: boolean;
error?: string;
success?: string;
dirty?: boolean; dirty?: boolean;
} }
@@ -73,6 +74,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null); const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false); 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<any | null>(null);
const sidebarRef = useRef<any>(null); const sidebarRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
@@ -131,7 +139,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000) 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 => ({ const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file, ...file,
@@ -211,7 +219,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
fetchHomeData(); fetchHomeData();
} catch (err: any) { } catch (err: any) {
const errorMessage = formatErrorMessage(err, 'Cannot read file'); 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); setActiveTab(tabId);
@@ -365,7 +374,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
setTimeout(() => reject(new Error('SSH status check timed out')), 10000) 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) { if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, { const connectPromise = connectSSH(tab.sshSessionId, {
@@ -395,13 +404,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const result = await Promise.race([savePromise, timeoutPromise]); const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? { setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t, ...t,
dirty: false, loading: false
success: 'File saved successfully'
} : t)); } : t));
setTimeout(() => { toast.success('File saved successfully');
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
}, 3000);
Promise.allSettled([ Promise.allSettled([
(async () => { (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.`; 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 => { toast.error(`Failed to save file: ${errorMessage}`);
const updatedTabs = tabs.map(t => t.id === tab.id ? { setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t, ...t,
error: `Failed to save file: ${errorMessage}` loading: false
} : t); } : t));
return updatedTabs;
});
setTimeout(() => {
setTabs(currentTabs => [...currentTabs]);
}, 100);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -452,6 +452,50 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const handleHostChange = (_host: SSHHost | 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) { if (!currentHost) {
return ( return (
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}> <div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
@@ -463,6 +507,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
host={initialHost as SSHHost} host={initialHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
/> />
</div> </div>
<div style={{ <div style={{
@@ -495,47 +543,57 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
host={currentHost as SSHHost} host={currentHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
onDeleteItem={handleDeleteFromSidebar}
/> />
</div> </div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}> <div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-11 relative px-4" <div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
style={{height: 44}}>
{/* Tab list scrollable area */} {/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center"> <div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<div <FIleManagerTopNavbar
className="h-9 w-full bg-[#09090b] border-2 border-[#303032] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" tabs={tabs.map(t => ({id: t.id, title: t.title}))}
style={{minWidth: 0}}> activeTab={activeTab}
<FIleManagerTopNavbar setActiveTab={setActiveTab}
tabs={tabs.map(t => ({id: t.id, title: t.title}))} closeTab={closeTab}
activeTab={activeTab} onHomeClick={() => {
setActiveTab={setActiveTab} setActiveTab('home');
closeTab={closeTab} if (currentHost) {
onHomeClick={() => { fetchHomeData();
setActiveTab('home'); }
if (currentHost) { }}
fetchHomeData(); />
} </div>
}} <div className="flex items-center justify-center gap-2 flex-1">
/> <Button
</div> variant="outline"
onClick={() => setShowOperations(!showOperations)}
className={cn(
'w-[30px] h-[30px]',
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
)}
title="File Operations"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
className={cn(
'w-[30px] h-[30px]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
)}
>
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div> </div>
{/* Save button - always visible */}
<Button
className={cn(
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
)}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
type="button"
style={{height: 36, alignSelf: 'center'}}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div> </div>
</div> </div>
<div style={{ <div style={{
@@ -550,67 +608,43 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}}> }}>
{/* Success/Error Messages */}
{/* The custom alert divs are removed, so this block is no longer needed. */}
{activeTab === 'home' ? ( {activeTab === 'home' ? (
<FileManagerHomeView <div className="flex h-full">
recent={recent} <div className="flex-1">
pinned={pinned} <FileManagerHomeView
shortcuts={shortcuts} recent={recent}
onOpenFile={handleOpenFile} pinned={pinned}
onRemoveRecent={handleRemoveRecent} shortcuts={shortcuts}
onPinFile={handlePinFile} onOpenFile={handleOpenFile}
onUnpinFile={handleUnpinFile} onRemoveRecent={handleRemoveRecent}
onOpenShortcut={handleOpenShortcut} onPinFile={handlePinFile}
onRemoveShortcut={handleRemoveShortcut} onUnpinFile={handleUnpinFile}
onAddShortcut={handleAddShortcut} onOpenShortcut={handleOpenShortcut}
/> onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
</div>
{showOperations && (
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
<FileManagerOperations
currentPath={currentPath}
sshSessionId={currentHost?.id.toString() || null}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
/>
</div>
)}
</div>
) : ( ) : (
(() => { (() => {
const tab = tabs.find(t => t.id === activeTab); const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null; if (!tab) return null;
return ( return (
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}> <div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
{/* Error display */}
{tab.error && (
<div
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-red-400"></span>
<span>{tab.error}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
error: undefined
} : t))}
className="text-red-400 hover:text-red-300 transition-colors"
>
</button>
</div>
</div>
)}
{/* Success display */}
{tab.success && (
<div
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400"></span>
<span>{tab.success}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
success: undefined
} : t))}
className="text-green-400 hover:text-green-300 transition-colors"
>
</button>
</div>
</div>
)}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<FileManagerFileEditor <FileManagerFileEditor
content={tab.content} content={tab.content}
@@ -623,6 +657,47 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
})() })()
)} )}
</div> </div>
{/* Delete Confirmation Modal */}
{deletingItem && (
<div className="fixed inset-0 z-[99999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60"></div>
{/* Modal */}
<div className="relative h-full flex items-center justify-center">
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400" />
Confirm Delete
</h3>
<p className="text-white mb-4">
Are you sure you want to delete <strong>{deletingItem.name}</strong>?
{deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'}
</p>
<p className="text-red-400 text-sm mb-6">
This action cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="destructive"
onClick={() => performDelete(deletingItem)}
className="flex-1"
>
Delete
</Button>
<Button
variant="outline"
onClick={() => setDeletingItem(null)}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -128,7 +128,7 @@ export function FileManagerHomeView({
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? ( {recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span> <span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +145,7 @@ export function FileManagerHomeView({
</TabsContent> </TabsContent>
<TabsContent value="pinned" className="mt-0"> <TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? ( {pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span> <span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -190,7 +190,7 @@ export function FileManagerHomeView({
Add Add
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? ( {shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full"> <div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span> <span className="text-sm text-muted-foreground">No shortcuts.</span>

View File

@@ -1,17 +1,22 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {Separator} from '@/components/ui/separator.tsx'; 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 {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import {toast} from 'sonner';
import { import {
listSSHFiles, listSSHFiles,
connectSSH, renameSSHItem,
getSSHStatus, deleteSSHItem,
getFileManagerRecent,
getFileManagerPinned, getFileManagerPinned,
addFileManagerPinned, addFileManagerPinned,
removeFileManagerPinned removeFileManagerPinned,
readSSHFile,
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts'; } from '@/ui/main-axios.ts';
interface SSHHost { interface SSHHost {
@@ -38,11 +43,16 @@ interface SSHHost {
} }
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onSelectView, onOpenFile, tabs, host}: { {onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: any[]; tabs: any[];
host: SSHHost; host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
}, },
ref ref
) { ) {
@@ -59,13 +69,13 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [search]); }, [search]);
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200); const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [fileSearch]); }, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null); const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState('');
const [connectingSSH, setConnectingSSH] = useState(false); const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, { const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string; sessionId: string;
@@ -73,10 +83,30 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}>>({}); }>>({});
const [fetchingFiles, setFetchingFiles] = useState(false); 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(() => { useEffect(() => {
// when host changes, set path and connect // when host changes, set path and connect
const nextPath = host?.defaultPath || '/'; const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath); setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => { (async () => {
await connectToSSH(host); await connectToSSH(host);
})(); })();
@@ -100,7 +130,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try { try {
if (!server.password && !server.key) { 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; return null;
} }
@@ -124,7 +154,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return sessionId; return sessionId;
} catch (err: any) { } 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); setSshSessionId(null);
return null; return null;
} finally { } finally {
@@ -140,7 +170,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
setFetchingFiles(true); setFetchingFiles(true);
setFiles([]); setFiles([]);
setFilesLoading(true); setFilesLoading(true);
setFilesError(null);
try { try {
let pinnedFiles: any[] = []; let pinnedFiles: any[] = [];
@@ -193,7 +222,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
} catch (err: any) { } catch (err: any) {
setFiles([]); 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 { } finally {
setFilesLoading(false); setFilesLoading(false);
setFetchingFiles(false); setFetchingFiles(false);
@@ -222,10 +251,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
setFetchingFiles(false); setFetchingFiles(false);
setFilesLoading(false); setFilesLoading(false);
setFilesError(null);
setFiles([]); setFiles([]);
setCurrentPath(path); setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) { if (!sshSessionId) {
const sessionId = await connectToSSH(host); const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId); if (sessionId) setSshSessionId(sessionId);
@@ -235,7 +264,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
if (host && sshSessionId) { if (host && sshSessionId) {
fetchFiles(); fetchFiles();
} }
} },
getCurrentPath: () => currentPath
})); }));
useEffect(() => { useEffect(() => {
@@ -250,6 +280,112 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return file.name.toLowerCase().includes(q); 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 ( return (
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}> <div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
<div className="flex flex-col flex-grow min-h-0"> <div className="flex flex-col flex-grow min-h-0">
@@ -260,26 +396,26 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
className="h-8 w-8 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring" className="h-9 w-9 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => { onClick={() => {
let path = currentPath; let path = currentPath;
if (path && path !== '/' && path !== '') { if (path && path !== '/' && path !== '') {
if (path.endsWith('/')) path = path.slice(0, -1); if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/'); const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) { if (lastSlash > 0) {
setCurrentPath(path.slice(0, lastSlash)); handlePathChange(path.slice(0, lastSlash));
} else { } else {
setCurrentPath('/'); handlePathChange('/');
} }
} else { } else {
setCurrentPath('/'); handlePathChange('/');
} }
}} }}
> >
<ArrowUp className="w-4 h-4"/> <ArrowUp className="w-4 h-4"/>
</Button> </Button>
<Input ref={pathInputRef} value={currentPath} <Input ref={pathInputRef} value={currentPath}
onChange={e => 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]" 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]"
/> />
</div> </div>
@@ -297,76 +433,115 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
<div className="p-2 pb-0"> <div className="p-2 pb-0">
{connectingSSH || filesLoading ? ( {connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div> <div className="text-xs text-muted-foreground">Loading...</div>
) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? ( ) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or folders found.</div> <div className="text-xs text-muted-foreground">No files or folders found.</div>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => { {filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path); 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 ( return (
<div <div
key={item.path} key={item.path}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full", "flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full relative",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none" isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)} )}
style={{maxWidth: 220, marginBottom: 8}} style={{maxWidth: 220, marginBottom: 8}}
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)}
> >
<div {isRenaming ? (
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" <div className="flex items-center gap-2 flex-1 min-w-0">
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({ {item.type === 'directory' ?
name: item.name, <Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
path: item.path, <File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
isSSH: item.isSSH, <Input
sshSessionId: item.sshSessionId value={renamingItem.newName}
}))} onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
> className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
{item.type === 'directory' ? autoFocus
<Folder className="w-4 h-4 text-blue-400"/> : onKeyDown={(e) => {
<File className="w-4 h-4 text-muted-foreground"/>} if (e.key === 'Enter') {
<span className="text-sm text-white truncate max-w-[120px]">{item.name}</span> handleRename(item, renamingItem.newName);
</div> } else if (e.key === 'Escape') {
<div className="flex items-center gap-1"> setRenamingItem(null);
{item.type === 'file' && ( }
<Button size="icon" variant="ghost" className="h-7 w-7" }}
disabled={isOpen} onBlur={() => handleRename(item, renamingItem.newName)}
onClick={async (e) => { />
e.stopPropagation(); </div>
try { ) : (
if (item.isPinned) { <>
await removeFileManagerPinned({ <div
name: item.name, className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
path: item.path, onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
hostId: host?.id, name: item.name,
isSSH: true, path: item.path,
sshSessionId: host?.id.toString() isSSH: item.isSSH,
}); sshSessionId: item.sshSessionId
setFiles(files.map(f => }))}
f.path === item.path ? { ...f, isPinned: false } : f
));
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
> >
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/> {item.type === 'directory' ?
</Button> <Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
)} <File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
</div> <span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
<Button size="icon" variant="ghost" className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: false } : f
));
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
>
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div> </div>
); );
})} })}
@@ -379,7 +554,34 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
)} )}
</div> </div>
</div> </div>
{/* Context Menu */}
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div> </div>
); );
}); });
export {FileManagerLeftSidebar}; export {FileManagerLeftSidebar};

View File

@@ -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<File | null>(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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
</div>
);
}
return (
<div className="p-4 space-y-4">
{/* Operation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
>
<Upload className="w-4 h-4 mr-2" />
Upload File
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
>
<FilePlus className="w-4 h-4 mr-2" />
New File
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
>
<Edit3 className="w-4 h-4 mr-2" />
Rename
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Item
</Button>
</div>
{/* Current Path Display */}
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
<div className="flex items-center gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400" />
<span className="text-muted-foreground">Current Path:</span>
<span className="text-white font-mono truncate">{currentPath}</span>
</div>
</div>
<Separator className="p-0.25 bg-[#303032]" />
{/* Upload File Modal */}
{showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload File
</h3>
<p className="text-xs text-muted-foreground mt-1">
Maximum file size: 100MB (JSON) / 200MB (Binary)
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
{uploadFile ? (
<div className="space-y-2">
<FileText className="w-8 h-8 text-blue-400 mx-auto" />
<p className="text-white font-medium">{uploadFile.name}</p>
<p className="text-sm text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="mt-2"
>
Remove File
</Button>
</div>
) : (
<div className="space-y-2">
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
<p className="text-white">Click to select a file</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
>
Choose File
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="flex-1"
>
{isLoading ? 'Uploading...' : 'Upload File'}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{/* Create File Modal */}
{showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5" />
Create New File
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-white mb-2 block">
File Name
</label>
<Input
value={newFileName}
onChange={(e) => 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()}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="flex-1"
>
{isLoading ? 'Creating...' : 'Create File'}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{/* Create Folder Modal */}
{showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-5 h-5" />
Create New Folder
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-white mb-2 block">
Folder Name
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Enter folder name"
className="bg-[#23232a] border-2 border-[#434345] text-white"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="flex-1"
>
{isLoading ? 'Creating...' : 'Create Folder'}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{/* Delete Modal */}
{showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400" />
Delete Item
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-center gap-2 text-red-300">
<AlertCircle className="w-4 h-4" />
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
Item Path
</label>
<Input
value={deletePath}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a]"
/>
<label htmlFor="deleteIsDirectory" className="text-sm text-white">
This is a directory (will delete recursively)
</label>
</div>
<div className="flex gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="flex-1"
>
{isLoading ? 'Deleting...' : 'Delete Item'}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
</Card>
)}
{/* Rename Modal */}
{showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Edit3 className="w-5 h-5" />
Rename Item
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-white mb-2 block">
Current Path
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder="Enter current path to item"
className="bg-[#23232a] border-2 border-[#434345] text-white"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
New Name
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Enter new name"
className="bg-[#23232a] border-2 border-[#434345] text-white"
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a]"
/>
<label htmlFor="renameIsDirectory" className="text-sm text-white">
This is a directory
</label>
</div>
<div className="flex gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="flex-1"
>
{isLoading ? 'Renaming...' : 'Rename Item'}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -17,38 +17,33 @@ interface FileManagerTabList {
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) { export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
return ( return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto"> <div className="inline-flex items-center h-full gap-2">
<Button <Button
onClick={onHomeClick} onClick={onHomeClick}
variant="outline" variant="outline"
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`} className={`h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
> >
<Home className="w-4 h-4"/> <Home className="w-4 h-4"/>
</Button> </Button>
{tabs.map((tab, index) => { {tabs.map((tab) => {
const isActive = tab.id === activeTab; const isActive = tab.id === activeTab;
return ( return (
<div <div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
key={tab.id} <Button
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""} onClick={() => setActiveTab(tab.id)}
> variant="outline"
<div className="inline-flex rounded-md shadow-sm" role="group"> className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
<Button >
onClick={() => setActiveTab(tab.id)} {tab.title}
variant="outline" </Button>
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button <Button
onClick={() => closeTab(tab.id)} onClick={() => closeTab(tab.id)}
variant="outline" variant="outline"
className="h-7 rounded-l-none p-0 !w-9" className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
> >
<X className="!w-5 !h-5" strokeWidth={2.5}/> <X className="!w-4 !h-4" strokeWidth={2}/>
</Button> </Button>
</div>
</div> </div>
); );
})} })}

View File

@@ -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<any> }[];
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<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
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<string, SSHHost[]> = {};
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<number[]>([]);
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 (
<SidebarProvider open={open} onOpenChange={onOpenChange}>
<Sidebar className="h-full flex flex-col overflow-hidden">
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
<span>Termix / Terminal</span>
<button
type="button"
onClick={() => onCloseSidebar?.()}
title="Hide sidebar"
style={{
height: 28,
width: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'hsl(240 5% 9%)',
color: 'hsl(240 5% 64.9%)',
border: '1px solid hsl(240 3.7% 15.9%)',
borderRadius: 6,
cursor: 'pointer',
}}
>
<Menu className="h-4 w-4"/>
</button>
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenuItem key="Homepage">
<Button
className="w-full mt-2 mb-2 h-8"
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
<div
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input
value={search}
onChange={e => 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"
/>
</div>
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{hostsError && (
<div className="px-2 py-1 mt-2">
<div
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
</div>
)}
<div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full">
<Accordion key={`host-accordion-${sortedFolders.length}`}
type="multiple" className="w-full"
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<AccordionTrigger
className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 px-3 pb-2 pt-1">
{getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem
host={host}
onHostConnect={onHostConnect}
/>
</div>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div
style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</ScrollArea>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
<div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild>
<Button
className="w-full h-8 mt-2"
variant="outline"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="mr-2 h-4 w-4"/>
Tools
</Button>
</SheetTrigger>
<SheetContent side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="multiwindow">
<AccordionTrigger className="text-base font-semibold">Run multiwindow
commands</AccordionTrigger>
<AccordionContent>
<textarea
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)}
style={{
fontFamily: 'monospace',
marginBottom: 8,
background: '#141416'
}}
/>
<div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
size="sm"
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
<Button
className="w-full"
variant="outline"
onClick={handleRunCommand}
disabled={!toolsCommand.trim() || !selectedTabIds.length}
>
Run Command
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator className="p-0.25"/>
<div className="flex items-center space-x-2 mt-5">
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
<label
htmlFor="enable-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable rightclick copy/paste
</label>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
}
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
host: SSHHost;
onHostConnect: (hostConfig: any) => void
}) {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10">
<div
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
>
<div className="flex items-center w-full">
{host.pin &&
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
}
<span className="font-medium truncate">{host.name || host.ip}</span>
</div>
</div>
</div>
{hasTags && (
<div
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{height: 30}}>
{tags.map((tag: string) => (
<span key={tag}
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
});

View File

@@ -558,6 +558,75 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
} }
} }
export async function uploadSSHFile(sessionId: string, path: string, fileName: string, content: string): Promise<any> {
try {
const response = await fileManagerApi.post('/ssh/file_manager/ssh/uploadFile', {
sessionId,
path,
fileName,
content
});
return response.data;
} catch (error) {
throw error;
}
}
export async function createSSHFile(sessionId: string, path: string, fileName: string, content: string = ''): Promise<any> {
try {
const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFile', {
sessionId,
path,
fileName,
content
});
return response.data;
} catch (error) {
throw error;
}
}
export async function createSSHFolder(sessionId: string, path: string, folderName: string): Promise<any> {
try {
const response = await fileManagerApi.post('/ssh/file_manager/ssh/createFolder', {
sessionId,
path,
folderName
});
return response.data;
} catch (error) {
throw error;
}
}
export async function deleteSSHItem(sessionId: string, path: string, isDirectory: boolean): Promise<any> {
try {
const response = await fileManagerApi.delete('/ssh/file_manager/ssh/deleteItem', {
data: {
sessionId,
path,
isDirectory
}
});
return response.data;
} catch (error) {
throw error;
}
}
export async function renameSSHItem(sessionId: string, oldPath: string, newName: string): Promise<any> {
try {
const response = await fileManagerApi.put('/ssh/file_manager/ssh/renameItem', {
sessionId,
oldPath,
newName
});
return response.data;
} catch (error) {
throw error;
}
}
export {sshHostApi, tunnelApi, fileManagerApi}; export {sshHostApi, tunnelApi, fileManagerApi};
export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> { export async function getAllServerStatuses(): Promise<Record<number, ServerStatus>> {