v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
30 changed files with 5976 additions and 668 deletions
Showing only changes of commit 16e35d7976 - Show all commits

View File

@@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
app.use(
(

View File

@@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -311,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
`);
try {

View File

@@ -212,6 +212,22 @@ export const snippets = sqliteTable("snippets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshFolders = sqliteTable("ssh_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const recentActivity = sqliteTable("recent_activity", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -226,3 +242,17 @@ export const recentActivity = sqliteTable("recent_activity", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const commandHistory = sqliteTable("command_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -8,7 +8,7 @@ import {
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
recentActivity,
sshFolders,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express";
@@ -226,7 +226,6 @@ router.post(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -266,7 +265,6 @@ router.post(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -326,7 +324,6 @@ router.post(
: []
: [],
pin: !!createdHost.pin,
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections
@@ -353,27 +350,6 @@ router.post(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_create",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -440,7 +416,6 @@ router.put(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -481,7 +456,6 @@ router.put(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -559,7 +533,6 @@ router.put(
: []
: [],
pin: !!updatedHost.pin,
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections
@@ -586,27 +559,6 @@ router.put(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_update",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -634,18 +586,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
});
return res.status(400).json({ error: "Invalid userId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch", {
operation: "host_fetch",
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
@@ -664,7 +604,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
: []
: [],
pin: !!row.pin,
overrideCredentialUsername: !!row.overrideCredentialUsername,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
@@ -711,19 +650,6 @@ router.get(
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
operation: "host_fetch_by_id",
hostId: parseInt(hostId),
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await db
.select()
@@ -749,7 +675,6 @@ router.get(
: []
: [],
pin: !!host.pin,
overrideCredentialUsername: !!host.overrideCredentialUsername,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
@@ -924,15 +849,6 @@ router.delete(
),
);
await db
.delete(recentActivity)
.where(
and(
eq(recentActivity.userId, userId),
eq(recentActivity.hostId, numericHostId),
),
);
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1352,9 +1268,7 @@ async function resolveHostCredentials(
const credential = credentials[0];
return {
...host,
username: host.overrideCredentialUsername
? host.username
: credential.username,
username: credential.username,
authType: credential.auth_type || credential.authType,
password: credential.password,
key: credential.key,
@@ -1428,6 +1342,17 @@ router.put(
DatabaseSaveTrigger.triggerSave("folder_rename");
// Also update folder metadata if exists
await db
.update(sshFolders)
.set({
name: newName,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
);
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
@@ -1445,6 +1370,151 @@ router.put(
},
);
// Route: Get all folders with metadata (requires JWT)
// GET /ssh/db/folders
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid user ID" });
}
try {
const folders = await db
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
res.json(folders);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
userId,
});
res.status(500).json({ error: "Failed to fetch folders" });
}
});
// Route: Update folder metadata (requires JWT)
// PUT /ssh/db/folders/metadata
router.put(
"/folders/metadata",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
// Check if folder metadata exists
const existing = await db
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
if (existing.length > 0) {
// Update existing
await db
.update(sshFolders)
.set({
color,
icon,
updatedAt: new Date().toISOString(),
})
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
} else {
// Create new
await db.insert(sshFolders).values({
userId,
name,
color,
icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
operation: "update_folder_metadata",
userId,
name,
});
res.status(500).json({ error: "Failed to update folder metadata" });
}
},
);
// Route: Delete all hosts in folder (requires JWT)
// DELETE /ssh/db/folders/:name/hosts
router.delete(
"/folders/:name/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = req.params.name;
if (!isNonEmptyString(userId) || !folderName) {
return res.status(400).json({ error: "Invalid folder name" });
}
try {
// Get all hosts in the folder
const hostsToDelete = await db
.select()
.from(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
message: "No hosts found in folder",
deletedCount: 0,
});
}
// Delete all hosts
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Delete folder metadata
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
sshLogger.info("Deleted all hosts in folder", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,
});
} catch (err) {
sshLogger.error("Failed to delete hosts in folder", err, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
// Route: Bulk import SSH hosts (requires JWT)
// POST /ssh/bulk-import
router.post(
@@ -1533,10 +1603,8 @@ router.post(
username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType,
credentialId: hostData.credentialId || null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key"

View File

@@ -0,0 +1,214 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Save command to history
// POST /terminal/command_history
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const insertData = {
userId,
hostId: parseInt(hostId, 10),
command: command.trim(),
};
const result = await db
.insert(commandHistory)
.values(insertData)
.returning();
authLogger.info(`Command saved to history for host ${hostId}`, {
operation: "command_history_save_success",
userId,
hostId: parseInt(hostId, 10),
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to save command",
});
}
},
);
// Get command history for a specific host
// GET /terminal/command_history/:hostId
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
// Get unique commands for this host, ordered by most recent
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
const result = await db
.selectDistinct({ command: commandHistory.command })
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(500); // Limit to last 500 unique commands
// Further deduplicate in case DISTINCT didn't work perfectly
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
authLogger.info(`Fetched command history for host ${hostId}`, {
operation: "command_history_fetch_success",
userId,
hostId: hostIdNum,
count: uniqueCommands.length,
});
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch history",
});
}
},
);
// Delete a specific command from history
// POST /terminal/command_history/delete
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
// Delete all instances of this command for this user and host
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
eq(commandHistory.command, command.trim()),
),
);
authLogger.info(`Command deleted from history for host ${hostId}`, {
operation: "command_history_delete_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete command",
});
}
},
);
// Clear command history for a specific host (optional feature)
// DELETE /terminal/command_history/:hostId
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
);
authLogger.success(`Command history cleared for host ${hostId}`, {
operation: "command_history_clear_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to clear history",
});
}
},
);
export default router;

View File

@@ -2490,6 +2490,474 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}'`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
return res.status(500).json({ error: "Failed to change permissions" });
}
let errorOutput = "";
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.json({
success: true,
message: "Permissions changed successfully",
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
// Route: Extract archive file (requires JWT)
// POST /ssh/file_manager/ssh/extractArchive
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
// Determine extraction command based on file extension
let extractCommand = "";
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.xz")) {
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar")) {
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".zip")) {
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
extractCommand = `bunzip2 -k "${archivePath}"`;
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
extractCommand = `unxz -k "${archivePath}"`;
} else if (fileExt.endsWith(".7z")) {
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
} else if (fileExt.endsWith(".rar")) {
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
} else {
return res.status(400).json({ error: "Unsupported archive format" });
}
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
command: extractCommand,
});
session.client.exec(extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
sessionId,
archivePath,
});
return res
.status(500)
.json({ error: "Failed to execute extract command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Extract stdout", {
operation: "extract_archive",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Extract stderr", {
operation: "extract_archive",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Extract command failed", {
operation: "extract_archive",
sessionId,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to extract archive";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
// Detect which command is missing based on file extension
let missingCmd = "";
let installHint = "";
if (fileExt.endsWith(".zip")) {
missingCmd = "unzip";
installHint =
"apt install unzip / yum install unzip / brew install unzip";
} else if (
fileExt.endsWith(".tar.gz") ||
fileExt.endsWith(".tgz") ||
fileExt.endsWith(".tar.bz2") ||
fileExt.endsWith(".tbz2") ||
fileExt.endsWith(".tar.xz") ||
fileExt.endsWith(".tar")
) {
missingCmd = "tar";
installHint = "Usually pre-installed on Linux/Unix systems";
} else if (fileExt.endsWith(".gz")) {
missingCmd = "gunzip";
installHint =
"apt install gzip / yum install gzip / Usually pre-installed";
} else if (fileExt.endsWith(".bz2")) {
missingCmd = "bunzip2";
installHint =
"apt install bzip2 / yum install bzip2 / brew install bzip2";
} else if (fileExt.endsWith(".xz")) {
missingCmd = "unxz";
installHint =
"apt install xz-utils / yum install xz / brew install xz";
} else if (fileExt.endsWith(".7z")) {
missingCmd = "7z";
installHint =
"apt install p7zip-full / yum install p7zip / brew install p7zip";
} else if (fileExt.endsWith(".rar")) {
missingCmd = "unrar";
installHint =
"apt install unrar / yum install unrar / brew install unrar";
}
if (missingCmd) {
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
});
res.json({
success: true,
message: "Archive extracted successfully",
extractPath: targetPath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH extractArchive stream error:", streamErr, {
operation: "extract_archive",
sessionId,
archivePath,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while extracting archive" });
}
});
});
});
// Route: Compress files/folders (requires JWT)
// POST /ssh/file_manager/ssh/compressFiles
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
if (
!sessionId ||
!paths ||
!Array.isArray(paths) ||
paths.length === 0 ||
!archiveName
) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Determine compression format
const compressionFormat = format || "zip"; // Default to zip
let compressCommand = "";
// Get the directory where the first file is located
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
// Extract just the file/folder names for the command
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
return `"${name}"`;
})
.join(" ");
// Construct archive path
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
} else {
archivePath = workingDir.endsWith("/")
? `${workingDir}${archiveName}`
: `${workingDir}/${archiveName}`;
}
if (compressionFormat === "zip") {
// Use zip command - need to cd to directory first
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar") {
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "7z") {
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
command: compressCommand,
});
session.client.exec(compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
sessionId,
paths,
});
return res
.status(500)
.json({ error: "Failed to execute compress command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Compress stdout", {
operation: "compress_files",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Compress stderr", {
operation: "compress_files",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Compress command failed", {
operation: "compress_files",
sessionId,
paths,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to compress files";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
const commandMap: Record<string, { cmd: string; install: string }> = {
zip: {
cmd: "zip",
install: "apt install zip / yum install zip / brew install zip",
},
"tar.gz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.bz2": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.xz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
tar: {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"7z": {
cmd: "7z",
install:
"apt install p7zip-full / yum install p7zip / brew install p7zip",
},
};
const info = commandMap[compressionFormat];
if (info) {
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
});
res.json({
success: true,
message: "Files compressed successfully",
archivePath: archivePath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH compressFiles stream error:", streamErr, {
operation: "compress_files",
sessionId,
paths,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while compressing files" });
}
});
});
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);

