Added config editor operations and re-added ssh tools with recording feature
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
23
src/components/ui/sonner.tsx
Normal file
23
src/components/ui/sonner.tsx
Normal 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 }
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 right‑click 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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};
|
||||||
588
src/ui/apps/File Manager/FileManagerOperations.tsx
Normal file
588
src/ui/apps/File Manager/FileManagerOperations.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
440
src/ui/apps/Terminal/OldTerminal.tsx
Normal file
440
src/ui/apps/Terminal/OldTerminal.tsx
Normal 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 right‑click 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user