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

View File

@@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) 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 ( CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -311,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) 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 { try {

View File

@@ -212,6 +212,22 @@ export const snippets = sqliteTable("snippets", {
.default(sql`CURRENT_TIMESTAMP`), .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", { export const recentActivity = sqliteTable("recent_activity", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id") userId: text("user_id")
@@ -226,3 +242,17 @@ export const recentActivity = sqliteTable("recent_activity", {
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .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, fileManagerRecent,
fileManagerPinned, fileManagerPinned,
fileManagerShortcuts, fileManagerShortcuts,
recentActivity, sshFolders,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@@ -226,7 +226,6 @@ router.post(
authMethod, authMethod,
authType, authType,
credentialId, credentialId,
overrideCredentialUsername,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -266,7 +265,6 @@ router.post(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -326,7 +324,6 @@ router.post(
: [] : []
: [], : [],
pin: !!createdHost.pin, pin: !!createdHost.pin,
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
enableTerminal: !!createdHost.enableTerminal, enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel, enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections 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); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", err, { sshLogger.error("Failed to save SSH host to database", err, {
@@ -440,7 +416,6 @@ router.put(
authMethod, authMethod,
authType, authType,
credentialId, credentialId,
overrideCredentialUsername,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -481,7 +456,6 @@ router.put(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -559,7 +533,6 @@ router.put(
: [] : []
: [], : [],
pin: !!updatedHost.pin, pin: !!updatedHost.pin,
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
enableTerminal: !!updatedHost.enableTerminal, enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel, enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections 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); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to update SSH host in database", 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" }); 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 { try {
const data = await SimpleDBOps.select( const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)), 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, pin: !!row.pin,
overrideCredentialUsername: !!row.overrideCredentialUsername,
enableTerminal: !!row.enableTerminal, enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel, enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections tunnelConnections: row.tunnelConnections
@@ -711,19 +650,6 @@ router.get(
}); });
return res.status(400).json({ error: "Invalid userId or hostId" }); 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 { try {
const data = await db const data = await db
.select() .select()
@@ -749,7 +675,6 @@ router.get(
: [] : []
: [], : [],
pin: !!host.pin, pin: !!host.pin,
overrideCredentialUsername: !!host.overrideCredentialUsername,
enableTerminal: !!host.enableTerminal, enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel, enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections 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 await db
.delete(sshData) .delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1352,9 +1268,7 @@ async function resolveHostCredentials(
const credential = credentials[0]; const credential = credentials[0];
return { return {
...host, ...host,
username: host.overrideCredentialUsername username: credential.username,
? host.username
: credential.username,
authType: credential.auth_type || credential.authType, authType: credential.auth_type || credential.authType,
password: credential.password, password: credential.password,
key: credential.key, key: credential.key,
@@ -1428,6 +1342,17 @@ router.put(
DatabaseSaveTrigger.triggerSave("folder_rename"); 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({ res.json({
message: "Folder renamed successfully", message: "Folder renamed successfully",
updatedHosts: updatedHosts.length, 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) // Route: Bulk import SSH hosts (requires JWT)
// POST /ssh/bulk-import // POST /ssh/bulk-import
router.post( router.post(
@@ -1533,10 +1603,8 @@ router.post(
username: hostData.username, username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null, password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType, authType: hostData.authType,
credentialId: hostData.credentialId || null, credentialId:
overrideCredentialUsername: hostData.overrideCredentialUsername hostData.authType === "credential" ? hostData.credentialId : null,
? 1
: 0,
key: hostData.authType === "key" ? hostData.key : null, key: hostData.authType === "key" ? hostData.key : null,
keyPassword: keyPassword:
hostData.authType === "key" 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", () => { process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession); Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0); process.exit(0);

View File

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

View File

@@ -755,6 +755,17 @@
"failedToRemoveFromFolder": "Failed to remove host from folder", "failedToRemoveFromFolder": "Failed to remove host from folder",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder", "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", "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move host to folder", "failedToMoveToFolder": "Failed to move host to folder",
"statistics": "Statistics", "statistics": "Statistics",
@@ -895,6 +906,22 @@
"connectToSsh": "Connect to SSH to use file operations", "connectToSsh": "Connect to SSH to use file operations",
"uploadFile": "Upload File", "uploadFile": "Upload File",
"downloadFile": "Download", "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", "edit": "Edit",
"preview": "Preview", "preview": "Preview",
"previous": "Previous", "previous": "Previous",
@@ -1172,7 +1199,19 @@
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}", "loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully", "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": { "tunnels": {
"title": "SSH Tunnels", "title": "SSH Tunnels",
@@ -1482,6 +1521,8 @@
"local": "Local", "local": "Local",
"external": "External (OIDC)", "external": "External (OIDC)",
"selectPreferredLanguage": "Select your preferred language for the interface", "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", "currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.", "passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again." "failedToChangePassword": "Failed to change password. Please check your current password and try again."

View File

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

View File

@@ -64,6 +64,16 @@ export interface SSHHostData {
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
} }
export interface SSHFolder {
id: number;
userId: string;
name: string;
color?: string;
icon?: string;
createdAt: string;
updatedAt: string;
}
// ============================================================================ // ============================================================================
// CREDENTIAL TYPES // 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 { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { CompressDialog } from "./components/CompressDialog";
import { import {
Upload, Upload,
FolderPlus, FolderPlus,
@@ -49,6 +51,9 @@ import {
addFolderShortcut, addFolderShortcut,
getPinnedFiles, getPinnedFiles,
logActivity, logActivity,
changeSSHPermissions,
extractSSHArchive,
compressSSHFiles,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar"; import type { SidebarItem } from "./FileManagerSidebar";
@@ -146,6 +151,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null); const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
const [editingFile, setEditingFile] = useState<FileItem | 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(); 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() { async function handleUndo() {
if (undoHistory.length === 0) { if (undoHistory.length === 0) {
toast.info(t("fileManager.noUndoableActions")); toast.info(t("fileManager.noUndoableActions"));
@@ -1159,6 +1245,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(file); 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() { async function ensureSSHConnection() {
if (!sshSessionId || !currentHost || isReconnecting) return; if (!sshSessionId || !currentHost || isReconnecting) return;
@@ -1947,10 +2061,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onAddShortcut={handleAddShortcut} onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile} isPinned={isPinnedFile}
currentPath={currentPath} currentPath={currentPath}
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
/> />
</div> </div>
</div> </div>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog <TOTPDialog
isOpen={totpRequired} isOpen={totpRequired}
prompt={totpPrompt} 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> </div>
); );
} }

View File

@@ -17,6 +17,7 @@ import {
Play, Play,
Star, Star,
Bookmark, Bookmark,
FileArchive,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { Kbd, KbdGroup } from "@/components/ui/kbd";
@@ -60,6 +61,8 @@ interface ContextMenuProps {
onAddShortcut?: (path: string) => void; onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean; isPinned?: (file: FileItem) => boolean;
currentPath?: string; currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
} }
interface MenuItem { interface MenuItem {
@@ -99,6 +102,8 @@ export function FileManagerContextMenu({
onAddShortcut, onAddShortcut,
isPinned, isPinned,
currentPath, currentPath,
onExtractArchive,
onCompress,
}: ContextMenuProps) { }: ContextMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y }); 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") { if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
@@ -451,7 +495,7 @@ export function FileManagerContextMenu({
<div <div
className={cn( className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150", "fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0" !isMounted && "opacity-0",
)} )}
/> />
@@ -460,7 +504,7 @@ export function FileManagerContextMenu({
className={cn( className={cn(
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden", "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", "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={{ style={{
left: menuPosition.x, left: menuPosition.x,

View File

@@ -24,6 +24,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js"; import type { FileItem } from "../../../types/index.js";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
interface CreateIntent { interface CreateIntent {
id: string; id: string;
@@ -96,15 +97,33 @@ interface FileManagerGridProps {
onNewFolder?: () => void; onNewFolder?: () => void;
} }
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { const getFileTypeColor = (file: FileItem): string => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6"; const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
if (!colorEnabled) {
return "text-muted-foreground";
}
if (file.type === "directory") { if (file.type === "directory") {
return <Folder className={`${iconClass} text-muted-foreground`} />; return "text-red-400";
} }
if (file.type === "link") { 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(); const ext = file.name.split(".").pop()?.toLowerCase();
@@ -113,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "txt": case "txt":
case "md": case "md":
case "readme": case "readme":
return <FileText className={`${iconClass} text-muted-foreground`} />; return <FileText className={`${iconClass} ${colorClass}`} />;
case "png": case "png":
case "jpg": case "jpg":
case "jpeg": case "jpeg":
case "gif": case "gif":
case "bmp": case "bmp":
case "svg": case "svg":
return <FileImage className={`${iconClass} text-muted-foreground`} />; return <FileImage className={`${iconClass} ${colorClass}`} />;
case "mp4": case "mp4":
case "avi": case "avi":
case "mkv": case "mkv":
case "mov": case "mov":
return <FileVideo className={`${iconClass} text-muted-foreground`} />; return <FileVideo className={`${iconClass} ${colorClass}`} />;
case "mp3": case "mp3":
case "wav": case "wav":
case "flac": case "flac":
case "ogg": case "ogg":
return <FileAudio className={`${iconClass} text-muted-foreground`} />; return <FileAudio className={`${iconClass} ${colorClass}`} />;
case "zip": case "zip":
case "tar": case "tar":
case "gz": case "gz":
case "rar": case "rar":
case "7z": case "7z":
return <Archive className={`${iconClass} text-muted-foreground`} />; return <Archive className={`${iconClass} ${colorClass}`} />;
case "js": case "js":
case "ts": case "ts":
case "jsx": case "jsx":
@@ -150,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "rb": case "rb":
case "go": case "go":
case "rs": case "rs":
return <Code className={`${iconClass} text-muted-foreground`} />; return <Code className={`${iconClass} ${colorClass}`} />;
case "json": case "json":
case "xml": case "xml":
case "yaml": case "yaml":
@@ -159,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "ini": case "ini":
case "conf": case "conf":
case "config": case "config":
return <Settings className={`${iconClass} text-muted-foreground`} />; return <Settings className={`${iconClass} ${colorClass}`} />;
default: default:
return <File className={`${iconClass} text-muted-foreground`} />; return <File className={`${iconClass} ${colorClass}`} />;
} }
}; };
@@ -853,19 +872,8 @@ export function FileManagerGrid({
onUndo, 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 ( 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-shrink-0 border-b border-dark-border">
<div className="flex items-center gap-1 p-2 border-b border-dark-border"> <div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button <button
@@ -1051,7 +1059,8 @@ export function FileManagerGrid({
"group p-3 rounded-lg cursor-pointer", "group p-3 rounded-lg cursor-pointer",
"transition-all duration-150 ease-out", "transition-all duration-150 ease-out",
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent", "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 && dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10", "bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) && dragState.files.some((f) => f.path === file.path) &&
@@ -1312,6 +1321,13 @@ export function FileManagerGrid({
</div>, </div>,
document.body, document.body,
)} )}
<LoadingOverlay
visible={isLoading}
minDuration={600}
message={t("common.loading")}
showLogo={true}
/>
</div> </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, updateSSHHost,
renameFolder, renameFolder,
exportSSHHostWithCredentials, exportSSHHostWithCredentials,
getSSHFolders,
updateFolderMetadata,
deleteAllHostsInFolder,
getServerStatusById,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { import {
Edit, Edit,
Trash2, Trash2,
@@ -45,16 +50,31 @@ import {
Copy, Copy,
Activity, Activity,
Clock, Clock,
Palette,
Trash,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
FolderOpen,
} from "lucide-react"; } from "lucide-react";
import type { import type {
SSHHost, SSHHost,
SSHFolder,
SSHManagerHostViewerProps, SSHManagerHostViewerProps,
} from "../../../../types/index.js"; } from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; 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) { export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const { addTab } = useTabs();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -65,13 +85,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [editingFolder, setEditingFolder] = useState<string | null>(null); const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState(""); const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false); 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); const dragCounter = useRef(0);
useEffect(() => { useEffect(() => {
fetchHosts(); fetchHosts();
fetchFolderMetadata();
const handleHostsRefresh = () => { const handleHostsRefresh = () => {
fetchHosts(); fetchHosts();
fetchFolderMetadata();
}; };
window.addEventListener("hosts:refresh", handleHostsRefresh); 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) => { const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast( confirmWithToast(
t("hosts.confirmDelete", { name: hostName }), t("hosts.confirmDelete", { name: hostName }),
@@ -854,7 +1038,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<AccordionItem value={folder} className="border-none"> <AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md"> <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"> <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 ? ( {editingFolder === folder ? (
<div <div
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -935,6 +1130,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{folderHosts.length} {folderHosts.length}
</Badge> </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> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="p-2"> <AccordionContent className="p-2">
@@ -957,6 +1196,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1"> <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 && ( {host.pin && (
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" /> <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)} )}
@@ -1179,6 +1444,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
})()} })()}
</div> </div>
</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> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -1202,6 +1549,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
))} ))}
</div> </div>
</ScrollArea> </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> </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, type StatsConfig,
DEFAULT_STATS_CONFIG, DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets"; } from "@/types/stats-widgets";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
import { import {
CpuWidget, CpuWidget,
MemoryWidget, MemoryWidget,
@@ -443,17 +444,8 @@ export function Server({
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{metricsEnabled && showStatsUI && ( {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"> <div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
{isLoadingMetrics && !metrics ? ( {!metrics && serverStatus === "offline" ? (
<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="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-center"> <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"> <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> </div>
)} )}
<LoadingOverlay
visible={isLoadingMetrics && !metrics}
minDuration={700}
message={t("serverStats.loadingMetrics")}
showLogo={true}
/>
</div> </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, useState,
useImperativeHandle, useImperativeHandle,
forwardRef, forwardRef,
useCallback,
} from "react"; } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
@@ -26,6 +27,11 @@ import {
TERMINAL_FONTS, TERMINAL_FONTS,
} from "@/constants/terminal-themes"; } from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types"; 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 { interface HostConfig {
id?: number; id?: number;
@@ -112,7 +118,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false); useState(false);
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
const isReadyRef = useRef<boolean>(false);
const isFittingRef = useRef(false); const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -123,6 +128,104 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const isConnectingRef = useRef(false); const isConnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null); const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false); 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 activityLoggingRef = useRef(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -158,10 +261,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
isVisibleRef.current = isVisible; isVisibleRef.current = isVisible;
}, [isVisible]); }, [isVisible]);
useEffect(() => {
isReadyRef.current = isReady;
}, [isReady]);
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
const jwtToken = getCookie("jwt"); const jwtToken = getCookie("jwt");
@@ -516,9 +615,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}), }),
); );
terminal.onData((data) => { terminal.onData((data) => {
if (data === "\x00" || data === "\u0000") { // Track command input for history (Stage 1)
return; trackInput(data);
} // Send input to server
ws.send(JSON.stringify({ type: "input", data })); ws.send(JSON.stringify({ type: "input", data }));
}); });
@@ -778,6 +877,88 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return ""; 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(() => { useEffect(() => {
if (!terminal || !xtermRef.current) return; if (!terminal || !xtermRef.current) return;
@@ -882,6 +1063,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.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 ( if (
config.backspaceMode === "control-h" && config.backspaceMode === "control-h" &&
e.key === "Backspace" && e.key === "Backspace" &&
@@ -933,21 +1128,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.addEventListener("keydown", handleMacKeyboard, true); element?.addEventListener("keydown", handleMacKeyboard, true);
const handleResize = () => { const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => { resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReadyRef.current) return; if (!isVisibleRef.current || !isReady) return;
performFit(); performFit();
}, 100); }, 50);
}; });
const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(xtermRef.current);
if (xtermRef.current) {
resizeObserver.observe(xtermRef.current);
}
window.addEventListener("resize", handleResize);
setVisible(true); setVisible(true);
@@ -960,7 +1149,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsReady(false); setIsReady(false);
isFittingRef.current = false; isFittingRef.current = false;
resizeObserver.disconnect(); resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true); element?.removeEventListener("keydown", handleMacKeyboard, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
@@ -977,6 +1165,192 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}; };
}, [xtermRef, terminal, hostConfig]); }, [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(() => { useEffect(() => {
if (!terminal || !hostConfig || !visible) return; if (!terminal || !hostConfig || !visible) return;
@@ -1103,17 +1477,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
/> />
{isConnecting && ( <CommandHistoryDialog
<div open={showHistoryDialog}
className="absolute inset-0 flex items-center justify-center" onOpenChange={setShowHistoryDialog}
style={{ backgroundColor }} commands={commandHistory}
> onSelectCommand={handleSelectCommand}
<div className="flex items-center gap-3"> onDeleteCommand={handleDeleteCommand}
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div> isLoading={isLoadingHistory}
<span className="text-gray-300">{t("terminal.connecting")}</span> />
</div>
</div> <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> </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 { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.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 { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -114,7 +115,11 @@ export function LeftSidebar({
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => { const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return; if (isSplitScreenActive) return;
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
return;
}
const id = addTab({ type: "ssh_manager", title: "Host Manager" }); const id = addTab({ type: "ssh_manager", title: "Host Manager" });
setCurrentTab(id); setCurrentTab(id);
}; };
@@ -145,6 +150,22 @@ export function LeftSidebar({
const prevHostsRef = React.useRef<SSHHost[]>([]); const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = 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 () => { const fetchHosts = React.useCallback(async () => {
try { try {
@@ -210,13 +231,18 @@ export function LeftSidebar({
React.useEffect(() => { React.useEffect(() => {
fetchHosts(); fetchHosts();
const interval = setInterval(fetchHosts, 300000); fetchFolderMetadata();
const interval = setInterval(() => {
fetchHosts();
fetchFolderMetadata();
}, 300000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchHosts]); }, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => { React.useEffect(() => {
const handleHostsChanged = () => { const handleHostsChanged = () => {
fetchHosts(); fetchHosts();
fetchFolderMetadata();
}; };
const handleCredentialsChanged = () => { const handleCredentialsChanged = () => {
fetchHosts(); fetchHosts();
@@ -239,7 +265,7 @@ export function LeftSidebar({
handleCredentialsChanged as EventListener, handleCredentialsChanged as EventListener,
); );
}; };
}, [fetchHosts]); }, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => { React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200); 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" className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline" variant="outline"
onClick={openSshManagerTab} onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive} disabled={isSplitScreenActive}
title={ title={
sshManagerTab isSplitScreenActive
? t("interface.sshManagerAlreadyOpen") ? t("interface.disabledDuringSplitScreen")
: isSplitScreenActive : undefined
? t("interface.disabledDuringSplitScreen")
: undefined
} }
> >
<HardDrive strokeWidth="2.5" /> <HardDrive strokeWidth="2.5" />
@@ -437,15 +461,20 @@ export function LeftSidebar({
</div> </div>
)} )}
{sortedFolders.map((folder, idx) => ( {sortedFolders.map((folder, idx) => {
<FolderCard const metadata = folderMetadata.get(folder);
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`} return (
folderName={folder} <FolderCard
hosts={getSortedHosts(hostsByFolder[folder])} key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
isFirst={idx === 0} folderName={folder}
isLast={idx === sortedFolders.length - 1} hosts={getSortedHosts(hostsByFolder[folder])}
/> isFirst={idx === 0}
))} isLast={idx === sortedFolders.length - 1}
folderColor={metadata?.color}
folderIcon={metadata?.icon}
/>
);
})}
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1" />

View File

@@ -1,6 +1,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { CardTitle } from "@/components/ui/card.tsx"; 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 { Button } from "@/components/ui/button.tsx";
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx"; import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
@@ -40,11 +52,15 @@ interface FolderCardProps {
hosts: SSHHost[]; hosts: SSHHost[];
isFirst: boolean; isFirst: boolean;
isLast: boolean; isLast: boolean;
folderColor?: string;
folderIcon?: string;
} }
export function FolderCard({ export function FolderCard({
folderName, folderName,
hosts, hosts,
folderColor,
folderIcon,
}: FolderCardProps): React.ReactElement { }: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
@@ -52,6 +68,30 @@ export function FolderCard({
setIsExpanded(!isExpanded); 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 ( return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0"> <div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div <div
@@ -59,7 +99,11 @@ export function FolderCard({
> >
<div className="flex gap-2 pr-10"> <div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center"> <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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md"> <CardTitle className="mb-0 leading-tight break-words text-md">

View File

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

View File

@@ -10,6 +10,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { User, Shield, AlertCircle } from "lucide-react"; import { User, Shield, AlertCircle } from "lucide-react";
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx"; import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
import { import {
@@ -93,6 +94,9 @@ export function UserProfile({
const [deletePassword, setDeletePassword] = useState(""); const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
localStorage.getItem("fileColorCoding") !== "false",
);
useEffect(() => { useEffect(() => {
fetchUserInfo(); 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) => { const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setDeleteLoading(true); setDeleteLoading(true);
@@ -331,6 +342,23 @@ export function UserProfile({
</div> </div>
</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="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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 { import type {
SSHHost, SSHHost,
SSHHostData, SSHHostData,
SSHFolder,
TunnelConfig, TunnelConfig,
TunnelStatus, TunnelStatus,
FileManagerFile, 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 // 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( export async function renameCredentialFolder(
oldName: string, oldName: string,
newName: string, newName: string,
@@ -2631,3 +2857,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
throw handleApiError(error, "reset recent activity"); 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");
}
}