View File

@@ -111,6 +111,7 @@ class SystemCrypto {
} else {
}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug(
".env file not accessible, will generate new database key",
{

View File

@@ -755,6 +755,17 @@
"failedToRemoveFromFolder": "Failed to remove host from folder",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"editFolderAppearance": "Edit Folder Appearance",
"editFolderAppearanceDesc": "Customize the color and icon for folder",
"folderColor": "Folder Color",
"folderIcon": "Folder Icon",
"preview": "Preview",
"folderAppearanceUpdated": "Folder appearance updated successfully",
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move host to folder",
"statistics": "Statistics",
@@ -895,6 +906,22 @@
"connectToSsh": "Connect to SSH to use file operations",
"uploadFile": "Upload File",
"downloadFile": "Download",
"extractArchive": "Extract Archive",
"extractingArchive": "Extracting {{name}}...",
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
"extractFailed": "Extract failed",
"compressFile": "Compress File",
"compressFiles": "Compress Files",
"compressFilesDesc": "Compress {{count}} items into an archive",
"archiveName": "Archive Name",
"enterArchiveName": "Enter archive name...",
"compressionFormat": "Compression Format",
"selectedFiles": "Selected files",
"andMoreFiles": "and {{count}} more...",
"compress": "Compress",
"compressingFiles": "Compressing {{count}} items into {{name}}...",
"filesCompressedSuccessfully": "{{name}} created successfully",
"compressFailed": "Compression failed",
"edit": "Edit",
"preview": "Preview",
"previous": "Previous",
@@ -1172,7 +1199,19 @@
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
"totpVerificationFailed": "TOTP verification failed",
"changePermissions": "Change Permissions",
"changePermissionsDesc": "Modify file permissions for",
"currentPermissions": "Current Permissions",
"newPermissions": "New Permissions",
"owner": "Owner",
"group": "Group",
"others": "Others",
"read": "Read",
"write": "Write",
"execute": "Execute",
"permissionsChangedSuccessfully": "Permissions changed successfully",
"failedToChangePermissions": "Failed to change permissions"
},
"tunnels": {
"title": "SSH Tunnels",
@@ -1482,6 +1521,8 @@
"local": "Local",
"external": "External (OIDC)",
"selectPreferredLanguage": "Select your preferred language for the interface",
"fileColorCoding": "File Color Coding",
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."

View File

@@ -766,6 +766,17 @@
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"editFolderAppearance": "编辑文件夹外观",
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
"folderColor": "文件夹颜色",
"folderIcon": "文件夹图标",
"preview": "预览",
"folderAppearanceUpdated": "文件夹外观更新成功",
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
"deleteAllHostsInFolder": "删除文件夹内所有主机",
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动主机到文件夹失败",
"statistics": "统计",
@@ -904,6 +915,22 @@
"connectToSsh": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"downloadFile": "下载",
"extractArchive": "解压文件",
"extractingArchive": "正在解压 {{name}}...",
"archiveExtractedSuccessfully": "{{name}} 解压成功",
"extractFailed": "解压失败",
"compressFile": "压缩文件",
"compressFiles": "压缩文件",
"compressFilesDesc": "将 {{count}} 个项目压缩为归档文件",
"archiveName": "归档文件名",
"enterArchiveName": "输入归档文件名...",
"compressionFormat": "压缩格式",
"selectedFiles": "已选文件",
"andMoreFiles": "以及其他 {{count}} 个...",
"compress": "压缩",
"compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...",
"filesCompressedSuccessfully": "{{name}} 创建成功",
"compressFailed": "压缩失败",
"edit": "编辑",
"preview": "预览",
"previous": "上一页",
@@ -1151,7 +1178,19 @@
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
"totpVerificationFailed": "TOTP 验证失败",
"changePermissions": "修改权限",
"changePermissionsDesc": "修改文件权限",
"currentPermissions": "当前权限",
"newPermissions": "新权限",
"owner": "所有者",
"group": "组",
"others": "其他",
"read": "读取",
"write": "写入",
"execute": "执行",
"permissionsChangedSuccessfully": "权限修改成功",
"failedToChangePermissions": "权限修改失败"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1442,6 +1481,8 @@
"local": "本地",
"external": "外部 (OIDC)",
"selectPreferredLanguage": "选择您的界面首选语言",
"fileColorCoding": "文件颜色编码",
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
"currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"

View File

@@ -64,6 +64,16 @@ export interface SSHHostData {
terminalConfig?: TerminalConfig;
}
export interface SSHFolder {
id: number;
userId: string;
name: string;
color?: string;
icon?: string;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// CREDENTIAL TYPES
// ============================================================================

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { CompressDialog } from "./components/CompressDialog";
import {
Upload,
FolderPlus,
@@ -49,6 +51,9 @@ import {
addFolderShortcut,
getPinnedFiles,
logActivity,
changeSSHPermissions,
extractSSHArchive,
compressSSHFiles,
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
@@ -146,6 +151,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
const [permissionsDialogFile, setPermissionsDialogFile] =
useState<FileItem | null>(null);
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
[],
);
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
@@ -1037,6 +1047,82 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
}
async function handleExtractArchive(file: FileItem) {
if (!sshSessionId) return;
try {
await ensureSSHConnection();
toast.info(t("fileManager.extractingArchive", { name: file.name }));
await extractSSHArchive(
sshSessionId,
file.path,
undefined,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
);
// Refresh directory to show extracted files
handleRefreshDirectory();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
function handleOpenCompressDialog(files: FileItem[]) {
setCompressDialogFiles(files);
}
async function handleCompress(archiveName: string, format: string) {
if (!sshSessionId || compressDialogFiles.length === 0) return;
try {
await ensureSSHConnection();
const paths = compressDialogFiles.map((f) => f.path);
const fileNames = compressDialogFiles.map((f) => f.name);
toast.info(
t("fileManager.compressingFiles", {
count: fileNames.length,
name: archiveName,
}),
);
await compressSSHFiles(
sshSessionId,
paths,
archiveName,
format,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(
t("fileManager.filesCompressedSuccessfully", {
name: archiveName,
}),
);
// Refresh directory to show compressed file
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info(t("fileManager.noUndoableActions"));
@@ -1159,6 +1245,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(file);
}
function handleOpenPermissionsDialog(file: FileItem) {
setPermissionsDialogFile(file);
}
async function handleSavePermissions(file: FileItem, permissions: string) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
await changeSSHPermissions(
sshSessionId,
file.path,
permissions,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.permissionsChangedSuccessfully"));
await handleRefreshDirectory();
} catch (error: unknown) {
console.error("Failed to change permissions:", error);
toast.error(t("fileManager.failedToChangePermissions"));
throw error;
}
}
async function ensureSSHConnection() {
if (!sshSessionId || !currentHost || isReconnecting) return;
@@ -1947,10 +2061,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile}
currentPath={currentPath}
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
/>
</div>
</div>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
@@ -1972,6 +2096,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
Play,
Star,
Bookmark,
FileArchive,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
@@ -60,6 +61,8 @@ interface ContextMenuProps {
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
}
interface MenuItem {
@@ -99,6 +102,8 @@ export function FileManagerContextMenu({
onAddShortcut,
isPinned,
currentPath,
onExtractArchive,
onCompress,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
@@ -254,6 +259,45 @@ export function FileManagerContextMenu({
});
}
// Add extract option for archive files
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
fileName.endsWith(".zip") ||
fileName.endsWith(".tar") ||
fileName.endsWith(".tar.gz") ||
fileName.endsWith(".tgz") ||
fileName.endsWith(".tar.bz2") ||
fileName.endsWith(".tbz2") ||
fileName.endsWith(".tar.xz") ||
fileName.endsWith(".gz") ||
fileName.endsWith(".bz2") ||
fileName.endsWith(".xz") ||
fileName.endsWith(".7z") ||
fileName.endsWith(".rar");
if (isArchive) {
menuItems.push({
icon: <FileArchive className="w-4 h-4" />,
label: t("fileManager.extractArchive"),
action: () => onExtractArchive(files[0]),
shortcut: "Ctrl+E",
});
}
}
// Add compress option for selected files/folders
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.compressFiles")
: t("fileManager.compressFile"),
action: () => onCompress(files),
shortcut: "Ctrl+Shift+C",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
@@ -451,7 +495,7 @@ export function FileManagerContextMenu({
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0"
!isMounted && "opacity-0",
)}
/>
@@ -460,7 +504,7 @@ export function FileManagerContextMenu({
className={cn(
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
"transition-all duration-150 ease-out origin-top-left",
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95"
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95",
)}
style={{
left: menuPosition.x,

View File

@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
interface CreateIntent {
id: string;
@@ -96,15 +97,33 @@ interface FileManagerGridProps {
onNewFolder?: () => void;
}
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
const getFileTypeColor = (file: FileItem): string => {
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
if (!colorEnabled) {
return "text-muted-foreground";
}
if (file.type === "directory") {
return <Folder className={`${iconClass} text-muted-foreground`} />;
return "text-red-400";
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
return "text-green-400";
}
return "text-blue-400";
};
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
const colorClass = getFileTypeColor(file);
if (file.type === "directory") {
return <Folder className={`${iconClass} ${colorClass}`} />;
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
@@ -113,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "txt":
case "md":
case "readme":
return <FileText className={`${iconClass} text-muted-foreground`} />;
return <FileText className={`${iconClass} ${colorClass}`} />;
case "png":
case "jpg":
case "jpeg":
case "gif":
case "bmp":
case "svg":
return <FileImage className={`${iconClass} text-muted-foreground`} />;
return <FileImage className={`${iconClass} ${colorClass}`} />;
case "mp4":
case "avi":
case "mkv":
case "mov":
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
return <FileVideo className={`${iconClass} ${colorClass}`} />;
case "mp3":
case "wav":
case "flac":
case "ogg":
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
return <FileAudio className={`${iconClass} ${colorClass}`} />;
case "zip":
case "tar":
case "gz":
case "rar":
case "7z":
return <Archive className={`${iconClass} text-muted-foreground`} />;
return <Archive className={`${iconClass} ${colorClass}`} />;
case "js":
case "ts":
case "jsx":
@@ -150,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "rb":
case "go":
case "rs":
return <Code className={`${iconClass} text-muted-foreground`} />;
return <Code className={`${iconClass} ${colorClass}`} />;
case "json":
case "xml":
case "yaml":
@@ -159,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "ini":
case "conf":
case "config":
return <Settings className={`${iconClass} text-muted-foreground`} />;
return <Settings className={`${iconClass} ${colorClass}`} />;
default:
return <File className={`${iconClass} text-muted-foreground`} />;
return <File className={`${iconClass} ${colorClass}`} />;
}
};
@@ -853,19 +872,8 @@ export function FileManagerGrid({
onUndo,
]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
<div className="flex-shrink-0 border-b border-dark-border">
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button
@@ -1051,7 +1059,8 @@ export function FileManagerGrid({
"group p-3 rounded-lg cursor-pointer",
"transition-all duration-150 ease-out",
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
isSelected && "bg-primary/20 border-primary ring-2 ring-primary/20",
isSelected &&
"bg-primary/20 border-primary ring-2 ring-primary/20",
dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) &&
@@ -1312,6 +1321,13 @@ export function FileManagerGrid({
</div>,
document.body,
)}
<LoadingOverlay
visible={isLoading}
minDuration={600}
message={t("common.loading")}
showLogo={true}
/>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
interface CompressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileNames: string[];
onCompress: (archiveName: string, format: string) => void;
}
export function CompressDialog({
open,
onOpenChange,
fileNames,
onCompress,
}: CompressDialogProps) {
const { t } = useTranslation();
const [archiveName, setArchiveName] = useState("");
const [format, setFormat] = useState("zip");
useEffect(() => {
if (open && fileNames.length > 0) {
// Generate default archive name
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
} else {
setArchiveName("archive");
}
}
}, [open, fileNames]);
const handleCompress = () => {
if (!archiveName.trim()) return;
// Append extension if not already present
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",
"tar.gz": ".tar.gz",
"tar.bz2": ".tar.bz2",
"tar.xz": ".tar.xz",
tar: ".tar",
"7z": ".7z",
};
const expectedExtension = extensions[format];
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
finalName += expectedExtension;
}
onCompress(finalName, format);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
<DialogDescription>
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="archiveName">{t("fileManager.archiveName")}</Label>
<Input
id="archiveName"
value={archiveName}
onChange={(e) => setArchiveName(e.target.value)}
placeholder={t("fileManager.enterArchiveName")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCompress();
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="format">{t("fileManager.compressionFormat")}</Label>
<Select value={format} onValueChange={setFormat}>
<SelectTrigger id="format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zip">ZIP (.zip)</SelectItem>
<SelectItem value="tar.gz">TAR.GZ (.tar.gz)</SelectItem>
<SelectItem value="tar.bz2">TAR.BZ2 (.tar.bz2)</SelectItem>
<SelectItem value="tar.xz">TAR.XZ (.tar.xz)</SelectItem>
<SelectItem value="tar">TAR (.tar)</SelectItem>
<SelectItem value="7z">7-Zip (.7z)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-sm space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li key={index} className="truncate">
{name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground italic">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}
</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCompress} disabled={!archiveName.trim()}>
{t("fileManager.compress")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,328 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
const parsePermissions = (
perms: string,
): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
// If numeric format like "755"
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "0", 10),
other: parseInt(numStr[2] || "0", 10),
};
}
// If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x"
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
// Convert individual bits to numeric format
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState(
(initialPerms.owner & 1) !== 0,
);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState(
(initialPerms.group & 1) !== 0,
);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState(
(initialPerms.other & 1) !== 0,
);
// Reset when file changes
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner =
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group =
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other =
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("fileManager.changePermissionsDesc")}:{" "}
<span className="font-mono text-foreground">{file.name}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Current info */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-gray-400">
{t("fileManager.currentPermissions")}
</Label>
<p className="font-mono text-lg mt-1">
{file.permissions || "644"}
</p>
</div>
<div>
<Label className="text-gray-400">
{t("fileManager.newPermissions")}
</Label>
<p className="font-mono text-lg mt-1">{octal}</p>
</div>
</div>
{/* Owner permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="owner-read"
checked={ownerRead}
onCheckedChange={(checked) => setOwnerRead(checked === true)}
/>
<label htmlFor="owner-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-write"
checked={ownerWrite}
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
/>
<label htmlFor="owner-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-execute"
checked={ownerExecute}
onCheckedChange={(checked) =>
setOwnerExecute(checked === true)
}
/>
<label
htmlFor="owner-execute"
className="text-sm cursor-pointer"
>
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Group permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.group")} {file.group && `(${file.group})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="group-read"
checked={groupRead}
onCheckedChange={(checked) => setGroupRead(checked === true)}
/>
<label htmlFor="group-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-write"
checked={groupWrite}
onCheckedChange={(checked) => setGroupWrite(checked === true)}
/>
<label htmlFor="group-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-execute"
checked={groupExecute}
onCheckedChange={(checked) =>
setGroupExecute(checked === true)
}
/>
<label
htmlFor="group-execute"
className="text-sm cursor-pointer"
>
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Others permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.others")}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="other-read"
checked={otherRead}
onCheckedChange={(checked) => setOtherRead(checked === true)}
/>
<label htmlFor="other-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-write"
checked={otherWrite}
onCheckedChange={(checked) => setOtherWrite(checked === true)}
/>
<label htmlFor="other-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-execute"
checked={otherExecute}
onCheckedChange={(checked) =>
setOtherExecute(checked === true)
}
/>
<label
htmlFor="other-execute"
className="text-sm cursor-pointer"
>
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -22,10 +22,15 @@ import {
updateSSHHost,
renameFolder,
exportSSHHostWithCredentials,
getSSHFolders,
updateFolderMetadata,
deleteAllHostsInFolder,
getServerStatusById,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {
Edit,
Trash2,
@@ -45,16 +50,31 @@ import {
Copy,
Activity,
Clock,
Palette,
Trash,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
FolderOpen,
} from "lucide-react";
import type {
SSHHost,
SSHFolder,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { FolderEditDialog } from "./components/FolderEditDialog";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { addTab } = useTabs();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -65,13 +85,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
new Map(),
);
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
string | null
>(null);
const [serverStatuses, setServerStatuses] = useState<
Map<number, "online" | "offline" | "degraded">
>(new Map());
const dragCounter = useRef(0);
useEffect(() => {
fetchHosts();
fetchFolderMetadata();
const handleHostsRefresh = () => {
fetchHosts();
fetchFolderMetadata();
};
window.addEventListener("hosts:refresh", handleHostsRefresh);
@@ -116,6 +147,159 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const fetchFolderMetadata = async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
};
const handleSaveFolderAppearance = async (
folderName: string,
color: string,
icon: string,
) => {
try {
await updateFolderMetadata(folderName, color, icon);
toast.success(t("hosts.folderAppearanceUpdated"));
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("folders:changed"));
} catch (error) {
console.error("Failed to update folder appearance:", error);
toast.error(t("hosts.failedToUpdateFolderAppearance"));
}
};
const handleDeleteAllHostsInFolder = async (folderName: string) => {
const hostsInFolder = hostsByFolder[folderName] || [];
confirmWithToast(
t("hosts.confirmDeleteAllHostsInFolder", {
folder: folderName,
count: hostsInFolder.length,
}),
async () => {
try {
const result = await deleteAllHostsInFolder(folderName);
toast.success(
t("hosts.allHostsInFolderDeleted", {
folder: folderName,
count: result.deletedCount,
}),
);
await fetchHosts();
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch (error) {
console.error("Failed to delete hosts in folder:", error);
toast.error(t("hosts.failedToDeleteHostsInFolder"));
}
},
"destructive",
);
};
useEffect(() => {
if (hosts.length === 0) return;
const statusIntervals: NodeJS.Timeout[] = [];
const statusCancelled: boolean[] = [];
hosts.forEach((host, index) => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
if (!shouldShowStatus) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, "offline");
return next;
});
return;
}
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!statusCancelled[index]) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(
host.id,
res?.status === "online" ? "online" : "offline",
);
return next;
});
}
} catch (error: unknown) {
if (!statusCancelled[index]) {
const err = error as { response?: { status?: number } };
let status: "online" | "offline" | "degraded" = "offline";
if (err?.response?.status === 504) {
status = "degraded";
}
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, status);
return next;
});
}
}
};
fetchStatus();
const intervalId = setInterval(fetchStatus, 10000);
statusIntervals.push(intervalId);
});
return () => {
statusCancelled.fill(true);
statusIntervals.forEach((interval) => clearInterval(interval));
};
}, [hosts]);
const getFolderIcon = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
if (!metadata?.icon) return Folder;
const iconMap: Record<string, React.ComponentType> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
return iconMap[metadata.icon] || Folder;
};
const getFolderColor = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
return metadata?.color;
};
const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast(
t("hosts.confirmDelete", { name: hostName }),
@@ -854,7 +1038,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4" />
{(() => {
const FolderIcon = getFolderIcon(folder);
const folderColor = getFolderColor(folder);
return (
<FolderIcon
className="h-4 w-4"
style={
folderColor ? { color: folderColor } : undefined
}
/>
);
})()}
{editingFolder === folder ? (
<div
className="flex items-center gap-2"
@@ -935,6 +1130,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
{folder !== t("hosts.uncategorized") && (
<div className="flex items-center gap-1 ml-auto">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingFolderAppearance(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
>
<Palette className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.editFolderAppearance")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeleteAllHostsInFolder(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
>
<Trash className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.deleteAllHostsInFolder")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
@@ -957,6 +1196,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{(() => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus =
statsConfig.statusCheckEnabled !==
false;
const serverStatus =
serverStatuses.get(host.id) ||
"degraded";
return shouldShowStatus ? (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
) : null;
})()}
{host.pin && (
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)}
@@ -1179,6 +1444,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
})()}
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "terminal",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Terminal</p>
</TooltipContent>
</Tooltip>
)}
{host.enableFileManager && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "file_manager",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open File Manager</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "server",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
>
<Server className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Server Details</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
@@ -1202,6 +1549,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
))}
</div>
</ScrollArea>
{editingFolderAppearance && (
<FolderEditDialog
folderName={editingFolderAppearance}
currentColor={getFolderColor(editingFolderAppearance)}
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
open={editingFolderAppearance !== null}
onOpenChange={(open) => {
if (!open) setEditingFolderAppearance(null);
}}
onSave={async (color, icon) => {
await handleSaveFolderAppearance(
editingFolderAppearance,
color,
icon,
);
setEditingFolderAppearance(null);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,194 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useTranslation } from "react-i18next";
import {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
interface FolderEditDialogProps {
folderName: string;
currentColor?: string;
currentIcon?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (color: string, icon: string) => Promise<void>;
}
const AVAILABLE_COLORS = [
{ value: "#ef4444", label: "Red" },
{ value: "#f97316", label: "Orange" },
{ value: "#eab308", label: "Yellow" },
{ value: "#22c55e", label: "Green" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#a855f7", label: "Purple" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
];
const AVAILABLE_ICONS = [
{ value: "Folder", label: "Folder", Icon: Folder },
{ value: "Server", label: "Server", Icon: Server },
{ value: "Cloud", label: "Cloud", Icon: Cloud },
{ value: "Database", label: "Database", Icon: Database },
{ value: "Box", label: "Box", Icon: Box },
{ value: "Package", label: "Package", Icon: Package },
{ value: "Layers", label: "Layers", Icon: Layers },
{ value: "Archive", label: "Archive", Icon: Archive },
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
{ value: "Globe", label: "Globe", Icon: Globe },
];
export function FolderEditDialog({
folderName,
currentColor,
currentIcon,
open,
onOpenChange,
onSave,
}: FolderEditDialogProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState(
currentColor || AVAILABLE_COLORS[0].value,
);
const [selectedIcon, setSelectedIcon] = useState(
currentIcon || AVAILABLE_ICONS[0].value,
);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
}
}, [open, currentColor, currentIcon]);
const handleSave = async () => {
setLoading(true);
try {
await onSave(selectedColor, selectedIcon);
onOpenChange(false);
} catch (error) {
console.error("Failed to save folder metadata:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Folder className="w-5 h-5" />
{t("hosts.editFolderAppearance")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("hosts.editFolderAppearanceDesc")}:{" "}
<span className="font-mono text-foreground">{folderName}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Color Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.folderColor")}
</Label>
<div className="grid grid-cols-4 gap-3">
{AVAILABLE_COLORS.map((color) => (
<button
key={color.value}
type="button"
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
selectedColor === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
}`}
style={{ backgroundColor: color.value }}
onClick={() => setSelectedColor(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Icon Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.folderIcon")}
</Label>
<div className="grid grid-cols-5 gap-3">
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
<button
key={value}
type="button"
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
selectedIcon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
}`}
onClick={() => setSelectedIcon(value)}
title={label}
>
<Icon className="w-6 h-6" />
</button>
))}
</div>
</div>
{/* Preview */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
{(() => {
const IconComponent =
AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon ||
Folder;
return (
<IconComponent
className="w-5 h-5"
style={{ color: selectedColor }}
/>
);
})()}
<span className="font-medium">{folderName}</span>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,7 @@ import {
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
import {
CpuWidget,
MemoryWidget,
@@ -443,17 +444,8 @@ export function Server({
<div className="flex-1 overflow-y-auto min-h-0">
{metricsEnabled && showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
{!metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
@@ -476,6 +468,13 @@ export function Server({
))}
</div>
)}
<LoadingOverlay
visible={isLoadingMetrics && !metrics}
minDuration={700}
message={t("serverStats.loadingMetrics")}
showLogo={true}
/>
</div>
)}

View File

@@ -0,0 +1,67 @@
import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface CommandAutocompleteProps {
suggestions: string[];
selectedIndex: number;
onSelect: (command: string) => void;
position: { top: number; left: number };
visible: boolean;
}
export function CommandAutocomplete({
suggestions,
selectedIndex,
onSelect,
position,
visible,
}: CommandAutocompleteProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
if (!visible || suggestions.length === 0) {
return null;
}
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-dark-hover",
index === selectedIndex && "bg-blue-500/20 text-blue-400",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
>
{suggestion}
</div>
))}
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
Tab/Enter to complete to navigate Esc to close
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Search, Clock, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CommandHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: string[];
onSelectCommand: (command: string) => void;
onDeleteCommand?: (command: string) => void;
isLoading?: boolean;
}
export function CommandHistoryDialog({
open,
onOpenChange,
commands,
onSelectCommand,
onDeleteCommand,
isLoading = false,
}: CommandHistoryDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Filter commands based on search query
const filteredCommands = searchQuery
? commands.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
)
: commands;
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedIndex(0);
// Focus search input
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (filteredCommands.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : prev,
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case "Enter":
e.preventDefault();
if (filteredCommands[selectedIndex]) {
onSelectCommand(filteredCommands[selectedIndex]);
onOpenChange(false);
}
break;
case "Escape":
e.preventDefault();
onOpenChange(false);
break;
}
};
const handleSelect = (command: string) => {
onSelectCommand(command);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Command History
</DialogTitle>
</DialogHeader>
<div className="px-6 pb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => {
setSearchQuery("");
inputRef.current?.focus();
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
Loading history...
</div>
</div>
) : filteredCommands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20" />
<p>No commands found matching "{searchQuery}"</p>
</>
) : (
<>
<Clock className="h-12 w-12 mb-2 opacity-20" />
<p>No command history yet</p>
<p className="text-sm">
Execute commands to build your history
</p>
</>
)}
</div>
) : (
<div className="space-y-1">
{filteredCommands.map((command, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-4 py-2.5 rounded-md transition-colors group",
"font-mono text-sm flex items-center justify-between gap-2",
"hover:bg-accent",
index === selectedIndex &&
"bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50",
)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className="flex-1 cursor-pointer"
onClick={() => handleSelect(command)}
>
{command}
</span>
{onDeleteCommand && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
onDeleteCommand(command);
}}
title="Delete command"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<div className="px-6 py-3 border-t border-border bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
</kbd>{" "}
Navigate
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
Enter
</kbd>{" "}
Select
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
Esc
</kbd>{" "}
Close
</span>
</div>
<span>
{filteredCommands.length} command
{filteredCommands.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import {
useState,
useImperativeHandle,
forwardRef,
useCallback,
} from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
@@ -26,6 +27,11 @@ import {
TERMINAL_FONTS,
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
import { useCommandHistory } from "@/ui/hooks/useCommandHistory";
import { CommandHistoryDialog } from "./CommandHistoryDialog";
import { CommandAutocomplete } from "./CommandAutocomplete";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
interface HostConfig {
id?: number;
@@ -112,7 +118,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false);
const isVisibleRef = useRef<boolean>(false);
const isReadyRef = useRef<boolean>(false);
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -123,6 +128,104 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const isConnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
// Command history tracking (Stage 1)
const { trackInput, getCurrentCommand, updateCurrentCommand } =
useCommandTracker({
hostId: hostConfig.id,
enabled: true,
onCommandExecuted: (command) => {
// Add to autocomplete history (Stage 3)
if (!autocompleteHistory.current.includes(command)) {
autocompleteHistory.current = [
command,
...autocompleteHistory.current,
];
}
},
});
// Create refs for callbacks to avoid triggering useEffect re-runs
const getCurrentCommandRef = useRef(getCurrentCommand);
const updateCurrentCommandRef = useRef(updateCurrentCommand);
useEffect(() => {
getCurrentCommandRef.current = getCurrentCommand;
updateCurrentCommandRef.current = updateCurrentCommand;
}, [getCurrentCommand, updateCurrentCommand]);
// Real-time autocomplete (Stage 3)
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
string[]
>([]);
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] =
useState(0);
const [autocompletePosition, setAutocompletePosition] = useState({
top: 0,
left: 0,
});
const autocompleteHistory = useRef<string[]>([]);
const currentAutocompleteCommand = useRef<string>("");
// Refs for accessing current state in event handlers
const showAutocompleteRef = useRef(false);
const autocompleteSuggestionsRef = useRef<string[]>([]);
const autocompleteSelectedIndexRef = useRef(0);
// Command history dialog (Stage 2)
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
// Load command history when dialog opens
useEffect(() => {
if (showHistoryDialog && hostConfig.id) {
setIsLoadingHistory(true);
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
setCommandHistory(history);
})
.catch((error) => {
console.error("Failed to load command history:", error);
setCommandHistory([]);
})
.finally(() => {
setIsLoadingHistory(false);
});
}
}, [showHistoryDialog, hostConfig.id]);
// Load command history for autocomplete on mount (Stage 3)
useEffect(() => {
if (hostConfig.id) {
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
autocompleteHistory.current = history;
})
.catch((error) => {
console.error("Failed to load autocomplete history:", error);
autocompleteHistory.current = [];
});
}
}, [hostConfig.id]);
// Sync autocomplete state to refs for event handlers
useEffect(() => {
showAutocompleteRef.current = showAutocomplete;
}, [showAutocomplete]);
useEffect(() => {
autocompleteSuggestionsRef.current = autocompleteSuggestions;
}, [autocompleteSuggestions]);
useEffect(() => {
autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
}, [autocompleteSelectedIndex]);
const activityLoggingRef = useRef(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -158,10 +261,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
isVisibleRef.current = isVisible;
}, [isVisible]);
useEffect(() => {
isReadyRef.current = isReady;
}, [isReady]);
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
@@ -516,9 +615,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
terminal.onData((data) => {
if (data === "\x00" || data === "\u0000") {
return;
}
// Track command input for history (Stage 1)
trackInput(data);
// Send input to server
ws.send(JSON.stringify({ type: "input", data }));
});
@@ -778,6 +877,88 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return "";
}
// Handle command selection from history dialog (Stage 2)
const handleSelectCommand = useCallback(
(command: string) => {
if (!terminal || !webSocketRef.current) return;
// Send the command to the terminal
// Simulate typing the command character by character
for (const char of command) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
// Return focus to terminal after selecting command
setTimeout(() => {
terminal.focus();
}, 100);
},
[terminal],
);
// Handle autocomplete selection (mouse click)
const handleAutocompleteSelect = useCallback(
(selectedCommand: string) => {
if (!webSocketRef.current) return;
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
// Update current command tracker
updateCurrentCommand(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
// Return focus to terminal
setTimeout(() => {
terminal?.focus();
}, 50);
console.log(`[Autocomplete] ${currentCmd}${selectedCommand}`);
},
[terminal, updateCurrentCommand],
);
// Handle command deletion from history dialog
const handleDeleteCommand = useCallback(
async (command: string) => {
if (!hostConfig.id) return;
try {
// Call API to delete command
const { deleteCommandFromHistory } = await import(
"@/ui/main-axios.ts"
);
await deleteCommandFromHistory(hostConfig.id, command);
// Update local state
setCommandHistory((prev) => prev.filter((cmd) => cmd !== command));
// Update autocomplete history
autocompleteHistory.current = autocompleteHistory.current.filter(
(cmd) => cmd !== command,
);
console.log(`[Terminal] Command deleted from history: ${command}`);
} catch (error) {
console.error("Failed to delete command from history:", error);
}
},
[hostConfig.id],
);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
@@ -882,6 +1063,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
// Handle Ctrl+R for command history (Stage 2)
if (
e.ctrlKey &&
e.key === "r" &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey
) {
e.preventDefault();
e.stopPropagation();
setShowHistoryDialog(true);
return false;
}
if (
config.backspaceMode === "control-h" &&
e.key === "Backspace" &&
@@ -933,21 +1128,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.addEventListener("keydown", handleMacKeyboard, true);
const handleResize = () => {
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReadyRef.current) return;
if (!isVisibleRef.current || !isReady) return;
performFit();
}, 100);
};
}, 50);
});
const resizeObserver = new ResizeObserver(handleResize);
if (xtermRef.current) {
resizeObserver.observe(xtermRef.current);
}
window.addEventListener("resize", handleResize);
resizeObserver.observe(xtermRef.current);
setVisible(true);
@@ -960,7 +1149,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsReady(false);
isFittingRef.current = false;
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
@@ -977,6 +1165,192 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
};
}, [xtermRef, terminal, hostConfig]);
// Register keyboard handler for autocomplete (Stage 3)
// Registered only once when terminal is created
useEffect(() => {
if (!terminal) return;
const handleCustomKey = (e: KeyboardEvent): boolean => {
// Only handle keydown events, ignore keyup to prevent double triggering
if (e.type !== "keydown") {
return true;
}
// If autocomplete is showing, handle keys specially
if (showAutocompleteRef.current) {
// Handle Escape to close autocomplete
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return false;
}
// Handle Arrow keys for autocomplete navigation
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
const currentIndex = autocompleteSelectedIndexRef.current;
const suggestionsLength = autocompleteSuggestionsRef.current.length;
if (e.key === "ArrowDown") {
const newIndex =
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
setAutocompleteSelectedIndex(newIndex);
} else if (e.key === "ArrowUp") {
const newIndex =
currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
setAutocompleteSelectedIndex(newIndex);
}
return false;
}
// Handle Enter to confirm autocomplete selection
if (
e.key === "Enter" &&
autocompleteSuggestionsRef.current.length > 0
) {
e.preventDefault();
e.stopPropagation();
const selectedCommand =
autocompleteSuggestionsRef.current[
autocompleteSelectedIndexRef.current
];
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
if (webSocketRef.current?.readyState === 1) {
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
}
// Update current command tracker
updateCurrentCommandRef.current(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return false;
}
// Handle Tab to cycle through suggestions
if (
e.key === "Tab" &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey
) {
e.preventDefault();
e.stopPropagation();
const currentIndex = autocompleteSelectedIndexRef.current;
const suggestionsLength = autocompleteSuggestionsRef.current.length;
const newIndex =
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
setAutocompleteSelectedIndex(newIndex);
return false;
}
// For any other key while autocomplete is showing, close it and let key through
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return true;
}
// Handle Tab for autocomplete (when autocomplete is not showing)
if (
e.key === "Tab" &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey
) {
e.preventDefault();
e.stopPropagation();
const currentCmd = getCurrentCommandRef.current().trim();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
// Filter commands that start with current input
const matches = autocompleteHistory.current
.filter(
(cmd) =>
cmd.startsWith(currentCmd) &&
cmd !== currentCmd &&
cmd.length > currentCmd.length,
)
.slice(0, 10); // Show up to 10 matches
if (matches.length === 1) {
// Only one match - auto-complete directly
const completedCommand = matches[0];
const completion = completedCommand.substring(currentCmd.length);
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
updateCurrentCommandRef.current(completedCommand);
} else if (matches.length > 1) {
// Multiple matches - show selection list
currentAutocompleteCommand.current = currentCmd;
setAutocompleteSuggestions(matches);
setAutocompleteSelectedIndex(0);
// Calculate position (below or above cursor based on available space)
const cursorY = terminal.buffer.active.cursorY;
const cursorX = terminal.buffer.active.cursorX;
const rect = xtermRef.current?.getBoundingClientRect();
if (rect) {
const cellHeight =
terminal.rows > 0 ? rect.height / terminal.rows : 20;
const cellWidth =
terminal.cols > 0 ? rect.width / terminal.cols : 10;
// Estimate autocomplete menu height (max-h-[240px] from component)
const menuHeight = 240;
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
const spaceBelow = window.innerHeight - cursorBottomY;
const spaceAbove = rect.top + cursorY * cellHeight;
// Show above cursor if not enough space below
const showAbove =
spaceBelow < menuHeight && spaceAbove > spaceBelow;
setAutocompletePosition({
top: showAbove
? rect.top + cursorY * cellHeight - menuHeight
: cursorBottomY,
left: rect.left + cursorX * cellWidth,
});
}
setShowAutocomplete(true);
}
}
return false; // Prevent default Tab behavior
}
// Let terminal handle all other keys
return true;
};
terminal.attachCustomKeyEventHandler(handleCustomKey);
}, [terminal]);
useEffect(() => {
if (!terminal || !hostConfig || !visible) return;
@@ -1103,17 +1477,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
{isConnecting && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ backgroundColor }}
>
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t("terminal.connecting")}</span>
</div>
</div>
)}
<CommandHistoryDialog
open={showHistoryDialog}
onOpenChange={setShowHistoryDialog}
commands={commandHistory}
onSelectCommand={handleSelectCommand}
onDeleteCommand={handleDeleteCommand}
isLoading={isLoadingHistory}
/>
<CommandAutocomplete
visible={showAutocomplete}
suggestions={autocompleteSuggestions}
selectedIndex={autocompleteSelectedIndex}
position={autocompletePosition}
onSelect={handleAutocompleteSelect}
/>
<LoadingOverlay
visible={isConnecting}
minDuration={800}
message={t("terminal.connecting")}
backgroundColor={backgroundColor}
showLogo={true}
/>
</div>
);
},

