diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 83280beb..37a09592 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; +import terminalRoutes from "./routes/terminal.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); +app.use("/terminal", terminalRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 09d37809..4c5b00bd 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ); + CREATE TABLE IF NOT EXISTS ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + CREATE TABLE IF NOT EXISTS recent_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -311,6 +322,16 @@ async function initializeCompleteDatabase(): Promise { 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 { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 45528c01..05973cae 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -212,6 +212,22 @@ export const snippets = sqliteTable("snippets", { .default(sql`CURRENT_TIMESTAMP`), }); +export const sshFolders = sqliteTable("ssh_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") @@ -226,3 +242,17 @@ export const recentActivity = sqliteTable("recent_activity", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const commandHistory = sqliteTable("command_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index b0e5067e..7a4dd158 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,7 +8,7 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, - recentActivity, + sshFolders, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -226,7 +226,6 @@ router.post( authMethod, authType, credentialId, - overrideCredentialUsername, key, keyPassword, keyType, @@ -266,7 +265,6 @@ router.post( username, authType: effectiveAuthType, credentialId: credentialId || null, - overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -326,7 +324,6 @@ router.post( : [] : [], pin: !!createdHost.pin, - overrideCredentialUsername: !!createdHost.overrideCredentialUsername, enableTerminal: !!createdHost.enableTerminal, enableTunnel: !!createdHost.enableTunnel, tunnelConnections: createdHost.tunnelConnections @@ -353,27 +350,6 @@ router.post( }, ); - try { - const fetch = (await import("node-fetch")).default; - const token = - req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); - await fetch("http://localhost:30005/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token && { Cookie: `jwt=${token}` }), - }, - }); - } catch (refreshError) { - sshLogger.warn("Failed to refresh server stats polling", { - operation: "stats_refresh_after_create", - error: - refreshError instanceof Error - ? refreshError.message - : "Unknown error", - }); - } - res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -440,7 +416,6 @@ router.put( authMethod, authType, credentialId, - overrideCredentialUsername, key, keyPassword, keyType, @@ -481,7 +456,6 @@ router.put( username, authType: effectiveAuthType, credentialId: credentialId || null, - overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, @@ -559,7 +533,6 @@ router.put( : [] : [], pin: !!updatedHost.pin, - overrideCredentialUsername: !!updatedHost.overrideCredentialUsername, enableTerminal: !!updatedHost.enableTerminal, enableTunnel: !!updatedHost.enableTunnel, tunnelConnections: updatedHost.tunnelConnections @@ -586,27 +559,6 @@ router.put( }, ); - try { - const fetch = (await import("node-fetch")).default; - const token = - req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); - await fetch("http://localhost:30005/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token && { Cookie: `jwt=${token}` }), - }, - }); - } catch (refreshError) { - sshLogger.warn("Failed to refresh server stats polling", { - operation: "stats_refresh_after_update", - error: - refreshError instanceof Error - ? refreshError.message - : "Unknown error", - }); - } - res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -634,18 +586,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { }); return res.status(400).json({ error: "Invalid userId" }); } - - if (!SimpleDBOps.isUserDataUnlocked(userId)) { - sshLogger.warn("User data not unlocked for SSH host fetch", { - operation: "host_fetch", - userId, - }); - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - try { const data = await SimpleDBOps.select( db.select().from(sshData).where(eq(sshData.userId, userId)), @@ -664,7 +604,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { : [] : [], pin: !!row.pin, - overrideCredentialUsername: !!row.overrideCredentialUsername, enableTerminal: !!row.enableTerminal, enableTunnel: !!row.enableTunnel, tunnelConnections: row.tunnelConnections @@ -711,19 +650,6 @@ router.get( }); return res.status(400).json({ error: "Invalid userId or hostId" }); } - - if (!SimpleDBOps.isUserDataUnlocked(userId)) { - sshLogger.warn("User data not unlocked for SSH host fetch by ID", { - operation: "host_fetch_by_id", - hostId: parseInt(hostId), - userId, - }); - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - try { const data = await db .select() @@ -749,7 +675,6 @@ router.get( : [] : [], pin: !!host.pin, - overrideCredentialUsername: !!host.overrideCredentialUsername, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, tunnelConnections: host.tunnelConnections @@ -924,15 +849,6 @@ router.delete( ), ); - await db - .delete(recentActivity) - .where( - and( - eq(recentActivity.userId, userId), - eq(recentActivity.hostId, numericHostId), - ), - ); - await db .delete(sshData) .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); @@ -1352,9 +1268,7 @@ async function resolveHostCredentials( const credential = credentials[0]; return { ...host, - username: host.overrideCredentialUsername - ? host.username - : credential.username, + username: credential.username, authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, @@ -1428,6 +1342,17 @@ router.put( DatabaseSaveTrigger.triggerSave("folder_rename"); + // Also update folder metadata if exists + await db + .update(sshFolders) + .set({ + name: newName, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)), + ); + res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, @@ -1445,6 +1370,151 @@ router.put( }, ); +// Route: Get all folders with metadata (requires JWT) +// GET /ssh/db/folders +router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + return res.status(400).json({ error: "Invalid user ID" }); + } + + try { + const folders = await db + .select() + .from(sshFolders) + .where(eq(sshFolders.userId, userId)); + + res.json(folders); + } catch (err) { + sshLogger.error("Failed to fetch folders", err, { + operation: "fetch_folders", + userId, + }); + res.status(500).json({ error: "Failed to fetch folders" }); + } +}); + +// Route: Update folder metadata (requires JWT) +// PUT /ssh/db/folders/metadata +router.put( + "/folders/metadata", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, color, icon } = req.body; + + if (!isNonEmptyString(userId) || !name) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + // Check if folder metadata exists + const existing = await db + .select() + .from(sshFolders) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .limit(1); + + if (existing.length > 0) { + // Update existing + await db + .update(sshFolders) + .set({ + color, + icon, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))); + } else { + // Create new + await db.insert(sshFolders).values({ + userId, + name, + color, + icon, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + DatabaseSaveTrigger.triggerSave("folder_metadata_update"); + + res.json({ message: "Folder metadata updated successfully" }); + } catch (err) { + sshLogger.error("Failed to update folder metadata", err, { + operation: "update_folder_metadata", + userId, + name, + }); + res.status(500).json({ error: "Failed to update folder metadata" }); + } + }, +); + +// Route: Delete all hosts in folder (requires JWT) +// DELETE /ssh/db/folders/:name/hosts +router.delete( + "/folders/:name/hosts", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const folderName = req.params.name; + + if (!isNonEmptyString(userId) || !folderName) { + return res.status(400).json({ error: "Invalid folder name" }); + } + + try { + // Get all hosts in the folder + const hostsToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + if (hostsToDelete.length === 0) { + return res.json({ + message: "No hosts found in folder", + deletedCount: 0, + }); + } + + // Delete all hosts + await db + .delete(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + // Delete folder metadata + await db + .delete(sshFolders) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)), + ); + + DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + + sshLogger.info("Deleted all hosts in folder", { + operation: "delete_folder_hosts", + userId, + folderName, + deletedCount: hostsToDelete.length, + }); + + res.json({ + message: "All hosts in folder deleted successfully", + deletedCount: hostsToDelete.length, + }); + } catch (err) { + sshLogger.error("Failed to delete hosts in folder", err, { + operation: "delete_folder_hosts", + userId, + folderName, + }); + res.status(500).json({ error: "Failed to delete hosts in folder" }); + } + }, +); + // Route: Bulk import SSH hosts (requires JWT) // POST /ssh/bulk-import router.post( @@ -1533,10 +1603,8 @@ router.post( username: hostData.username, password: hostData.authType === "password" ? hostData.password : null, authType: hostData.authType, - credentialId: hostData.credentialId || null, - overrideCredentialUsername: hostData.overrideCredentialUsername - ? 1 - : 0, + credentialId: + hostData.authType === "credential" ? hostData.credentialId : null, key: hostData.authType === "key" ? hostData.key : null, keyPassword: hostData.authType === "key" diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts new file mode 100644 index 00000000..0b327820 --- /dev/null +++ b/src/backend/database/routes/terminal.ts @@ -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; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 0eb17e2d..6a051784 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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 = { + zip: { + cmd: "zip", + install: "apt install zip / yum install zip / brew install zip", + }, + "tar.gz": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "tar.bz2": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "tar.xz": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + tar: { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "7z": { + cmd: "7z", + install: + "apt install p7zip-full / yum install p7zip / brew install p7zip", + }, + }; + + const info = commandMap[compressionFormat]; + if (info) { + friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`; + } + } + + return res.status(500).json({ error: friendlyError }); + } + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + }); + + res.json({ + success: true, + message: "Files compressed successfully", + archivePath: archivePath, + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH compressFiles stream error:", streamErr, { + operation: "compress_files", + sessionId, + paths, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while compressing files" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index d00330a7..41aa2ff1 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -111,6 +111,7 @@ class SystemCrypto { } else { } } catch (fileError) { + // OK: .env file not found or unreadable, will generate new database key databaseLogger.debug( ".env file not accessible, will generate new database key", { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0dffa20a..e8c5bd21 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -755,6 +755,17 @@ "failedToRemoveFromFolder": "Failed to remove host from folder", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", + "editFolderAppearance": "Edit Folder Appearance", + "editFolderAppearanceDesc": "Customize the color and icon for folder", + "folderColor": "Folder Color", + "folderIcon": "Folder Icon", + "preview": "Preview", + "folderAppearanceUpdated": "Folder appearance updated successfully", + "failedToUpdateFolderAppearance": "Failed to update folder appearance", + "deleteAllHostsInFolder": "Delete All Hosts in Folder", + "confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.", + "allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully", + "failedToDeleteHostsInFolder": "Failed to delete hosts in folder", "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", "failedToMoveToFolder": "Failed to move host to folder", "statistics": "Statistics", @@ -895,6 +906,22 @@ "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", "downloadFile": "Download", + "extractArchive": "Extract Archive", + "extractingArchive": "Extracting {{name}}...", + "archiveExtractedSuccessfully": "{{name}} extracted successfully", + "extractFailed": "Extract failed", + "compressFile": "Compress File", + "compressFiles": "Compress Files", + "compressFilesDesc": "Compress {{count}} items into an archive", + "archiveName": "Archive Name", + "enterArchiveName": "Enter archive name...", + "compressionFormat": "Compression Format", + "selectedFiles": "Selected files", + "andMoreFiles": "and {{count}} more...", + "compress": "Compress", + "compressingFiles": "Compressing {{count}} items into {{name}}...", + "filesCompressedSuccessfully": "{{name}} created successfully", + "compressFailed": "Compression failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", @@ -1172,7 +1199,19 @@ "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "loadFileFailed": "Failed to load file: {{error}}", "connectedSuccessfully": "Connected successfully", - "totpVerificationFailed": "TOTP verification failed" + "totpVerificationFailed": "TOTP verification failed", + "changePermissions": "Change Permissions", + "changePermissionsDesc": "Modify file permissions for", + "currentPermissions": "Current Permissions", + "newPermissions": "New Permissions", + "owner": "Owner", + "group": "Group", + "others": "Others", + "read": "Read", + "write": "Write", + "execute": "Execute", + "permissionsChangedSuccessfully": "Permissions changed successfully", + "failedToChangePermissions": "Failed to change permissions" }, "tunnels": { "title": "SSH Tunnels", @@ -1482,6 +1521,8 @@ "local": "Local", "external": "External (OIDC)", "selectPreferredLanguage": "Select your preferred language for the interface", + "fileColorCoding": "File Color Coding", + "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", "currentPassword": "Current Password", "passwordChangedSuccess": "Password changed successfully! Please log in again.", "failedToChangePassword": "Failed to change password. Please check your current password and try again." diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 3495f971..615d1230 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -766,6 +766,17 @@ "failedToRemoveFromFolder": "从文件夹中移除主机失败", "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", "failedToRenameFolder": "重命名文件夹失败", + "editFolderAppearance": "编辑文件夹外观", + "editFolderAppearanceDesc": "自定义文件夹的颜色和图标", + "folderColor": "文件夹颜色", + "folderIcon": "文件夹图标", + "preview": "预览", + "folderAppearanceUpdated": "文件夹外观更新成功", + "failedToUpdateFolderAppearance": "更新文件夹外观失败", + "deleteAllHostsInFolder": "删除文件夹内所有主机", + "confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。", + "allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机", + "failedToDeleteHostsInFolder": "删除文件夹中的主机失败", "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", "failedToMoveToFolder": "移动主机到文件夹失败", "statistics": "统计", @@ -904,6 +915,22 @@ "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", "downloadFile": "下载", + "extractArchive": "解压文件", + "extractingArchive": "正在解压 {{name}}...", + "archiveExtractedSuccessfully": "{{name}} 解压成功", + "extractFailed": "解压失败", + "compressFile": "压缩文件", + "compressFiles": "压缩文件", + "compressFilesDesc": "将 {{count}} 个项目压缩为归档文件", + "archiveName": "归档文件名", + "enterArchiveName": "输入归档文件名...", + "compressionFormat": "压缩格式", + "selectedFiles": "已选文件", + "andMoreFiles": "以及其他 {{count}} 个...", + "compress": "压缩", + "compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...", + "filesCompressedSuccessfully": "{{name}} 创建成功", + "compressFailed": "压缩失败", "edit": "编辑", "preview": "预览", "previous": "上一页", @@ -1151,7 +1178,19 @@ "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", "loadFileFailed": "加载文件失败:{{error}}", "connectedSuccessfully": "连接成功", - "totpVerificationFailed": "TOTP 验证失败" + "totpVerificationFailed": "TOTP 验证失败", + "changePermissions": "修改权限", + "changePermissionsDesc": "修改文件权限", + "currentPermissions": "当前权限", + "newPermissions": "新权限", + "owner": "所有者", + "group": "组", + "others": "其他", + "read": "读取", + "write": "写入", + "execute": "执行", + "permissionsChangedSuccessfully": "权限修改成功", + "failedToChangePermissions": "权限修改失败" }, "tunnels": { "title": "SSH 隧道", @@ -1442,6 +1481,8 @@ "local": "本地", "external": "外部 (OIDC)", "selectPreferredLanguage": "选择您的界面首选语言", + "fileColorCoding": "文件颜色编码", + "fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)", "currentPassword": "当前密码", "passwordChangedSuccess": "密码修改成功!请重新登录。", "failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。" diff --git a/src/types/index.ts b/src/types/index.ts index cf94b2f4..913cf5fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -64,6 +64,16 @@ export interface SSHHostData { terminalConfig?: TerminalConfig; } +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; +} + // ============================================================================ // CREDENTIAL TYPES // ============================================================================ diff --git a/src/ui/components/LoadingOverlay.tsx b/src/ui/components/LoadingOverlay.tsx new file mode 100644 index 00000000..d6869c8d --- /dev/null +++ b/src/ui/components/LoadingOverlay.tsx @@ -0,0 +1,1739 @@ +import React, { useEffect, useState, useRef } from "react"; +import { cn } from "@/lib/utils"; + +interface LoadingOverlayProps { + visible: boolean; + minDuration?: number; // Minimum display duration in milliseconds + message?: string; + showLogo?: boolean; + className?: string; + backgroundColor?: string; +} + +export function LoadingOverlay({ + visible, + minDuration = 800, + message, + showLogo = true, + className, + backgroundColor, +}: LoadingOverlayProps) { + const [isShowing, setIsShowing] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + const [animationType, setAnimationType] = useState< + "glitch" | "breathe" | "typewriter" | "scanner" | "pulse" + >("glitch"); + const showStartTimeRef = useRef(null); + const minDurationTimerRef = useRef(null); + + useEffect(() => { + if (visible) { + // Randomly choose animation type from 5 options + const animations: ( + | "glitch" + | "breathe" + | "typewriter" + | "scanner" + | "pulse" + )[] = ["glitch", "breathe", "typewriter", "scanner", "pulse"]; + const randomIndex = Math.floor(Math.random() * 5); + setAnimationType(animations[randomIndex]); + + // Start showing immediately + setIsShowing(true); + setIsFadingOut(false); + showStartTimeRef.current = Date.now(); + + // Clear any existing timer + if (minDurationTimerRef.current) { + clearTimeout(minDurationTimerRef.current); + minDurationTimerRef.current = null; + } + } else if (isShowing) { + // Calculate how long it has been showing + const elapsed = showStartTimeRef.current + ? Date.now() - showStartTimeRef.current + : 0; + const remaining = Math.max(0, minDuration - elapsed); + + if (remaining > 0) { + // Wait for minimum duration before hiding + minDurationTimerRef.current = setTimeout(() => { + setIsFadingOut(true); + // Wait for fade-out animation to complete + setTimeout(() => { + setIsShowing(false); + setIsFadingOut(false); + showStartTimeRef.current = null; + }, 300); // Match fade-out duration + }, remaining); + } else { + // Minimum duration already passed, hide immediately + setIsFadingOut(true); + setTimeout(() => { + setIsShowing(false); + setIsFadingOut(false); + showStartTimeRef.current = null; + }, 300); + } + } + + return () => { + if (minDurationTimerRef.current) { + clearTimeout(minDurationTimerRef.current); + minDurationTimerRef.current = null; + } + }; + }, [visible, isShowing, minDuration]); + + if (!isShowing) { + return null; + } + + return ( + <> + + +
+ {animationType === "glitch" ? ( + <> + {/* Fullscreen Glitch Background */} +
+ {/* RGB Split Layers */} +
+
+
+
+
+ + {/* Signal Distortion Bars */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Original Effects */} +
+
+
+ +
+
+ {/* TERMIX Glitch Text */} +
+ TERMIX +
+ + {/* Scan line effect */} +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : animationType === "breathe" ? ( + <> + {/* Fullscreen Elegant Background */} +
+ {/* Floating Particles Field */} +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+ {/* Pulse rings */} +
+
+
+ + {/* Orbiting dots */} +
+
+
+
+
+
+ + {/* Particles */} +
+
+
+
+
+
+
+
+
+
+ + {/* TERMIX Breathe Text */} +
+ T + E + R + M + I + X +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : animationType === "typewriter" ? ( + <> + {/* Fullscreen Retro Terminal Background */} +
+ {/* ASCII Character Rain */} +
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ {`$\n>\n_\n{\n}\n[\n]\n|\n/\n\\\n-\n+\n*\n#\n@\n%`} +
+ ))} +
+ + {/* CRT Scanline */} +
+ + {/* Cursor Trails */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+ {/* TERMIX Typewriter Text */} +
+ T + E + R + M + I + X + +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : animationType === "scanner" ? ( + <> + {/* Fullscreen Matrix Background */} +
+ {/* Grid Background */} +
+ + {/* Matrix Digital Rain */} +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ {`01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00`} +
+ ))} +
+ + {/* Random Code Fragments */} +
+
+ {"{"} ssh: 22 {"}"} +
+
+ {"<"} connect... {">"} +
+
0x4A3F2B1D
+
[SCANNING...]
+
{">"} _
+
+ + {/* Powerful Scan Beam */} +
+
+ +
+
+ {/* TERMIX Scanner Text */} +
+ TERMIX +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : ( + <> + {/* Fullscreen Radar/Sonar Background */} +
+ {/* Radar Circular Grid */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + {/* Radar Cross Lines */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Sonar Pulse Waves */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + {/* Radar Targets (Detection Points) */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+ {/* Wave Rings */} +
+
+
+
+
+ + {/* Radar Sweep */} +
+ + {/* Center Dot */} +
+ + {/* TERMIX Pulse Text */} +
+ TERMIX +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + )} +
+ + ); +} diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index cd1ff279..5c6dc0ce 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -16,6 +16,8 @@ import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; +import { PermissionsDialog } from "./components/PermissionsDialog"; +import { CompressDialog } from "./components/CompressDialog"; import { Upload, FolderPlus, @@ -49,6 +51,9 @@ import { addFolderShortcut, getPinnedFiles, logActivity, + changeSSHPermissions, + extractSSHArchive, + compressSSHFiles, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -146,6 +151,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); + const [permissionsDialogFile, setPermissionsDialogFile] = + useState(null); + const [compressDialogFiles, setCompressDialogFiles] = useState( + [], + ); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -1037,6 +1047,82 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } + async function handleExtractArchive(file: FileItem) { + if (!sshSessionId) return; + + try { + await ensureSSHConnection(); + + toast.info(t("fileManager.extractingArchive", { name: file.name })); + + await extractSSHArchive( + sshSessionId, + file.path, + undefined, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success( + t("fileManager.archiveExtractedSuccessfully", { name: file.name }), + ); + + // Refresh directory to show extracted files + handleRefreshDirectory(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + + function handleOpenCompressDialog(files: FileItem[]) { + setCompressDialogFiles(files); + } + + async function handleCompress(archiveName: string, format: string) { + if (!sshSessionId || compressDialogFiles.length === 0) return; + + try { + await ensureSSHConnection(); + + const paths = compressDialogFiles.map((f) => f.path); + const fileNames = compressDialogFiles.map((f) => f.name); + + toast.info( + t("fileManager.compressingFiles", { + count: fileNames.length, + name: archiveName, + }), + ); + + await compressSSHFiles( + sshSessionId, + paths, + archiveName, + format, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success( + t("fileManager.filesCompressedSuccessfully", { + name: archiveName, + }), + ); + + // Refresh directory to show compressed file + handleRefreshDirectory(); + clearSelection(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); @@ -1159,6 +1245,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setEditingFile(file); } + function handleOpenPermissionsDialog(file: FileItem) { + setPermissionsDialogFile(file); + } + + async function handleSavePermissions(file: FileItem, permissions: string) { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + await changeSSHPermissions( + sshSessionId, + file.path, + permissions, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.permissionsChangedSuccessfully")); + await handleRefreshDirectory(); + } catch (error: unknown) { + console.error("Failed to change permissions:", error); + toast.error(t("fileManager.failedToChangePermissions")); + throw error; + } + } + async function ensureSSHConnection() { if (!sshSessionId || !currentHost || isReconnecting) return; @@ -1947,10 +2061,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onAddShortcut={handleAddShortcut} isPinned={isPinnedFile} currentPath={currentPath} + onProperties={handleOpenPermissionsDialog} + onExtractArchive={handleExtractArchive} + onCompress={handleOpenCompressDialog} /> + 0} + onOpenChange={(open) => !open && setCompressDialogFiles([])} + fileNames={compressDialogFiles.map((f) => f.name)} + onCompress={handleCompress} + /> + )} + + { + if (!open) setPermissionsDialogFile(null); + }} + onSave={handleSavePermissions} + /> ); } diff --git a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx index e11cbf6c..73f09e10 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx @@ -17,6 +17,7 @@ import { Play, Star, Bookmark, + FileArchive, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; @@ -60,6 +61,8 @@ interface ContextMenuProps { onAddShortcut?: (path: string) => void; isPinned?: (file: FileItem) => boolean; currentPath?: string; + onExtractArchive?: (file: FileItem) => void; + onCompress?: (files: FileItem[]) => void; } interface MenuItem { @@ -99,6 +102,8 @@ export function FileManagerContextMenu({ onAddShortcut, isPinned, currentPath, + onExtractArchive, + onCompress, }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -254,6 +259,45 @@ export function FileManagerContextMenu({ }); } + // Add extract option for archive files + if (isSingleFile && files[0].type === "file" && onExtractArchive) { + const fileName = files[0].name.toLowerCase(); + const isArchive = + fileName.endsWith(".zip") || + fileName.endsWith(".tar") || + fileName.endsWith(".tar.gz") || + fileName.endsWith(".tgz") || + fileName.endsWith(".tar.bz2") || + fileName.endsWith(".tbz2") || + fileName.endsWith(".tar.xz") || + fileName.endsWith(".gz") || + fileName.endsWith(".bz2") || + fileName.endsWith(".xz") || + fileName.endsWith(".7z") || + fileName.endsWith(".rar"); + + if (isArchive) { + menuItems.push({ + icon: , + label: t("fileManager.extractArchive"), + action: () => onExtractArchive(files[0]), + shortcut: "Ctrl+E", + }); + } + } + + // Add compress option for selected files/folders + if (isFileContext && onCompress) { + menuItems.push({ + icon: , + label: isMultipleFiles + ? t("fileManager.compressFiles") + : t("fileManager.compressFile"), + action: () => onCompress(files), + shortcut: "Ctrl+Shift+C", + }); + } + if (isSingleFile && files[0].type === "file") { const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; @@ -451,7 +495,7 @@ export function FileManagerContextMenu({
@@ -460,7 +504,7 @@ export function FileManagerContextMenu({ className={cn( "fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden", "transition-all duration-150 ease-out origin-top-left", - isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95" + isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95", )} style={{ left: menuPosition.x, diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 8809a7bd..c49c80b3 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -24,6 +24,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { FileItem } from "../../../types/index.js"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; interface CreateIntent { id: string; @@ -96,15 +97,33 @@ interface FileManagerGridProps { onNewFolder?: () => void; } -const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { - const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6"; +const getFileTypeColor = (file: FileItem): string => { + const colorEnabled = localStorage.getItem("fileColorCoding") !== "false"; + if (!colorEnabled) { + return "text-muted-foreground"; + } if (file.type === "directory") { - return ; + return "text-red-400"; } if (file.type === "link") { - return ; + 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 ; + } + + if (file.type === "link") { + return ; } const ext = file.name.split(".").pop()?.toLowerCase(); @@ -113,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "txt": case "md": case "readme": - return ; + return ; case "png": case "jpg": case "jpeg": case "gif": case "bmp": case "svg": - return ; + return ; case "mp4": case "avi": case "mkv": case "mov": - return ; + return ; case "mp3": case "wav": case "flac": case "ogg": - return ; + return ; case "zip": case "tar": case "gz": case "rar": case "7z": - return ; + return ; case "js": case "ts": case "jsx": @@ -150,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "rb": case "go": case "rs": - return ; + return ; case "json": case "xml": case "yaml": @@ -159,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "ini": case "conf": case "config": - return ; + return ; default: - return ; + return ; } }; @@ -853,19 +872,8 @@ export function FileManagerGrid({ onUndo, ]); - if (isLoading) { - return ( -
-
-
-

{t("common.loading")}

-
-
- ); - } - return ( -
+
, document.body, )} + +
); } diff --git a/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx new file mode 100644 index 00000000..75ee47ec --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx @@ -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 = { + 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 ( + + + + {t("fileManager.compressFiles")} + + {t("fileManager.compressFilesDesc", { count: fileNames.length })} + + + +
+
+ + setArchiveName(e.target.value)} + placeholder={t("fileManager.enterArchiveName")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCompress(); + } + }} + /> +
+ +
+ + +
+ +
+

+ {t("fileManager.selectedFiles")}: +

+
    + {fileNames.slice(0, 5).map((name, index) => ( +
  • + • {name} +
  • + ))} + {fileNames.length > 5 && ( +
  • + {t("fileManager.andMoreFiles", { + count: fileNames.length - 5, + })} +
  • + )} +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx new file mode 100644 index 00000000..501575c2 --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx @@ -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; +} + +// 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 ( + + + + + + {t("fileManager.changePermissions")} + + + {t("fileManager.changePermissionsDesc")}:{" "} + {file.name} + + + +
+ {/* Current info */} +
+
+ +

+ {file.permissions || "644"} +

+
+
+ +

{octal}

+
+
+ + {/* Owner permissions */} +
+ +
+
+ setOwnerRead(checked === true)} + /> + +
+
+ setOwnerWrite(checked === true)} + /> + +
+
+ + setOwnerExecute(checked === true) + } + /> + +
+
+
+ + {/* Group permissions */} +
+ +
+
+ setGroupRead(checked === true)} + /> + +
+
+ setGroupWrite(checked === true)} + /> + +
+
+ + setGroupExecute(checked === true) + } + /> + +
+
+
+ + {/* Others permissions */} +
+ +
+
+ setOtherRead(checked === true)} + /> + +
+
+ setOtherWrite(checked === true)} + /> + +
+
+ + setOtherExecute(checked === true) + } + /> + +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index c438b447..20a704c2 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -22,10 +22,15 @@ import { updateSSHHost, renameFolder, exportSSHHostWithCredentials, + getSSHFolders, + updateFolderMetadata, + deleteAllHostsInFolder, + getServerStatusById, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Edit, Trash2, @@ -45,16 +50,31 @@ import { Copy, Activity, Clock, + Palette, + Trash, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + FolderOpen, } from "lucide-react"; import type { SSHHost, + SSHFolder, SSHManagerHostViewerProps, } from "../../../../types/index.js"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; +import { FolderEditDialog } from "./components/FolderEditDialog"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); + const { addTab } = useTabs(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -65,13 +85,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); + const [folderMetadata, setFolderMetadata] = useState>( + new Map(), + ); + const [editingFolderAppearance, setEditingFolderAppearance] = useState< + string | null + >(null); + const [serverStatuses, setServerStatuses] = useState< + Map + >(new Map()); const dragCounter = useRef(0); useEffect(() => { fetchHosts(); + fetchFolderMetadata(); const handleHostsRefresh = () => { fetchHosts(); + fetchFolderMetadata(); }; window.addEventListener("hosts:refresh", handleHostsRefresh); @@ -116,6 +147,159 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { } }; + const fetchFolderMetadata = async () => { + try { + const folders = await getSSHFolders(); + const metadataMap = new Map(); + 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 = { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + }; + + return iconMap[metadata.icon] || Folder; + }; + + const getFolderColor = (folderName: string) => { + const metadata = folderMetadata.get(folderName); + return metadata?.color; + }; + const handleDelete = async (hostId: number, hostName: string) => { confirmWithToast( t("hosts.confirmDelete", { name: hostName }), @@ -854,7 +1038,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
- + {(() => { + const FolderIcon = getFolderIcon(folder); + const folderColor = getFolderColor(folder); + return ( + + ); + })()} {editingFolder === folder ? (
{folderHosts.length} + {folder !== t("hosts.uncategorized") && ( +
+ + + + + + + {t("hosts.editFolderAppearance")} + + + + + + + + + + {t("hosts.deleteAllHostsInFolder")} + + + +
+ )}
@@ -957,6 +1196,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
+ {(() => { + 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 ? ( + + + + ) : null; + })()} {host.pin && ( )} @@ -1179,6 +1444,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { })()}
+ +
+ {host.enableTerminal && ( + + + + + +

Open Terminal

+
+
+ )} + {host.enableFileManager && ( + + + + + +

Open File Manager

+
+
+ )} + + + + + +

Open Server Details

+
+
+
@@ -1202,6 +1549,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ))}
+ + {editingFolderAppearance && ( + { + if (!open) setEditingFolderAppearance(null); + }} + onSave={async (color, icon) => { + await handleSaveFolderAppearance( + editingFolderAppearance, + color, + icon, + ); + setEditingFolderAppearance(null); + }} + /> + )}
); } diff --git a/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx new file mode 100644 index 00000000..7e9fb075 --- /dev/null +++ b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx @@ -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; +} + +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 ( + + + + + + {t("hosts.editFolderAppearance")} + + + {t("hosts.editFolderAppearanceDesc")}:{" "} + {folderName} + + + +
+ {/* Color Selection */} +
+ +
+ {AVAILABLE_COLORS.map((color) => ( +
+
+ + {/* Icon Selection */} +
+ +
+ {AVAILABLE_ICONS.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {/* Preview */} +
+ +
+ {(() => { + const IconComponent = + AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon || + Folder; + return ( + + ); + })()} + {folderName} +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 21879e0f..4cf5be8c 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -17,6 +17,7 @@ import { type StatsConfig, DEFAULT_STATS_CONFIG, } from "@/types/stats-widgets"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; import { CpuWidget, MemoryWidget, @@ -443,17 +444,8 @@ export function Server({
{metricsEnabled && showStatsUI && ( -
- {isLoadingMetrics && !metrics ? ( -
-
-
- - {t("serverStats.loadingMetrics")} - -
-
- ) : !metrics && serverStatus === "offline" ? ( +
+ {!metrics && serverStatus === "offline" ? (
@@ -476,6 +468,13 @@ export function Server({ ))}
)} + +
)} diff --git a/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx new file mode 100644 index 00000000..d0d77aaf --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx @@ -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(null); + const selectedRef = useRef(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 ( +
+ {suggestions.map((suggestion, index) => ( +
onSelect(suggestion)} + onMouseEnter={() => { + // Optional: update selected index on hover + }} + > + {suggestion} +
+ ))} +
+ Tab/Enter to complete • ↑↓ to navigate • Esc to close +
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx new file mode 100644 index 00000000..b23e5d6a --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx @@ -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(null); + const listRef = useRef(null); + const selectedRef = useRef(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 ( + + + + + + Command History + + + +
+
+ + { + setSearchQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+
+ + + {isLoading ? ( +
+
+
+ Loading history... +
+
+ ) : filteredCommands.length === 0 ? ( +
+ {searchQuery ? ( + <> + +

No commands found matching "{searchQuery}"

+ + ) : ( + <> + +

No command history yet

+

+ Execute commands to build your history +

+ + )} +
+ ) : ( +
+ {filteredCommands.map((command, index) => ( +
setSelectedIndex(index)} + > + handleSelect(command)} + > + {command} + + {onDeleteCommand && ( + + )} +
+ ))} +
+ )} +
+ +
+
+
+ + + ↑↓ + {" "} + Navigate + + + + Enter + {" "} + Select + + + + Esc + {" "} + Close + +
+ + {filteredCommands.length} command + {filteredCommands.length !== 1 ? "s" : ""} + +
+
+
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 0c03136b..1aba49dd 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -4,6 +4,7 @@ import { useState, useImperativeHandle, forwardRef, + useCallback, } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; @@ -26,6 +27,11 @@ import { TERMINAL_FONTS, } from "@/constants/terminal-themes"; import type { TerminalConfig } from "@/types"; +import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; +import { useCommandHistory } from "@/ui/hooks/useCommandHistory"; +import { CommandHistoryDialog } from "./CommandHistoryDialog"; +import { CommandAutocomplete } from "./CommandAutocomplete"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; interface HostConfig { id?: number; @@ -112,7 +118,6 @@ export const Terminal = forwardRef( const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = useState(false); const isVisibleRef = useRef(false); - const isReadyRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -123,6 +128,104 @@ export const Terminal = forwardRef( const isConnectingRef = useRef(false); const connectionTimeoutRef = useRef(null); const activityLoggedRef = useRef(false); + const keyHandlerAttachedRef = useRef(false); + + // Command history tracking (Stage 1) + const { trackInput, getCurrentCommand, updateCurrentCommand } = + useCommandTracker({ + hostId: hostConfig.id, + enabled: true, + onCommandExecuted: (command) => { + // Add to autocomplete history (Stage 3) + if (!autocompleteHistory.current.includes(command)) { + autocompleteHistory.current = [ + command, + ...autocompleteHistory.current, + ]; + } + }, + }); + + // Create refs for callbacks to avoid triggering useEffect re-runs + const getCurrentCommandRef = useRef(getCurrentCommand); + const updateCurrentCommandRef = useRef(updateCurrentCommand); + + useEffect(() => { + getCurrentCommandRef.current = getCurrentCommand; + updateCurrentCommandRef.current = updateCurrentCommand; + }, [getCurrentCommand, updateCurrentCommand]); + + // Real-time autocomplete (Stage 3) + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< + string[] + >([]); + const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = + useState(0); + const [autocompletePosition, setAutocompletePosition] = useState({ + top: 0, + left: 0, + }); + const autocompleteHistory = useRef([]); + const currentAutocompleteCommand = useRef(""); + + // Refs for accessing current state in event handlers + const showAutocompleteRef = useRef(false); + const autocompleteSuggestionsRef = useRef([]); + const autocompleteSelectedIndexRef = useRef(0); + + // Command history dialog (Stage 2) + const [showHistoryDialog, setShowHistoryDialog] = useState(false); + const [commandHistory, setCommandHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // Load command history when dialog opens + useEffect(() => { + if (showHistoryDialog && hostConfig.id) { + setIsLoadingHistory(true); + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + setCommandHistory(history); + }) + .catch((error) => { + console.error("Failed to load command history:", error); + setCommandHistory([]); + }) + .finally(() => { + setIsLoadingHistory(false); + }); + } + }, [showHistoryDialog, hostConfig.id]); + + // Load command history for autocomplete on mount (Stage 3) + useEffect(() => { + if (hostConfig.id) { + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + autocompleteHistory.current = history; + }) + .catch((error) => { + console.error("Failed to load autocomplete history:", error); + autocompleteHistory.current = []; + }); + } + }, [hostConfig.id]); + + // Sync autocomplete state to refs for event handlers + useEffect(() => { + showAutocompleteRef.current = showAutocomplete; + }, [showAutocomplete]); + + useEffect(() => { + autocompleteSuggestionsRef.current = autocompleteSuggestions; + }, [autocompleteSuggestions]); + + useEffect(() => { + autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; + }, [autocompleteSelectedIndex]); + const activityLoggingRef = useRef(false); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -158,10 +261,6 @@ export const Terminal = forwardRef( isVisibleRef.current = isVisible; }, [isVisible]); - useEffect(() => { - isReadyRef.current = isReady; - }, [isReady]); - useEffect(() => { const checkAuth = () => { const jwtToken = getCookie("jwt"); @@ -516,9 +615,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { - if (data === "\x00" || data === "\u0000") { - return; - } + // Track command input for history (Stage 1) + trackInput(data); + // Send input to server ws.send(JSON.stringify({ type: "input", data })); }); @@ -778,6 +877,88 @@ export const Terminal = forwardRef( return ""; } + // Handle command selection from history dialog (Stage 2) + const handleSelectCommand = useCallback( + (command: string) => { + if (!terminal || !webSocketRef.current) return; + + // Send the command to the terminal + // Simulate typing the command character by character + for (const char of command) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + + // Return focus to terminal after selecting command + setTimeout(() => { + terminal.focus(); + }, 100); + }, + [terminal], + ); + + // Handle autocomplete selection (mouse click) + const handleAutocompleteSelect = useCallback( + (selectedCommand: string) => { + if (!webSocketRef.current) return; + + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + + // Update current command tracker + updateCurrentCommand(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + // Return focus to terminal + setTimeout(() => { + terminal?.focus(); + }, 50); + + console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`); + }, + [terminal, updateCurrentCommand], + ); + + // Handle command deletion from history dialog + const handleDeleteCommand = useCallback( + async (command: string) => { + if (!hostConfig.id) return; + + try { + // Call API to delete command + const { deleteCommandFromHistory } = await import( + "@/ui/main-axios.ts" + ); + await deleteCommandFromHistory(hostConfig.id, command); + + // Update local state + setCommandHistory((prev) => prev.filter((cmd) => cmd !== command)); + + // Update autocomplete history + autocompleteHistory.current = autocompleteHistory.current.filter( + (cmd) => cmd !== command, + ); + + console.log(`[Terminal] Command deleted from history: ${command}`); + } catch (error) { + console.error("Failed to delete command from history:", error); + } + }, + [hostConfig.id], + ); + useEffect(() => { if (!terminal || !xtermRef.current) return; @@ -882,6 +1063,20 @@ export const Terminal = forwardRef( navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + // Handle Ctrl+R for command history (Stage 2) + if ( + e.ctrlKey && + e.key === "r" && + !e.shiftKey && + !e.altKey && + !e.metaKey + ) { + e.preventDefault(); + e.stopPropagation(); + setShowHistoryDialog(true); + return false; + } + if ( config.backspaceMode === "control-h" && e.key === "Backspace" && @@ -933,21 +1128,15 @@ export const Terminal = forwardRef( element?.addEventListener("keydown", handleMacKeyboard, true); - const handleResize = () => { + const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReadyRef.current) return; + if (!isVisibleRef.current || !isReady) return; performFit(); - }, 100); - }; + }, 50); + }); - const resizeObserver = new ResizeObserver(handleResize); - - if (xtermRef.current) { - resizeObserver.observe(xtermRef.current); - } - - window.addEventListener("resize", handleResize); + resizeObserver.observe(xtermRef.current); setVisible(true); @@ -960,7 +1149,6 @@ export const Terminal = forwardRef( setIsReady(false); isFittingRef.current = false; resizeObserver.disconnect(); - window.removeEventListener("resize", handleResize); element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("keydown", handleMacKeyboard, true); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); @@ -977,6 +1165,192 @@ export const Terminal = forwardRef( }; }, [xtermRef, terminal, hostConfig]); + // Register keyboard handler for autocomplete (Stage 3) + // Registered only once when terminal is created + useEffect(() => { + if (!terminal) return; + + const handleCustomKey = (e: KeyboardEvent): boolean => { + // Only handle keydown events, ignore keyup to prevent double triggering + if (e.type !== "keydown") { + return true; + } + + // If autocomplete is showing, handle keys specially + if (showAutocompleteRef.current) { + // Handle Escape to close autocomplete + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return false; + } + + // Handle Arrow keys for autocomplete navigation + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + + if (e.key === "ArrowDown") { + const newIndex = + currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + } else if (e.key === "ArrowUp") { + const newIndex = + currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; + setAutocompleteSelectedIndex(newIndex); + } + return false; + } + + // Handle Enter to confirm autocomplete selection + if ( + e.key === "Enter" && + autocompleteSuggestionsRef.current.length > 0 + ) { + e.preventDefault(); + e.stopPropagation(); + + const selectedCommand = + autocompleteSuggestionsRef.current[ + autocompleteSelectedIndexRef.current + ]; + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + if (webSocketRef.current?.readyState === 1) { + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + } + + // Update current command tracker + updateCurrentCommandRef.current(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + return false; + } + + // Handle Tab to cycle through suggestions + if ( + e.key === "Tab" && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey + ) { + e.preventDefault(); + e.stopPropagation(); + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + const newIndex = + currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + return false; + } + + // For any other key while autocomplete is showing, close it and let key through + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return true; + } + + // Handle Tab for autocomplete (when autocomplete is not showing) + if ( + e.key === "Tab" && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey + ) { + e.preventDefault(); + e.stopPropagation(); + + const currentCmd = getCurrentCommandRef.current().trim(); + if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { + // Filter commands that start with current input + const matches = autocompleteHistory.current + .filter( + (cmd) => + cmd.startsWith(currentCmd) && + cmd !== currentCmd && + cmd.length > currentCmd.length, + ) + .slice(0, 10); // Show up to 10 matches + + if (matches.length === 1) { + // Only one match - auto-complete directly + const completedCommand = matches[0]; + const completion = completedCommand.substring(currentCmd.length); + + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + + updateCurrentCommandRef.current(completedCommand); + } else if (matches.length > 1) { + // Multiple matches - show selection list + currentAutocompleteCommand.current = currentCmd; + setAutocompleteSuggestions(matches); + setAutocompleteSelectedIndex(0); + + // Calculate position (below or above cursor based on available space) + const cursorY = terminal.buffer.active.cursorY; + const cursorX = terminal.buffer.active.cursorX; + const rect = xtermRef.current?.getBoundingClientRect(); + + if (rect) { + const cellHeight = + terminal.rows > 0 ? rect.height / terminal.rows : 20; + const cellWidth = + terminal.cols > 0 ? rect.width / terminal.cols : 10; + + // Estimate autocomplete menu height (max-h-[240px] from component) + const menuHeight = 240; + const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; + const spaceBelow = window.innerHeight - cursorBottomY; + const spaceAbove = rect.top + cursorY * cellHeight; + + // Show above cursor if not enough space below + const showAbove = + spaceBelow < menuHeight && spaceAbove > spaceBelow; + + setAutocompletePosition({ + top: showAbove + ? rect.top + cursorY * cellHeight - menuHeight + : cursorBottomY, + left: rect.left + cursorX * cellWidth, + }); + } + + setShowAutocomplete(true); + } + } + return false; // Prevent default Tab behavior + } + + // Let terminal handle all other keys + return true; + }; + + terminal.attachCustomKeyEventHandler(handleCustomKey); + }, [terminal]); + useEffect(() => { if (!terminal || !hostConfig || !visible) return; @@ -1103,17 +1477,30 @@ export const Terminal = forwardRef( backgroundColor={backgroundColor} /> - {isConnecting && ( -
-
-
- {t("terminal.connecting")} -
-
- )} + + + + +
); }, diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 12f94aaa..2885ea02 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -5,7 +5,12 @@ import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs.tsx"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@/components/ui/tabs.tsx"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx"; import { toast } from "sonner"; @@ -559,61 +564,65 @@ export function Auth({ if (success) { setOidcLoading(true); - // Clear the success parameter first to prevent re-processing - window.history.replaceState({}, document.title, window.location.pathname); - - setTimeout(() => { - getUserInfo() - .then((meRes) => { - if (isInElectronWebView()) { - const token = getCookie("jwt") || localStorage.getItem("jwt"); - if (token) { - try { - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - token: token, - source: "oidc_callback", - platform: "desktop", - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - setTimeout(() => window.location.reload(), 100); - setOidcLoading(false); - return; - } catch (e) { - console.error("Error posting auth success message:", e); - } + getUserInfo() + .then((meRes) => { + if (isInElectronWebView()) { + const token = getCookie("jwt") || localStorage.getItem("jwt"); + if (token) { + try { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + token: token, + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + setTimeout(() => window.location.reload(), 100); + setOidcLoading(false); + return; + } catch (e) { + console.error("Error posting auth success message:", e); } } + } - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, - }); - setInternalLoggedIn(true); - }) - .catch((err) => { - console.error("Failed to get user info after OIDC callback:", err); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - }) - .finally(() => { - setOidcLoading(false); + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, }); - }, 200); + setInternalLoggedIn(true); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + }) + .catch(() => { + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + }) + .finally(() => { + setOidcLoading(false); + }); } }, [ onAuthSuccess, @@ -836,19 +845,18 @@ export function Auth({ > {/* Split Screen Layout */}
- {/* Left Side - Brand Showcase */}
{/* Logo and Branding */} @@ -856,7 +864,8 @@ export function Auth({
TERMIX @@ -865,479 +874,520 @@ export function Auth({ {t("auth.tagline") || "SSH TERMINAL MANAGER"}
- {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"} + {t("auth.description") || + "Secure, powerful, and intuitive SSH connection management"}
{/* Right Side - Auth Form */}
-
- {isInElectronWebView() && !webviewAuthSuccess && ( - - - {t("auth.desktopApp")} - {t("auth.loggingInToDesktopApp")} - - )} - {isInElectronWebView() && webviewAuthSuccess && ( -
-
-

- {t("messages.loginSuccess")} -

-

- {t("auth.redirectingToApp")} -

-
-
- )} - {!webviewAuthSuccess && totpRequired && ( -
-
-

- {t("auth.twoFactorAuth")} -

-

{t("auth.enterCode")}

-
- -
- - setTotpCode(e.target.value.replace(/\D/g, ""))} - disabled={totpLoading} - className="text-center text-2xl tracking-widest font-mono" - autoComplete="one-time-code" - /> -

- {t("auth.backupCode")} -

-
- - - - -
- )} - - {!webviewAuthSuccess && !loggedIn && !authLoading && !totpRequired && ( - <> - {(() => { - const hasLogin = passwordLoginAllowed && !firstUser; - const hasSignup = - (passwordLoginAllowed || firstUser) && registrationAllowed; - const hasOIDC = oidcConfigured; - const hasAnyAuth = hasLogin || hasSignup || hasOIDC; - - if (!hasAnyAuth) { - return ( + {isInElectronWebView() && !webviewAuthSuccess && ( + + + {t("auth.desktopApp")} + + {t("auth.loggingInToDesktopApp")} + + + )} + {isInElectronWebView() && webviewAuthSuccess && ( +
-

- {t("auth.authenticationDisabled")} +

+ {t("messages.loginSuccess")}

- {t("auth.authenticationDisabledDesc")} + {t("auth.redirectingToApp")}

- ); - } - - return ( - <> - {/* Tab Navigation */} - { - const newTab = value as "login" | "signup" | "external" | "reset"; - setTab(newTab); - if (tab === "reset") resetPasswordState(); - if ((tab === "login" && newTab === "signup") || (tab === "signup" && newTab === "login")) { - clearFormFields(); - } - }} className="w-full mb-8"> - - {passwordLoginAllowed && ( - - {t("common.login")} - - )} - {(passwordLoginAllowed || firstUser) && registrationAllowed && ( - - {t("common.register")} - - )} - {oidcConfigured && ( - - {t("auth.external")} - - )} - - - - {/* Page Title */} -
-

- {tab === "login" - ? t("auth.loginTitle") - : tab === "signup" - ? t("auth.registerTitle") - : tab === "external" - ? t("auth.loginWithExternal") - : t("auth.forgotPassword")} +

+ )} + {!webviewAuthSuccess && totpRequired && ( +
+
+

+ {t("auth.twoFactorAuth")}

+

{t("auth.enterCode")}

- {tab === "external" || tab === "reset" ? ( -
- {tab === "external" && ( - <> -
-

{t("auth.loginWithExternalDesc")}

+
+ + + setTotpCode(e.target.value.replace(/\D/g, "")) + } + disabled={totpLoading} + className="text-center text-2xl tracking-widest font-mono" + autoComplete="one-time-code" + /> +

+ {t("auth.backupCode")} +

+
+ + + + +
+ )} + + {!webviewAuthSuccess && + !loggedIn && + !authLoading && + !totpRequired && ( + <> + {(() => { + const hasLogin = passwordLoginAllowed && !firstUser; + const hasSignup = + (passwordLoginAllowed || firstUser) && + registrationAllowed; + const hasOIDC = oidcConfigured; + const hasAnyAuth = hasLogin || hasSignup || hasOIDC; + + if (!hasAnyAuth) { + return ( +
+

+ {t("auth.authenticationDisabled")} +

+

+ {t("auth.authenticationDisabledDesc")} +

- {(() => { - if (isElectron()) { - return ( -
-

- {t("auth.externalNotSupportedInElectron")} -

-
- ); - } else { - return ( - - ); - } - })()} - - )} - {tab === "reset" && ( + ); + } + + return ( <> - {resetStep === "initiate" && ( - <> - - {t("common.warning")} - - {t("auth.dataLossWarning")} - - -
-

{t("auth.resetCodeDesc")}

+ {/* Tab Navigation */} + { + const newTab = value as + | "login" + | "signup" + | "external" + | "reset"; + setTab(newTab); + if (tab === "reset") resetPasswordState(); + if ( + (tab === "login" && newTab === "signup") || + (tab === "signup" && newTab === "login") + ) { + clearFormFields(); + } + }} + className="w-full mb-8" + > + + {passwordLoginAllowed && ( + + {t("common.login")} + + )} + {(passwordLoginAllowed || firstUser) && + registrationAllowed && ( + + {t("common.register")} + + )} + {oidcConfigured && ( + + {t("auth.external")} + + )} + + + + {/* Page Title */} +
+

+ {tab === "login" + ? t("auth.loginTitle") + : tab === "signup" + ? t("auth.registerTitle") + : tab === "external" + ? t("auth.loginWithExternal") + : t("auth.forgotPassword")} +

+
+ + {tab === "external" || tab === "reset" ? ( +
+ {tab === "external" && ( + <> +
+

{t("auth.loginWithExternalDesc")}

+
+ {(() => { + if (isElectron()) { + return ( +
+

+ {t( + "auth.externalNotSupportedInElectron", + )} +

+
+ ); + } else { + return ( + + ); + } + })()} + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> + + + {t("common.warning")} + + + {t("auth.dataLossWarning")} + + +
+

{t("auth.resetCodeDesc")}

+
+
+
+ + + setLocalUsername(e.target.value) + } + disabled={resetLoading} + /> +
+ +
+ + )} + + {resetStep === "verify" && ( + <> +
+

+ {t("auth.enterResetCode")}{" "} + {localUsername} +

+
+
+
+ + + setResetCode( + e.target.value.replace(/\D/g, ""), + ) + } + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetStep === "newPassword" && + !resetSuccess && ( + <> +
+

+ {t("auth.enterNewPassword")}{" "} + {localUsername} +

+
+
+
+ + + setNewPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+
+ + + setConfirmPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+ + +
+ + )} + + )} +
+ ) : ( +
+
+ + + setLocalUsername(e.target.value) + } + disabled={loading || loggedIn} + />
-
+
+ + setPassword(e.target.value)} + disabled={loading || loggedIn} + /> +
+ {tab === "signup" && (
-
- -
- - )} - - {resetStep === "verify" && ( - <> -
-

- {t("auth.enterResetCode")}{" "} - {localUsername} -

-
-
-
- - - setResetCode( - e.target.value.replace(/\D/g, ""), - ) - } - disabled={resetLoading} - placeholder="000000" - /> -
- + )} + + {tab === "login" && ( -
- + )} +
)} - {resetStep === "newPassword" && !resetSuccess && ( - <> -
-

- {t("auth.enterNewPassword")}{" "} - {localUsername} -

+
+
+
+
-
-
-
+ {isElectron() && currentServerUrl && ( +
+
+ - - setNewPassword(e.target.value) - } - disabled={resetLoading} - autoComplete="new-password" - /> +
+ {currentServerUrl} +
-
- - - setConfirmPassword(e.target.value) - } - disabled={resetLoading} - autoComplete="new-password" - /> -
-
- - )} - - )} -
- ) : ( -
-
- - setLocalUsername(e.target.value)} - disabled={loading || loggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || loggedIn} - /> -
- {tab === "signup" && ( -
- - - setSignupConfirmPassword(e.target.value) - } - disabled={loading || loggedIn} - /> -
- )} - - {tab === "login" && ( - - )} -
- )} - -
-
-
- -
- -
- {isElectron() && currentServerUrl && ( -
-
- -
- {currentServerUrl} + )}
-
- -
- )} -
- - ); - })()} - - )} + + ); + })()} + + )}
diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 15b0e812..29fe0f46 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -34,8 +34,9 @@ import { import { Input } from "@/components/ui/input.tsx"; import { Button } from "@/components/ui/button.tsx"; import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx"; -import { getSSHHosts } from "@/ui/main-axios.ts"; +import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import type { SSHFolder } from "@/types/index.ts"; interface SSHHost { id: number; @@ -114,7 +115,11 @@ export function LeftSidebar({ Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const openSshManagerTab = () => { - if (sshManagerTab || isSplitScreenActive) return; + if (isSplitScreenActive) return; + if (sshManagerTab) { + setCurrentTab(sshManagerTab.id); + return; + } const id = addTab({ type: "ssh_manager", title: "Host Manager" }); setCurrentTab(id); }; @@ -145,6 +150,22 @@ export function LeftSidebar({ const prevHostsRef = React.useRef([]); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); + const [folderMetadata, setFolderMetadata] = useState>( + new Map(), + ); + + const fetchFolderMetadata = React.useCallback(async () => { + try { + const folders = await getSSHFolders(); + const metadataMap = new Map(); + folders.forEach((folder) => { + metadataMap.set(folder.name, folder); + }); + setFolderMetadata(metadataMap); + } catch (error) { + console.error("Failed to fetch folder metadata:", error); + } + }, []); const fetchHosts = React.useCallback(async () => { try { @@ -210,13 +231,18 @@ export function LeftSidebar({ React.useEffect(() => { fetchHosts(); - const interval = setInterval(fetchHosts, 300000); + fetchFolderMetadata(); + const interval = setInterval(() => { + fetchHosts(); + fetchFolderMetadata(); + }, 300000); return () => clearInterval(interval); - }, [fetchHosts]); + }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handleHostsChanged = () => { fetchHosts(); + fetchFolderMetadata(); }; const handleCredentialsChanged = () => { fetchHosts(); @@ -239,7 +265,7 @@ export function LeftSidebar({ handleCredentialsChanged as EventListener, ); }; - }, [fetchHosts]); + }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); @@ -396,13 +422,11 @@ export function LeftSidebar({ className="m-2 flex flex-row font-semibold border-2 !border-dark-border" variant="outline" onClick={openSshManagerTab} - disabled={!!sshManagerTab || isSplitScreenActive} + disabled={isSplitScreenActive} title={ - sshManagerTab - ? t("interface.sshManagerAlreadyOpen") - : isSplitScreenActive - ? t("interface.disabledDuringSplitScreen") - : undefined + isSplitScreenActive + ? t("interface.disabledDuringSplitScreen") + : undefined } > @@ -437,15 +461,20 @@ export function LeftSidebar({
)} - {sortedFolders.map((folder, idx) => ( - - ))} + {sortedFolders.map((folder, idx) => { + const metadata = folderMetadata.get(folder); + return ( + + ); + })} diff --git a/src/ui/desktop/navigation/hosts/FolderCard.tsx b/src/ui/desktop/navigation/hosts/FolderCard.tsx index 82b0dc38..8d957a8b 100644 --- a/src/ui/desktop/navigation/hosts/FolderCard.tsx +++ b/src/ui/desktop/navigation/hosts/FolderCard.tsx @@ -1,6 +1,18 @@ import React, { useState } from "react"; import { CardTitle } from "@/components/ui/card.tsx"; -import { ChevronDown, Folder } from "lucide-react"; +import { + ChevronDown, + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, +} from "lucide-react"; import { Button } from "@/components/ui/button.tsx"; import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx"; import { Separator } from "@/components/ui/separator.tsx"; @@ -40,11 +52,15 @@ interface FolderCardProps { hosts: SSHHost[]; isFirst: boolean; isLast: boolean; + folderColor?: string; + folderIcon?: string; } export function FolderCard({ folderName, hosts, + folderColor, + folderIcon, }: FolderCardProps): React.ReactElement { const [isExpanded, setIsExpanded] = useState(true); @@ -52,6 +68,30 @@ export function FolderCard({ setIsExpanded(!isExpanded); }; + const iconMap: Record< + string, + React.ComponentType<{ + size?: number; + strokeWidth?: number; + className?: string; + style?: React.CSSProperties; + }> + > = { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + }; + + const FolderIcon = + folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder; + return (
- +
diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 03a21bbb..ba35e918 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -27,6 +27,8 @@ interface TabProps { disableClose?: boolean; isDragging?: boolean; isDragOver?: boolean; + isValidDropTarget?: boolean; + isHoveredDropTarget?: boolean; } export function Tab({ @@ -44,6 +46,8 @@ export function Tab({ disableClose = false, isDragging = false, isDragOver = false, + isValidDropTarget = false, + isHoveredDropTarget = false, }: TabProps): React.ReactElement { const { t } = useTranslation(); @@ -54,12 +58,21 @@ export function Tab({ isDragOver && "bg-background/40 text-muted-foreground border-border opacity-60", isDragging && "opacity-70", + isHoveredDropTarget && + "bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50", + !isHoveredDropTarget && + isValidDropTarget && + "border-blue-400/50 bg-background/90", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && isActive && "bg-background text-foreground border-border z-10", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && !isActive && "bg-background/80 text-muted-foreground border-border hover:bg-background/90", ); diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 79bdaf74..4169de4b 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -10,6 +10,7 @@ import { TabsTrigger, } from "@/components/ui/tabs.tsx"; import { Separator } from "@/components/ui/separator.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; import { User, Shield, AlertCircle } from "lucide-react"; import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx"; import { @@ -93,6 +94,9 @@ export function UserProfile({ const [deletePassword, setDeletePassword] = useState(""); const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [fileColorCoding, setFileColorCoding] = useState( + localStorage.getItem("fileColorCoding") !== "false", + ); useEffect(() => { fetchUserInfo(); @@ -134,6 +138,13 @@ export function UserProfile({ } }; + const handleFileColorCodingToggle = (enabled: boolean) => { + setFileColorCoding(enabled); + localStorage.setItem("fileColorCoding", enabled.toString()); + // Trigger a re-render by dispatching a custom event + window.dispatchEvent(new Event("fileColorCodingChanged")); + }; + const handleDeleteAccount = async (e: React.FormEvent) => { e.preventDefault(); setDeleteLoading(true); @@ -331,6 +342,23 @@ export function UserProfile({
+
+
+
+ +

+ {t("profile.fileColorCodingDesc")} +

+
+ +
+
+
diff --git a/src/ui/hooks/useCommandHistory.ts b/src/ui/hooks/useCommandHistory.ts new file mode 100644 index 00000000..ef877759 --- /dev/null +++ b/src/ui/hooks/useCommandHistory.ts @@ -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; + 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([]); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const historyCache = useRef>(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, + }; +} diff --git a/src/ui/hooks/useCommandTracker.ts b/src/ui/hooks/useCommandTracker.ts new file mode 100644 index 00000000..e3d6d88e --- /dev/null +++ b/src/ui/hooks/useCommandTracker.ts @@ -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(""); + const isInEscapeSequenceRef = useRef(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, + }; +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index a524ed14..5be22077 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios"; import type { SSHHost, SSHHostData, + SSHFolder, TunnelConfig, TunnelStatus, FileManagerFile, @@ -1520,6 +1521,145 @@ export async function moveSSHItem( } } +export async function changeSSHPermissions( + sessionId: string, + path: string, + permissions: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string }> { + try { + fileLogger.info("Changing SSH file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/changePermissions", { + sessionId, + path, + permissions, + hostId, + userId, + }); + + fileLogger.success("SSH file permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to change SSH file permissions", error, { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + handleApiError(error, "change SSH permissions"); + throw error; + } +} + +export async function extractSSHArchive( + sessionId: string, + archivePath: string, + extractPath?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; extractPath: string }> { + try { + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/extractArchive", { + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: response.data.extractPath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to extract archive", error, { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + }); + handleApiError(error, "extract archive"); + throw error; + } +} + +export async function compressSSHFiles( + sessionId: string, + paths: string[], + archiveName: string, + format?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; archivePath: string }> { + try { + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/compressFiles", { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }); + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath: response.data.archivePath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to compress files", error, { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + }); + handleApiError(error, "compress files"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ @@ -2411,6 +2551,92 @@ export async function renameFolder( } } +export async function getSSHFolders(): Promise { + 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 { + try { + sshLogger.info("Updating folder metadata", { + operation: "update_folder_metadata", + name, + color, + icon, + }); + + await authApi.put("/ssh/folders/metadata", { + name, + color, + icon, + }); + + sshLogger.success("Folder metadata updated successfully", { + operation: "update_folder_metadata", + name, + }); + } catch (error) { + sshLogger.error("Failed to update folder metadata", error, { + operation: "update_folder_metadata", + name, + }); + handleApiError(error, "update folder metadata"); + throw error; + } +} + +export async function deleteAllHostsInFolder( + folderName: string, +): Promise<{ deletedCount: number }> { + try { + sshLogger.info("Deleting all hosts in folder", { + operation: "delete_folder_hosts", + folderName, + }); + + const response = await authApi.delete( + `/ssh/folders/${encodeURIComponent(folderName)}/hosts`, + ); + + sshLogger.success("All hosts in folder deleted successfully", { + operation: "delete_folder_hosts", + folderName, + deletedCount: response.data.deletedCount, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to delete hosts in folder", error, { + operation: "delete_folder_hosts", + folderName, + }); + handleApiError(error, "delete hosts in folder"); + throw error; + } +} + export async function renameCredentialFolder( oldName: string, newName: string, @@ -2631,3 +2857,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> { throw handleApiError(error, "reset recent activity"); } } + +// ============================================================================ +// COMMAND HISTORY API +// ============================================================================ + +/** + * Save a command to history for a specific host + */ +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise<{ id: number; command: string; executedAt: string }> { + try { + const response = await authApi.post("/terminal/command_history", { + hostId, + command, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "save command to history"); + } +} + +/** + * Get command history for a specific host + * Returns array of unique commands ordered by most recent + */ +export async function getCommandHistory(hostId: number): Promise { + 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"); + } +}