File diff suppressed because it is too large Load Diff

View File

@@ -34,8 +34,9 @@ import {
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts } from "@/ui/main-axios.ts";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts";
interface SSHHost {
id: number;
@@ -114,7 +115,11 @@ export function LeftSidebar({
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
if (isSplitScreenActive) return;
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
return;
}
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
setCurrentTab(id);
};
@@ -145,6 +150,22 @@ export function LeftSidebar({
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
new Map(),
);
const fetchFolderMetadata = React.useCallback(async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
}, []);
const fetchHosts = React.useCallback(async () => {
try {
@@ -210,13 +231,18 @@ export function LeftSidebar({
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000);
fetchFolderMetadata();
const interval = setInterval(() => {
fetchHosts();
fetchFolderMetadata();
}, 300000);
return () => clearInterval(interval);
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
fetchFolderMetadata();
};
const handleCredentialsChanged = () => {
fetchHosts();
@@ -239,7 +265,7 @@ export function LeftSidebar({
handleCredentialsChanged as EventListener,
);
};
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -396,13 +422,11 @@ export function LeftSidebar({
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
disabled={isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
@@ -437,15 +461,20 @@ export function LeftSidebar({
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
{sortedFolders.map((folder, idx) => {
const metadata = folderMetadata.get(folder);
return (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
folderColor={metadata?.color}
folderIcon={metadata?.icon}
/>
);
})}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />

View File

@@ -1,6 +1,18 @@
import React, { useState } from "react";
import { CardTitle } from "@/components/ui/card.tsx";
import { ChevronDown, Folder } from "lucide-react";
import {
ChevronDown,
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
import { Separator } from "@/components/ui/separator.tsx";
@@ -40,11 +52,15 @@ interface FolderCardProps {
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
folderColor?: string;
folderIcon?: string;
}
export function FolderCard({
folderName,
hosts,
folderColor,
folderIcon,
}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
@@ -52,6 +68,30 @@ export function FolderCard({
setIsExpanded(!isExpanded);
};
const iconMap: Record<
string,
React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>
> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
const FolderIcon =
folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder;
return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div
@@ -59,7 +99,11 @@ export function FolderCard({
>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3} />
<FolderIcon
size={16}
strokeWidth={3}
style={folderColor ? { color: folderColor } : undefined}
/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">

View File

@@ -27,6 +27,8 @@ interface TabProps {
disableClose?: boolean;
isDragging?: boolean;
isDragOver?: boolean;
isValidDropTarget?: boolean;
isHoveredDropTarget?: boolean;
}
export function Tab({
@@ -44,6 +46,8 @@ export function Tab({
disableClose = false,
isDragging = false,
isDragOver = false,
isValidDropTarget = false,
isHoveredDropTarget = false,
}: TabProps): React.ReactElement {
const { t } = useTranslation();
@@ -54,12 +58,21 @@ export function Tab({
isDragOver &&
"bg-background/40 text-muted-foreground border-border opacity-60",
isDragging && "opacity-70",
isHoveredDropTarget &&
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
!isHoveredDropTarget &&
isValidDropTarget &&
"border-blue-400/50 bg-background/90",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
isActive &&
"bg-background text-foreground border-border z-10",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
!isActive &&
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
);

View File

@@ -10,6 +10,7 @@ import {
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { User, Shield, AlertCircle } from "lucide-react";
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
import {
@@ -93,6 +94,9 @@ export function UserProfile({
const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
localStorage.getItem("fileColorCoding") !== "false",
);
useEffect(() => {
fetchUserInfo();
@@ -134,6 +138,13 @@ export function UserProfile({
}
};
const handleFileColorCodingToggle = (enabled: boolean) => {
setFileColorCoding(enabled);
localStorage.setItem("fileColorCoding", enabled.toString());
// Trigger a re-render by dispatching a custom event
window.dispatchEvent(new Event("fileColorCodingChanged"));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -331,6 +342,23 @@ export function UserProfile({
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.fileColorCoding")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.fileColorCodingDesc")}
</p>
</div>
<Switch
checked={fileColorCoding}
onCheckedChange={handleFileColorCodingToggle}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>

View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { getCommandHistory, saveCommandToHistory } from "@/ui/main-axios.ts";
interface UseCommandHistoryOptions {
hostId?: number;
enabled?: boolean;
}
interface CommandHistoryResult {
suggestions: string[];
getSuggestions: (input: string) => string[];
saveCommand: (command: string) => Promise<void>;
clearSuggestions: () => void;
isLoading: boolean;
}
/**
* Custom hook for managing command history and autocomplete suggestions
*/
export function useCommandHistory({
hostId,
enabled = true,
}: UseCommandHistoryOptions): CommandHistoryResult {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const historyCache = useRef<Map<number, string[]>>(new Map());
// Fetch command history when hostId changes
useEffect(() => {
if (!enabled || !hostId) {
setCommandHistory([]);
setSuggestions([]);
return;
}
// Check cache first
const cached = historyCache.current.get(hostId);
if (cached) {
setCommandHistory(cached);
return;
}
// Fetch from server
const fetchHistory = async () => {
setIsLoading(true);
try {
const history = await getCommandHistory(hostId);
setCommandHistory(history);
historyCache.current.set(hostId, history);
} catch (error) {
console.error("Failed to fetch command history:", error);
setCommandHistory([]);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, [hostId, enabled]);
/**
* Get command suggestions based on current input
*/
const getSuggestions = useCallback(
(input: string): string[] => {
if (!input || input.trim().length === 0) {
return [];
}
const trimmedInput = input.trim();
const matches = commandHistory.filter((cmd) =>
cmd.startsWith(trimmedInput),
);
// Return up to 10 suggestions, excluding exact matches
const filtered = matches
.filter((cmd) => cmd !== trimmedInput)
.slice(0, 10);
setSuggestions(filtered);
return filtered;
},
[commandHistory],
);
/**
* Save a command to history
*/
const saveCommand = useCallback(
async (command: string) => {
if (!enabled || !hostId || !command || command.trim().length === 0) {
return;
}
const trimmedCommand = command.trim();
// Skip if it's the same as the last command
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
return;
}
try {
// Save to server
await saveCommandToHistory(hostId, trimmedCommand);
// Update local state - add to beginning
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
...prev.filter((c) => c !== trimmedCommand),
];
// Keep max 500 commands in memory
const limited = newHistory.slice(0, 500);
historyCache.current.set(hostId, limited);
return limited;
});
} catch (error) {
console.error("Failed to save command to history:", error);
// Still update local state even if server save fails
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
...prev.filter((c) => c !== trimmedCommand),
];
return newHistory.slice(0, 500);
});
}
},
[enabled, hostId, commandHistory],
);
/**
* Clear current suggestions
*/
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, []);
return {
suggestions,
getSuggestions,
saveCommand,
clearSuggestions,
isLoading,
};
}

View File

@@ -0,0 +1,144 @@
import { useRef, useCallback } from "react";
import { saveCommandToHistory } from "@/ui/main-axios.ts";
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
onCommandExecuted?: (command: string) => void;
}
interface CommandTrackerResult {
trackInput: (data: string) => void;
getCurrentCommand: () => string;
clearCurrentCommand: () => void;
updateCurrentCommand: (command: string) => void;
}
/**
* Hook to track terminal input and save executed commands to history
* Works with SSH terminals by monitoring input data
*/
export function useCommandTracker({
hostId,
enabled = true,
onCommandExecuted,
}: UseCommandTrackerOptions): CommandTrackerResult {
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
/**
* Track input data and detect command execution
*/
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
// Handle each character
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
// Detect escape sequences (e.g., arrow keys, function keys)
if (charCode === 27) {
// ESC
isInEscapeSequenceRef.current = true;
continue;
}
// Skip characters that are part of escape sequences
if (isInEscapeSequenceRef.current) {
// Common escape sequence endings
if (
(charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
charCode === 126 // ~
) {
isInEscapeSequenceRef.current = false;
}
continue;
}
// Handle Enter key (CR or LF)
if (charCode === 13 || charCode === 10) {
// \r or \n
const command = currentCommandRef.current.trim();
// Save non-empty commands
if (command.length > 0) {
// Save to history (async, don't wait)
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
// Callback for external handling
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
// Clear current command
currentCommandRef.current = "";
continue;
}
// Handle Backspace/Delete
if (charCode === 8 || charCode === 127) {
// Backspace or DEL
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
// Handle Ctrl+C, Ctrl+D, etc. - clear current command
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
// Handle Ctrl+U (clear line) - common in terminals
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
// Add printable characters to current command
if (charCode >= 32 && charCode <= 126) {
// Printable ASCII
currentCommandRef.current += char;
}
}
},
[enabled, hostId, onCommandExecuted],
);
/**
* Get the current command being typed
*/
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
/**
* Clear the current command buffer
*/
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
/**
* Update the current command buffer (used for autocomplete)
*/
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);
return {
trackInput,
getCurrentCommand,
clearCurrentCommand,
updateCurrentCommand,
};
}

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
import type {
SSHHost,
SSHHostData,
SSHFolder,
TunnelConfig,
TunnelStatus,
FileManagerFile,
@@ -1520,6 +1521,145 @@ export async function moveSSHItem(
}
}
export async function changeSSHPermissions(
sessionId: string,
path: string,
permissions: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string }> {
try {
fileLogger.info("Changing SSH file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/changePermissions", {
sessionId,
path,
permissions,
hostId,
userId,
});
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to change SSH file permissions", error, {
operation: "change_permissions",
sessionId,
path,
permissions,
});
handleApiError(error, "change SSH permissions");
throw error;
}
}
export async function extractSSHArchive(
sessionId: string,
archivePath: string,
extractPath?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; extractPath: string }> {
try {
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/extractArchive", {
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: response.data.extractPath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to extract archive", error, {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
});
handleApiError(error, "extract archive");
throw error;
}
}
export async function compressSSHFiles(
sessionId: string,
paths: string[],
archiveName: string,
format?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; archivePath: string }> {
try {
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/compressFiles", {
sessionId,
paths,
archiveName,
format: format || "zip",
hostId,
userId,
});
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath: response.data.archivePath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to compress files", error, {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
});
handleApiError(error, "compress files");
throw error;
}
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
@@ -2411,6 +2551,92 @@ export async function renameFolder(
}
}
export async function getSSHFolders(): Promise<SSHFolder[]> {
try {
sshLogger.info("Fetching SSH folders", {
operation: "fetch_ssh_folders",
});
const response = await authApi.get("/ssh/folders");
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
count: response.data.length,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to fetch SSH folders", error, {
operation: "fetch_ssh_folders",
});
handleApiError(error, "fetch SSH folders");
throw error;
}
}
export async function updateFolderMetadata(
name: string,
color?: string,
icon?: string,
): Promise<void> {
try {
sshLogger.info("Updating folder metadata", {
operation: "update_folder_metadata",
name,
color,
icon,
});
await authApi.put("/ssh/folders/metadata", {
name,
color,
icon,
});
sshLogger.success("Folder metadata updated successfully", {
operation: "update_folder_metadata",
name,
});
} catch (error) {
sshLogger.error("Failed to update folder metadata", error, {
operation: "update_folder_metadata",
name,
});
handleApiError(error, "update folder metadata");
throw error;
}
}
export async function deleteAllHostsInFolder(
folderName: string,
): Promise<{ deletedCount: number }> {
try {
sshLogger.info("Deleting all hosts in folder", {
operation: "delete_folder_hosts",
folderName,
});
const response = await authApi.delete(
`/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
);
sshLogger.success("All hosts in folder deleted successfully", {
operation: "delete_folder_hosts",
folderName,
deletedCount: response.data.deletedCount,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to delete hosts in folder", error, {
operation: "delete_folder_hosts",
folderName,
});
handleApiError(error, "delete hosts in folder");
throw error;
}
}
export async function renameCredentialFolder(
oldName: string,
newName: string,
@@ -2631,3 +2857,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
throw handleApiError(error, "reset recent activity");
}
}
// ============================================================================
// COMMAND HISTORY API
// ============================================================================
/**
* Save a command to history for a specific host
*/
export async function saveCommandToHistory(
hostId: number,
command: string,
): Promise<{ id: number; command: string; executedAt: string }> {
try {
const response = await authApi.post("/terminal/command_history", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save command to history");
}
}
/**
* Get command history for a specific host
* Returns array of unique commands ordered by most recent
*/
export async function getCommandHistory(hostId: number): Promise<string[]> {
try {
const response = await authApi.get(`/terminal/command_history/${hostId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch command history");
}
}
/**
* Delete a specific command from history
*/
export async function deleteCommandFromHistory(
hostId: number,
command: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/terminal/command_history/delete", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete command from history");
}
}
/**
* Clear command history for a specific host (optional feature)
*/
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/terminal/command_history/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "clear command history");
}
}