v1.9.0 #437
@@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js";
|
|||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes);
|
|||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
|
app.use("/terminal", terminalRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -311,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS command_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
|
);
|
||||||
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -212,6 +212,22 @@ export const snippets = sqliteTable("snippets", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sshFolders = sqliteTable("ssh_folders", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
export const recentActivity = sqliteTable("recent_activity", {
|
export const recentActivity = sqliteTable("recent_activity", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
@@ -226,3 +242,17 @@ export const recentActivity = sqliteTable("recent_activity", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const commandHistory = sqliteTable("command_history", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
hostId: integer("host_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshData.id),
|
||||||
|
command: text("command").notNull(),
|
||||||
|
executedAt: text("executed_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
fileManagerShortcuts,
|
fileManagerShortcuts,
|
||||||
recentActivity,
|
sshFolders,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -226,7 +226,6 @@ router.post(
|
|||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
overrideCredentialUsername,
|
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
@@ -266,7 +265,6 @@ router.post(
|
|||||||
username,
|
username,
|
||||||
authType: effectiveAuthType,
|
authType: effectiveAuthType,
|
||||||
credentialId: credentialId || null,
|
credentialId: credentialId || null,
|
||||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
||||||
pin: pin ? 1 : 0,
|
pin: pin ? 1 : 0,
|
||||||
enableTerminal: enableTerminal ? 1 : 0,
|
enableTerminal: enableTerminal ? 1 : 0,
|
||||||
enableTunnel: enableTunnel ? 1 : 0,
|
enableTunnel: enableTunnel ? 1 : 0,
|
||||||
@@ -326,7 +324,6 @@ router.post(
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!createdHost.pin,
|
pin: !!createdHost.pin,
|
||||||
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
|
|
||||||
enableTerminal: !!createdHost.enableTerminal,
|
enableTerminal: !!createdHost.enableTerminal,
|
||||||
enableTunnel: !!createdHost.enableTunnel,
|
enableTunnel: !!createdHost.enableTunnel,
|
||||||
tunnelConnections: createdHost.tunnelConnections
|
tunnelConnections: createdHost.tunnelConnections
|
||||||
@@ -353,27 +350,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
const fetch = (await import("node-fetch")).default;
|
|
||||||
const token =
|
|
||||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
|
||||||
await fetch("http://localhost:30005/refresh", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token && { Cookie: `jwt=${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (refreshError) {
|
|
||||||
sshLogger.warn("Failed to refresh server stats polling", {
|
|
||||||
operation: "stats_refresh_after_create",
|
|
||||||
error:
|
|
||||||
refreshError instanceof Error
|
|
||||||
? refreshError.message
|
|
||||||
: "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(resolvedHost);
|
res.json(resolvedHost);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to save SSH host to database", err, {
|
sshLogger.error("Failed to save SSH host to database", err, {
|
||||||
@@ -440,7 +416,6 @@ router.put(
|
|||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
overrideCredentialUsername,
|
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
@@ -481,7 +456,6 @@ router.put(
|
|||||||
username,
|
username,
|
||||||
authType: effectiveAuthType,
|
authType: effectiveAuthType,
|
||||||
credentialId: credentialId || null,
|
credentialId: credentialId || null,
|
||||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
||||||
pin: pin ? 1 : 0,
|
pin: pin ? 1 : 0,
|
||||||
enableTerminal: enableTerminal ? 1 : 0,
|
enableTerminal: enableTerminal ? 1 : 0,
|
||||||
enableTunnel: enableTunnel ? 1 : 0,
|
enableTunnel: enableTunnel ? 1 : 0,
|
||||||
@@ -559,7 +533,6 @@ router.put(
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!updatedHost.pin,
|
pin: !!updatedHost.pin,
|
||||||
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
|
|
||||||
enableTerminal: !!updatedHost.enableTerminal,
|
enableTerminal: !!updatedHost.enableTerminal,
|
||||||
enableTunnel: !!updatedHost.enableTunnel,
|
enableTunnel: !!updatedHost.enableTunnel,
|
||||||
tunnelConnections: updatedHost.tunnelConnections
|
tunnelConnections: updatedHost.tunnelConnections
|
||||||
@@ -586,27 +559,6 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
const fetch = (await import("node-fetch")).default;
|
|
||||||
const token =
|
|
||||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
|
||||||
await fetch("http://localhost:30005/refresh", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token && { Cookie: `jwt=${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (refreshError) {
|
|
||||||
sshLogger.warn("Failed to refresh server stats polling", {
|
|
||||||
operation: "stats_refresh_after_update",
|
|
||||||
error:
|
|
||||||
refreshError instanceof Error
|
|
||||||
? refreshError.message
|
|
||||||
: "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(resolvedHost);
|
res.json(resolvedHost);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to update SSH host in database", err, {
|
sshLogger.error("Failed to update SSH host in database", err, {
|
||||||
@@ -634,18 +586,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
return res.status(400).json({ error: "Invalid userId" });
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
|
||||||
sshLogger.warn("User data not unlocked for SSH host fetch", {
|
|
||||||
operation: "host_fetch",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return res.status(401).json({
|
|
||||||
error: "Session expired - please log in again",
|
|
||||||
code: "SESSION_EXPIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await SimpleDBOps.select(
|
const data = await SimpleDBOps.select(
|
||||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||||
@@ -664,7 +604,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!row.pin,
|
pin: !!row.pin,
|
||||||
overrideCredentialUsername: !!row.overrideCredentialUsername,
|
|
||||||
enableTerminal: !!row.enableTerminal,
|
enableTerminal: !!row.enableTerminal,
|
||||||
enableTunnel: !!row.enableTunnel,
|
enableTunnel: !!row.enableTunnel,
|
||||||
tunnelConnections: row.tunnelConnections
|
tunnelConnections: row.tunnelConnections
|
||||||
@@ -711,19 +650,6 @@ router.get(
|
|||||||
});
|
});
|
||||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
|
||||||
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
|
|
||||||
operation: "host_fetch_by_id",
|
|
||||||
hostId: parseInt(hostId),
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return res.status(401).json({
|
|
||||||
error: "Session expired - please log in again",
|
|
||||||
code: "SESSION_EXPIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await db
|
const data = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -749,7 +675,6 @@ router.get(
|
|||||||
: []
|
: []
|
||||||
: [],
|
: [],
|
||||||
pin: !!host.pin,
|
pin: !!host.pin,
|
||||||
overrideCredentialUsername: !!host.overrideCredentialUsername,
|
|
||||||
enableTerminal: !!host.enableTerminal,
|
enableTerminal: !!host.enableTerminal,
|
||||||
enableTunnel: !!host.enableTunnel,
|
enableTunnel: !!host.enableTunnel,
|
||||||
tunnelConnections: host.tunnelConnections
|
tunnelConnections: host.tunnelConnections
|
||||||
@@ -924,15 +849,6 @@ router.delete(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recentActivity.userId, userId),
|
|
||||||
eq(recentActivity.hostId, numericHostId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(sshData)
|
.delete(sshData)
|
||||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
||||||
@@ -1352,9 +1268,7 @@ async function resolveHostCredentials(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
return {
|
return {
|
||||||
...host,
|
...host,
|
||||||
username: host.overrideCredentialUsername
|
username: credential.username,
|
||||||
? host.username
|
|
||||||
: credential.username,
|
|
||||||
authType: credential.auth_type || credential.authType,
|
authType: credential.auth_type || credential.authType,
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.key,
|
||||||
@@ -1428,6 +1342,17 @@ router.put(
|
|||||||
|
|
||||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||||
|
|
||||||
|
// Also update folder metadata if exists
|
||||||
|
await db
|
||||||
|
.update(sshFolders)
|
||||||
|
.set({
|
||||||
|
name: newName,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
updatedHosts: updatedHosts.length,
|
updatedHosts: updatedHosts.length,
|
||||||
@@ -1445,6 +1370,151 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Route: Get all folders with metadata (requires JWT)
|
||||||
|
// GET /ssh/db/folders
|
||||||
|
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid user ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = await db
|
||||||
|
.select()
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(eq(sshFolders.userId, userId));
|
||||||
|
|
||||||
|
res.json(folders);
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to fetch folders", err, {
|
||||||
|
operation: "fetch_folders",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to fetch folders" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Update folder metadata (requires JWT)
|
||||||
|
// PUT /ssh/db/folders/metadata
|
||||||
|
router.put(
|
||||||
|
"/folders/metadata",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { name, color, icon } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !name) {
|
||||||
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if folder metadata exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing
|
||||||
|
await db
|
||||||
|
.update(sshFolders)
|
||||||
|
.set({
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
await db.insert(sshFolders).values({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
|
||||||
|
|
||||||
|
res.json({ message: "Folder metadata updated successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to update folder metadata", err, {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to update folder metadata" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route: Delete all hosts in folder (requires JWT)
|
||||||
|
// DELETE /ssh/db/folders/:name/hosts
|
||||||
|
router.delete(
|
||||||
|
"/folders/:name/hosts",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const folderName = req.params.name;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !folderName) {
|
||||||
|
return res.status(400).json({ error: "Invalid folder name" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all hosts in the folder
|
||||||
|
const hostsToDelete = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||||
|
|
||||||
|
if (hostsToDelete.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
message: "No hosts found in folder",
|
||||||
|
deletedCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all hosts
|
||||||
|
await db
|
||||||
|
.delete(sshData)
|
||||||
|
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||||
|
|
||||||
|
// Delete folder metadata
|
||||||
|
await db
|
||||||
|
.delete(sshFolders)
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||||
|
);
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||||
|
|
||||||
|
sshLogger.info("Deleted all hosts in folder", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
deletedCount: hostsToDelete.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "All hosts in folder deleted successfully",
|
||||||
|
deletedCount: hostsToDelete.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to delete hosts in folder", err, {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Route: Bulk import SSH hosts (requires JWT)
|
// Route: Bulk import SSH hosts (requires JWT)
|
||||||
// POST /ssh/bulk-import
|
// POST /ssh/bulk-import
|
||||||
router.post(
|
router.post(
|
||||||
@@ -1533,10 +1603,8 @@ router.post(
|
|||||||
username: hostData.username,
|
username: hostData.username,
|
||||||
password: hostData.authType === "password" ? hostData.password : null,
|
password: hostData.authType === "password" ? hostData.password : null,
|
||||||
authType: hostData.authType,
|
authType: hostData.authType,
|
||||||
credentialId: hostData.credentialId || null,
|
credentialId:
|
||||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||||
? 1
|
|
||||||
: 0,
|
|
||||||
key: hostData.authType === "key" ? hostData.key : null,
|
key: hostData.authType === "key" ? hostData.key : null,
|
||||||
keyPassword:
|
keyPassword:
|
||||||
hostData.authType === "key"
|
hostData.authType === "key"
|
||||||
|
|||||||
214
src/backend/database/routes/terminal.ts
Normal file
214
src/backend/database/routes/terminal.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import express from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { commandHistory } from "../db/schema.js";
|
||||||
|
import { eq, and, desc, sql } from "drizzle-orm";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
|
// Save command to history
|
||||||
|
// POST /terminal/command_history
|
||||||
|
router.post(
|
||||||
|
"/command_history",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { hostId, command } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||||
|
authLogger.warn("Invalid command history save request", {
|
||||||
|
operation: "command_history_save",
|
||||||
|
userId,
|
||||||
|
hasHostId: !!hostId,
|
||||||
|
hasCommand: !!command,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Missing required parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const insertData = {
|
||||||
|
userId,
|
||||||
|
hostId: parseInt(hostId, 10),
|
||||||
|
command: command.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.insert(commandHistory)
|
||||||
|
.values(insertData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
authLogger.info(`Command saved to history for host ${hostId}`, {
|
||||||
|
operation: "command_history_save_success",
|
||||||
|
userId,
|
||||||
|
hostId: parseInt(hostId, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(result[0]);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to save command to history", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to save command",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get command history for a specific host
|
||||||
|
// GET /terminal/command_history/:hostId
|
||||||
|
router.get(
|
||||||
|
"/command_history/:hostId",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { hostId } = req.params;
|
||||||
|
const hostIdNum = parseInt(hostId, 10);
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||||
|
authLogger.warn("Invalid command history fetch request", {
|
||||||
|
userId,
|
||||||
|
hostId: hostIdNum,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Invalid request parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get unique commands for this host, ordered by most recent
|
||||||
|
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
|
||||||
|
const result = await db
|
||||||
|
.selectDistinct({ command: commandHistory.command })
|
||||||
|
.from(commandHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(commandHistory.userId, userId),
|
||||||
|
eq(commandHistory.hostId, hostIdNum),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(commandHistory.executedAt))
|
||||||
|
.limit(500); // Limit to last 500 unique commands
|
||||||
|
|
||||||
|
// Further deduplicate in case DISTINCT didn't work perfectly
|
||||||
|
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
|
||||||
|
|
||||||
|
authLogger.info(`Fetched command history for host ${hostId}`, {
|
||||||
|
operation: "command_history_fetch_success",
|
||||||
|
userId,
|
||||||
|
hostId: hostIdNum,
|
||||||
|
count: uniqueCommands.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(uniqueCommands);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to fetch command history", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to fetch history",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a specific command from history
|
||||||
|
// POST /terminal/command_history/delete
|
||||||
|
router.post(
|
||||||
|
"/command_history/delete",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { hostId, command } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||||
|
authLogger.warn("Invalid command delete request", {
|
||||||
|
operation: "command_history_delete",
|
||||||
|
userId,
|
||||||
|
hasHostId: !!hostId,
|
||||||
|
hasCommand: !!command,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Missing required parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hostIdNum = parseInt(hostId, 10);
|
||||||
|
|
||||||
|
// Delete all instances of this command for this user and host
|
||||||
|
await db
|
||||||
|
.delete(commandHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(commandHistory.userId, userId),
|
||||||
|
eq(commandHistory.hostId, hostIdNum),
|
||||||
|
eq(commandHistory.command, command.trim()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
authLogger.info(`Command deleted from history for host ${hostId}`, {
|
||||||
|
operation: "command_history_delete_success",
|
||||||
|
userId,
|
||||||
|
hostId: hostIdNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to delete command from history", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to delete command",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear command history for a specific host (optional feature)
|
||||||
|
// DELETE /terminal/command_history/:hostId
|
||||||
|
router.delete(
|
||||||
|
"/command_history/:hostId",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { hostId } = req.params;
|
||||||
|
const hostIdNum = parseInt(hostId, 10);
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||||
|
authLogger.warn("Invalid command history clear request");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(commandHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(commandHistory.userId, userId),
|
||||||
|
eq(commandHistory.hostId, hostIdNum),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
authLogger.success(`Command history cleared for host ${hostId}`, {
|
||||||
|
operation: "command_history_clear_success",
|
||||||
|
userId,
|
||||||
|
hostId: hostIdNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to clear command history", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to clear history",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2490,6 +2490,474 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||||
|
const { sessionId, path, permissions } = req.body;
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|
||||||
|
if (!sshConn || !sshConn.isConnected) {
|
||||||
|
fileLogger.error(
|
||||||
|
"SSH connection not found or not connected for changePermissions",
|
||||||
|
{
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
hasConnection: !!sshConn,
|
||||||
|
isConnected: sshConn?.isConnected,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.status(400).json({ error: "SSH connection not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return res.status(400).json({ error: "File path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Valid permissions required (e.g., 755, 644)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const octalPerms = permissions.slice(-3);
|
||||||
|
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||||
|
const command = `chmod ${octalPerms} '${escapedPath}'`;
|
||||||
|
|
||||||
|
fileLogger.info("Changing file permissions", {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions: octalPerms,
|
||||||
|
});
|
||||||
|
|
||||||
|
sshConn.client.exec(command, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions: octalPerms,
|
||||||
|
});
|
||||||
|
return res.status(500).json({ error: "Failed to change permissions" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorOutput = "";
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
fileLogger.error("chmod command failed", {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions: octalPerms,
|
||||||
|
exitCode: code,
|
||||||
|
error: errorOutput,
|
||||||
|
});
|
||||||
|
return res.status(500).json({
|
||||||
|
error: errorOutput || "Failed to change permissions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.success("File permissions changed successfully", {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions: octalPerms,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Permissions changed successfully",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions: octalPerms,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Stream error while changing permissions" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Extract archive file (requires JWT)
|
||||||
|
// POST /ssh/file_manager/ssh/extractArchive
|
||||||
|
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||||
|
const { sessionId, archivePath, extractPath } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !archivePath) {
|
||||||
|
return res.status(400).json({ error: "Missing required parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sshSessions[sessionId];
|
||||||
|
if (!session || !session.isConnected) {
|
||||||
|
return res.status(400).json({ error: "SSH session not connected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastActive = Date.now();
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
const fileName = archivePath.split("/").pop() || "";
|
||||||
|
const fileExt = fileName.toLowerCase();
|
||||||
|
|
||||||
|
// Determine extraction command based on file extension
|
||||||
|
let extractCommand = "";
|
||||||
|
const targetPath =
|
||||||
|
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
||||||
|
|
||||||
|
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
|
||||||
|
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
|
||||||
|
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".tar.xz")) {
|
||||||
|
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".tar")) {
|
||||||
|
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".zip")) {
|
||||||
|
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
|
||||||
|
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
|
||||||
|
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
|
||||||
|
extractCommand = `bunzip2 -k "${archivePath}"`;
|
||||||
|
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
|
||||||
|
extractCommand = `unxz -k "${archivePath}"`;
|
||||||
|
} else if (fileExt.endsWith(".7z")) {
|
||||||
|
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
|
||||||
|
} else if (fileExt.endsWith(".rar")) {
|
||||||
|
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: "Unsupported archive format" });
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.info("Extracting archive", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath: targetPath,
|
||||||
|
command: extractCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
session.client.exec(extractCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH exec error during extract:", err, {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to execute extract command" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorOutput = "";
|
||||||
|
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
fileLogger.debug("Extract stdout", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
output: data.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
fileLogger.debug("Extract stderr", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
error: data.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
fileLogger.error("Extract command failed", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
exitCode: code,
|
||||||
|
error: errorOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if command not found
|
||||||
|
let friendlyError = errorOutput || "Failed to extract archive";
|
||||||
|
if (
|
||||||
|
errorOutput.includes("command not found") ||
|
||||||
|
errorOutput.includes("not found")
|
||||||
|
) {
|
||||||
|
// Detect which command is missing based on file extension
|
||||||
|
let missingCmd = "";
|
||||||
|
let installHint = "";
|
||||||
|
|
||||||
|
if (fileExt.endsWith(".zip")) {
|
||||||
|
missingCmd = "unzip";
|
||||||
|
installHint =
|
||||||
|
"apt install unzip / yum install unzip / brew install unzip";
|
||||||
|
} else if (
|
||||||
|
fileExt.endsWith(".tar.gz") ||
|
||||||
|
fileExt.endsWith(".tgz") ||
|
||||||
|
fileExt.endsWith(".tar.bz2") ||
|
||||||
|
fileExt.endsWith(".tbz2") ||
|
||||||
|
fileExt.endsWith(".tar.xz") ||
|
||||||
|
fileExt.endsWith(".tar")
|
||||||
|
) {
|
||||||
|
missingCmd = "tar";
|
||||||
|
installHint = "Usually pre-installed on Linux/Unix systems";
|
||||||
|
} else if (fileExt.endsWith(".gz")) {
|
||||||
|
missingCmd = "gunzip";
|
||||||
|
installHint =
|
||||||
|
"apt install gzip / yum install gzip / Usually pre-installed";
|
||||||
|
} else if (fileExt.endsWith(".bz2")) {
|
||||||
|
missingCmd = "bunzip2";
|
||||||
|
installHint =
|
||||||
|
"apt install bzip2 / yum install bzip2 / brew install bzip2";
|
||||||
|
} else if (fileExt.endsWith(".xz")) {
|
||||||
|
missingCmd = "unxz";
|
||||||
|
installHint =
|
||||||
|
"apt install xz-utils / yum install xz / brew install xz";
|
||||||
|
} else if (fileExt.endsWith(".7z")) {
|
||||||
|
missingCmd = "7z";
|
||||||
|
installHint =
|
||||||
|
"apt install p7zip-full / yum install p7zip / brew install p7zip";
|
||||||
|
} else if (fileExt.endsWith(".rar")) {
|
||||||
|
missingCmd = "unrar";
|
||||||
|
installHint =
|
||||||
|
"apt install unrar / yum install unrar / brew install unrar";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingCmd) {
|
||||||
|
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ error: friendlyError });
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.success("Archive extracted successfully", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath: targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Archive extracted successfully",
|
||||||
|
extractPath: targetPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH extractArchive stream error:", streamErr, {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Stream error while extracting archive" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Compress files/folders (requires JWT)
|
||||||
|
// POST /ssh/file_manager/ssh/compressFiles
|
||||||
|
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||||
|
const { sessionId, paths, archiveName, format } = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sessionId ||
|
||||||
|
!paths ||
|
||||||
|
!Array.isArray(paths) ||
|
||||||
|
paths.length === 0 ||
|
||||||
|
!archiveName
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Missing required parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sshSessions[sessionId];
|
||||||
|
if (!session || !session.isConnected) {
|
||||||
|
return res.status(400).json({ error: "SSH session not connected" });
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastActive = Date.now();
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
// Determine compression format
|
||||||
|
const compressionFormat = format || "zip"; // Default to zip
|
||||||
|
let compressCommand = "";
|
||||||
|
|
||||||
|
// Get the directory where the first file is located
|
||||||
|
const firstPath = paths[0];
|
||||||
|
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
||||||
|
|
||||||
|
// Extract just the file/folder names for the command
|
||||||
|
const fileNames = paths
|
||||||
|
.map((p) => {
|
||||||
|
const name = p.split("/").pop();
|
||||||
|
return `"${name}"`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// Construct archive path
|
||||||
|
let archivePath = "";
|
||||||
|
if (archiveName.includes("/")) {
|
||||||
|
archivePath = archiveName;
|
||||||
|
} else {
|
||||||
|
archivePath = workingDir.endsWith("/")
|
||||||
|
? `${workingDir}${archiveName}`
|
||||||
|
: `${workingDir}/${archiveName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressionFormat === "zip") {
|
||||||
|
// Use zip command - need to cd to directory first
|
||||||
|
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
|
||||||
|
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
|
||||||
|
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
|
||||||
|
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
|
||||||
|
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
|
||||||
|
} else if (compressionFormat === "tar.xz") {
|
||||||
|
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
|
||||||
|
} else if (compressionFormat === "tar") {
|
||||||
|
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
|
||||||
|
} else if (compressionFormat === "7z") {
|
||||||
|
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: "Unsupported compression format" });
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.info("Compressing files", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archivePath,
|
||||||
|
format: compressionFormat,
|
||||||
|
command: compressCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
session.client.exec(compressCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("SSH exec error during compress:", err, {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to execute compress command" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorOutput = "";
|
||||||
|
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
fileLogger.debug("Compress stdout", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
output: data.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
fileLogger.debug("Compress stderr", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
error: data.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
fileLogger.error("Compress command failed", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archivePath,
|
||||||
|
exitCode: code,
|
||||||
|
error: errorOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if command not found
|
||||||
|
let friendlyError = errorOutput || "Failed to compress files";
|
||||||
|
if (
|
||||||
|
errorOutput.includes("command not found") ||
|
||||||
|
errorOutput.includes("not found")
|
||||||
|
) {
|
||||||
|
const commandMap: Record<string, { cmd: string; install: string }> = {
|
||||||
|
zip: {
|
||||||
|
cmd: "zip",
|
||||||
|
install: "apt install zip / yum install zip / brew install zip",
|
||||||
|
},
|
||||||
|
"tar.gz": {
|
||||||
|
cmd: "tar",
|
||||||
|
install: "Usually pre-installed on Linux/Unix systems",
|
||||||
|
},
|
||||||
|
"tar.bz2": {
|
||||||
|
cmd: "tar",
|
||||||
|
install: "Usually pre-installed on Linux/Unix systems",
|
||||||
|
},
|
||||||
|
"tar.xz": {
|
||||||
|
cmd: "tar",
|
||||||
|
install: "Usually pre-installed on Linux/Unix systems",
|
||||||
|
},
|
||||||
|
tar: {
|
||||||
|
cmd: "tar",
|
||||||
|
install: "Usually pre-installed on Linux/Unix systems",
|
||||||
|
},
|
||||||
|
"7z": {
|
||||||
|
cmd: "7z",
|
||||||
|
install:
|
||||||
|
"apt install p7zip-full / yum install p7zip / brew install p7zip",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = commandMap[compressionFormat];
|
||||||
|
if (info) {
|
||||||
|
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ error: friendlyError });
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLogger.success("Files compressed successfully", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archivePath,
|
||||||
|
format: compressionFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Files compressed successfully",
|
||||||
|
archivePath: archivePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (streamErr) => {
|
||||||
|
fileLogger.error("SSH compressFiles stream error:", streamErr, {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Stream error while compressing files" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
Object.keys(sshSessions).forEach(cleanupSession);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ class SystemCrypto {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
} catch (fileError) {
|
} catch (fileError) {
|
||||||
|
// OK: .env file not found or unreadable, will generate new database key
|
||||||
databaseLogger.debug(
|
databaseLogger.debug(
|
||||||
".env file not accessible, will generate new database key",
|
".env file not accessible, will generate new database key",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -755,6 +755,17 @@
|
|||||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
"failedToRenameFolder": "Failed to rename folder",
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"editFolderAppearance": "Edit Folder Appearance",
|
||||||
|
"editFolderAppearanceDesc": "Customize the color and icon for folder",
|
||||||
|
"folderColor": "Folder Color",
|
||||||
|
"folderIcon": "Folder Icon",
|
||||||
|
"preview": "Preview",
|
||||||
|
"folderAppearanceUpdated": "Folder appearance updated successfully",
|
||||||
|
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
|
||||||
|
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
|
||||||
|
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
|
||||||
|
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
|
||||||
|
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
|
||||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
"failedToMoveToFolder": "Failed to move host to folder",
|
"failedToMoveToFolder": "Failed to move host to folder",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
@@ -895,6 +906,22 @@
|
|||||||
"connectToSsh": "Connect to SSH to use file operations",
|
"connectToSsh": "Connect to SSH to use file operations",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"downloadFile": "Download",
|
"downloadFile": "Download",
|
||||||
|
"extractArchive": "Extract Archive",
|
||||||
|
"extractingArchive": "Extracting {{name}}...",
|
||||||
|
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
|
||||||
|
"extractFailed": "Extract failed",
|
||||||
|
"compressFile": "Compress File",
|
||||||
|
"compressFiles": "Compress Files",
|
||||||
|
"compressFilesDesc": "Compress {{count}} items into an archive",
|
||||||
|
"archiveName": "Archive Name",
|
||||||
|
"enterArchiveName": "Enter archive name...",
|
||||||
|
"compressionFormat": "Compression Format",
|
||||||
|
"selectedFiles": "Selected files",
|
||||||
|
"andMoreFiles": "and {{count}} more...",
|
||||||
|
"compress": "Compress",
|
||||||
|
"compressingFiles": "Compressing {{count}} items into {{name}}...",
|
||||||
|
"filesCompressedSuccessfully": "{{name}} created successfully",
|
||||||
|
"compressFailed": "Compression failed",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
@@ -1172,7 +1199,19 @@
|
|||||||
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
|
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
|
||||||
"loadFileFailed": "Failed to load file: {{error}}",
|
"loadFileFailed": "Failed to load file: {{error}}",
|
||||||
"connectedSuccessfully": "Connected successfully",
|
"connectedSuccessfully": "Connected successfully",
|
||||||
"totpVerificationFailed": "TOTP verification failed"
|
"totpVerificationFailed": "TOTP verification failed",
|
||||||
|
"changePermissions": "Change Permissions",
|
||||||
|
"changePermissionsDesc": "Modify file permissions for",
|
||||||
|
"currentPermissions": "Current Permissions",
|
||||||
|
"newPermissions": "New Permissions",
|
||||||
|
"owner": "Owner",
|
||||||
|
"group": "Group",
|
||||||
|
"others": "Others",
|
||||||
|
"read": "Read",
|
||||||
|
"write": "Write",
|
||||||
|
"execute": "Execute",
|
||||||
|
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
||||||
|
"failedToChangePermissions": "Failed to change permissions"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH Tunnels",
|
"title": "SSH Tunnels",
|
||||||
@@ -1482,6 +1521,8 @@
|
|||||||
"local": "Local",
|
"local": "Local",
|
||||||
"external": "External (OIDC)",
|
"external": "External (OIDC)",
|
||||||
"selectPreferredLanguage": "Select your preferred language for the interface",
|
"selectPreferredLanguage": "Select your preferred language for the interface",
|
||||||
|
"fileColorCoding": "File Color Coding",
|
||||||
|
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
|
||||||
"currentPassword": "Current Password",
|
"currentPassword": "Current Password",
|
||||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||||
|
|||||||
@@ -766,6 +766,17 @@
|
|||||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
"failedToRenameFolder": "重命名文件夹失败",
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"editFolderAppearance": "编辑文件夹外观",
|
||||||
|
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
|
||||||
|
"folderColor": "文件夹颜色",
|
||||||
|
"folderIcon": "文件夹图标",
|
||||||
|
"preview": "预览",
|
||||||
|
"folderAppearanceUpdated": "文件夹外观更新成功",
|
||||||
|
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
|
||||||
|
"deleteAllHostsInFolder": "删除文件夹内所有主机",
|
||||||
|
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
|
||||||
|
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
|
||||||
|
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
|
||||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||||
"statistics": "统计",
|
"statistics": "统计",
|
||||||
@@ -904,6 +915,22 @@
|
|||||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
"downloadFile": "下载",
|
"downloadFile": "下载",
|
||||||
|
"extractArchive": "解压文件",
|
||||||
|
"extractingArchive": "正在解压 {{name}}...",
|
||||||
|
"archiveExtractedSuccessfully": "{{name}} 解压成功",
|
||||||
|
"extractFailed": "解压失败",
|
||||||
|
"compressFile": "压缩文件",
|
||||||
|
"compressFiles": "压缩文件",
|
||||||
|
"compressFilesDesc": "将 {{count}} 个项目压缩为归档文件",
|
||||||
|
"archiveName": "归档文件名",
|
||||||
|
"enterArchiveName": "输入归档文件名...",
|
||||||
|
"compressionFormat": "压缩格式",
|
||||||
|
"selectedFiles": "已选文件",
|
||||||
|
"andMoreFiles": "以及其他 {{count}} 个...",
|
||||||
|
"compress": "压缩",
|
||||||
|
"compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...",
|
||||||
|
"filesCompressedSuccessfully": "{{name}} 创建成功",
|
||||||
|
"compressFailed": "压缩失败",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"previous": "上一页",
|
"previous": "上一页",
|
||||||
@@ -1151,7 +1178,19 @@
|
|||||||
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
|
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
|
||||||
"loadFileFailed": "加载文件失败:{{error}}",
|
"loadFileFailed": "加载文件失败:{{error}}",
|
||||||
"connectedSuccessfully": "连接成功",
|
"connectedSuccessfully": "连接成功",
|
||||||
"totpVerificationFailed": "TOTP 验证失败"
|
"totpVerificationFailed": "TOTP 验证失败",
|
||||||
|
"changePermissions": "修改权限",
|
||||||
|
"changePermissionsDesc": "修改文件权限",
|
||||||
|
"currentPermissions": "当前权限",
|
||||||
|
"newPermissions": "新权限",
|
||||||
|
"owner": "所有者",
|
||||||
|
"group": "组",
|
||||||
|
"others": "其他",
|
||||||
|
"read": "读取",
|
||||||
|
"write": "写入",
|
||||||
|
"execute": "执行",
|
||||||
|
"permissionsChangedSuccessfully": "权限修改成功",
|
||||||
|
"failedToChangePermissions": "权限修改失败"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH 隧道",
|
"title": "SSH 隧道",
|
||||||
@@ -1442,6 +1481,8 @@
|
|||||||
"local": "本地",
|
"local": "本地",
|
||||||
"external": "外部 (OIDC)",
|
"external": "外部 (OIDC)",
|
||||||
"selectPreferredLanguage": "选择您的界面首选语言",
|
"selectPreferredLanguage": "选择您的界面首选语言",
|
||||||
|
"fileColorCoding": "文件颜色编码",
|
||||||
|
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
|
||||||
"currentPassword": "当前密码",
|
"currentPassword": "当前密码",
|
||||||
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
||||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export interface SSHHostData {
|
|||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SSHFolder {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CREDENTIAL TYPES
|
// CREDENTIAL TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
1739
src/ui/components/LoadingOverlay.tsx
Normal file
1739
src/ui/components/LoadingOverlay.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ import { toast } from "sonner";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||||
|
import { PermissionsDialog } from "./components/PermissionsDialog";
|
||||||
|
import { CompressDialog } from "./components/CompressDialog";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -49,6 +51,9 @@ import {
|
|||||||
addFolderShortcut,
|
addFolderShortcut,
|
||||||
getPinnedFiles,
|
getPinnedFiles,
|
||||||
logActivity,
|
logActivity,
|
||||||
|
changeSSHPermissions,
|
||||||
|
extractSSHArchive,
|
||||||
|
compressSSHFiles,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SidebarItem } from "./FileManagerSidebar";
|
import type { SidebarItem } from "./FileManagerSidebar";
|
||||||
|
|
||||||
@@ -146,6 +151,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
|
|
||||||
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
||||||
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
||||||
|
const [permissionsDialogFile, setPermissionsDialogFile] =
|
||||||
|
useState<FileItem | null>(null);
|
||||||
|
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||||
|
|
||||||
@@ -1037,6 +1047,82 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExtractArchive(file: FileItem) {
|
||||||
|
if (!sshSessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
toast.info(t("fileManager.extractingArchive", { name: file.name }));
|
||||||
|
|
||||||
|
await extractSSHArchive(
|
||||||
|
sshSessionId,
|
||||||
|
file.path,
|
||||||
|
undefined,
|
||||||
|
currentHost?.id,
|
||||||
|
currentHost?.userId?.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh directory to show extracted files
|
||||||
|
handleRefreshDirectory();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { message?: string };
|
||||||
|
toast.error(
|
||||||
|
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenCompressDialog(files: FileItem[]) {
|
||||||
|
setCompressDialogFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompress(archiveName: string, format: string) {
|
||||||
|
if (!sshSessionId || compressDialogFiles.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
const paths = compressDialogFiles.map((f) => f.path);
|
||||||
|
const fileNames = compressDialogFiles.map((f) => f.name);
|
||||||
|
|
||||||
|
toast.info(
|
||||||
|
t("fileManager.compressingFiles", {
|
||||||
|
count: fileNames.length,
|
||||||
|
name: archiveName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await compressSSHFiles(
|
||||||
|
sshSessionId,
|
||||||
|
paths,
|
||||||
|
archiveName,
|
||||||
|
format,
|
||||||
|
currentHost?.id,
|
||||||
|
currentHost?.userId?.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("fileManager.filesCompressedSuccessfully", {
|
||||||
|
name: archiveName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh directory to show compressed file
|
||||||
|
handleRefreshDirectory();
|
||||||
|
clearSelection();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { message?: string };
|
||||||
|
toast.error(
|
||||||
|
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUndo() {
|
async function handleUndo() {
|
||||||
if (undoHistory.length === 0) {
|
if (undoHistory.length === 0) {
|
||||||
toast.info(t("fileManager.noUndoableActions"));
|
toast.info(t("fileManager.noUndoableActions"));
|
||||||
@@ -1159,6 +1245,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
setEditingFile(file);
|
setEditingFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenPermissionsDialog(file: FileItem) {
|
||||||
|
setPermissionsDialogFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePermissions(file: FileItem, permissions: string) {
|
||||||
|
if (!sshSessionId) {
|
||||||
|
toast.error(t("fileManager.noSSHConnection"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeSSHPermissions(
|
||||||
|
sshSessionId,
|
||||||
|
file.path,
|
||||||
|
permissions,
|
||||||
|
currentHost?.id,
|
||||||
|
currentHost?.userId?.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(t("fileManager.permissionsChangedSuccessfully"));
|
||||||
|
await handleRefreshDirectory();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Failed to change permissions:", error);
|
||||||
|
toast.error(t("fileManager.failedToChangePermissions"));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSSHConnection() {
|
async function ensureSSHConnection() {
|
||||||
if (!sshSessionId || !currentHost || isReconnecting) return;
|
if (!sshSessionId || !currentHost || isReconnecting) return;
|
||||||
|
|
||||||
@@ -1947,10 +2061,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
onAddShortcut={handleAddShortcut}
|
onAddShortcut={handleAddShortcut}
|
||||||
isPinned={isPinnedFile}
|
isPinned={isPinnedFile}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
|
onProperties={handleOpenPermissionsDialog}
|
||||||
|
onExtractArchive={handleExtractArchive}
|
||||||
|
onCompress={handleOpenCompressDialog}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompressDialog
|
||||||
|
open={compressDialogFiles.length > 0}
|
||||||
|
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||||
|
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||||
|
onCompress={handleCompress}
|
||||||
|
/>
|
||||||
|
|
||||||
<TOTPDialog
|
<TOTPDialog
|
||||||
isOpen={totpRequired}
|
isOpen={totpRequired}
|
||||||
prompt={totpPrompt}
|
prompt={totpPrompt}
|
||||||
@@ -1972,6 +2096,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PermissionsDialog
|
||||||
|
file={permissionsDialogFile}
|
||||||
|
open={permissionsDialogFile !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPermissionsDialogFile(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSavePermissions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Star,
|
Star,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
FileArchive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||||
@@ -60,6 +61,8 @@ interface ContextMenuProps {
|
|||||||
onAddShortcut?: (path: string) => void;
|
onAddShortcut?: (path: string) => void;
|
||||||
isPinned?: (file: FileItem) => boolean;
|
isPinned?: (file: FileItem) => boolean;
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
onExtractArchive?: (file: FileItem) => void;
|
||||||
|
onCompress?: (files: FileItem[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -99,6 +102,8 @@ export function FileManagerContextMenu({
|
|||||||
onAddShortcut,
|
onAddShortcut,
|
||||||
isPinned,
|
isPinned,
|
||||||
currentPath,
|
currentPath,
|
||||||
|
onExtractArchive,
|
||||||
|
onCompress,
|
||||||
}: ContextMenuProps) {
|
}: ContextMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||||
@@ -254,6 +259,45 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add extract option for archive files
|
||||||
|
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
||||||
|
const fileName = files[0].name.toLowerCase();
|
||||||
|
const isArchive =
|
||||||
|
fileName.endsWith(".zip") ||
|
||||||
|
fileName.endsWith(".tar") ||
|
||||||
|
fileName.endsWith(".tar.gz") ||
|
||||||
|
fileName.endsWith(".tgz") ||
|
||||||
|
fileName.endsWith(".tar.bz2") ||
|
||||||
|
fileName.endsWith(".tbz2") ||
|
||||||
|
fileName.endsWith(".tar.xz") ||
|
||||||
|
fileName.endsWith(".gz") ||
|
||||||
|
fileName.endsWith(".bz2") ||
|
||||||
|
fileName.endsWith(".xz") ||
|
||||||
|
fileName.endsWith(".7z") ||
|
||||||
|
fileName.endsWith(".rar");
|
||||||
|
|
||||||
|
if (isArchive) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <FileArchive className="w-4 h-4" />,
|
||||||
|
label: t("fileManager.extractArchive"),
|
||||||
|
action: () => onExtractArchive(files[0]),
|
||||||
|
shortcut: "Ctrl+E",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add compress option for selected files/folders
|
||||||
|
if (isFileContext && onCompress) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <FileArchive className="w-4 h-4" />,
|
||||||
|
label: isMultipleFiles
|
||||||
|
? t("fileManager.compressFiles")
|
||||||
|
: t("fileManager.compressFile"),
|
||||||
|
action: () => onCompress(files),
|
||||||
|
shortcut: "Ctrl+Shift+C",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isSingleFile && files[0].type === "file") {
|
if (isSingleFile && files[0].type === "file") {
|
||||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||||
|
|
||||||
@@ -451,7 +495,7 @@ export function FileManagerContextMenu({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||||
!isMounted && "opacity-0"
|
!isMounted && "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -460,7 +504,7 @@ export function FileManagerContextMenu({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
|
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
|
||||||
"transition-all duration-150 ease-out origin-top-left",
|
"transition-all duration-150 ease-out origin-top-left",
|
||||||
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95"
|
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: menuPosition.x,
|
left: menuPosition.x,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FileItem } from "../../../types/index.js";
|
import type { FileItem } from "../../../types/index.js";
|
||||||
|
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
|
||||||
|
|
||||||
interface CreateIntent {
|
interface CreateIntent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -96,15 +97,33 @@ interface FileManagerGridProps {
|
|||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
const getFileTypeColor = (file: FileItem): string => {
|
||||||
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
|
||||||
|
if (!colorEnabled) {
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
if (file.type === "directory") {
|
if (file.type === "directory") {
|
||||||
return <Folder className={`${iconClass} text-muted-foreground`} />;
|
return "text-red-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type === "link") {
|
if (file.type === "link") {
|
||||||
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
|
return "text-green-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "text-blue-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||||
|
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
||||||
|
const colorClass = getFileTypeColor(file);
|
||||||
|
|
||||||
|
if (file.type === "directory") {
|
||||||
|
return <Folder className={`${iconClass} ${colorClass}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === "link") {
|
||||||
|
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||||
@@ -113,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
|||||||
case "txt":
|
case "txt":
|
||||||
case "md":
|
case "md":
|
||||||
case "readme":
|
case "readme":
|
||||||
return <FileText className={`${iconClass} text-muted-foreground`} />;
|
return <FileText className={`${iconClass} ${colorClass}`} />;
|
||||||
case "png":
|
case "png":
|
||||||
case "jpg":
|
case "jpg":
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
case "gif":
|
case "gif":
|
||||||
case "bmp":
|
case "bmp":
|
||||||
case "svg":
|
case "svg":
|
||||||
return <FileImage className={`${iconClass} text-muted-foreground`} />;
|
return <FileImage className={`${iconClass} ${colorClass}`} />;
|
||||||
case "mp4":
|
case "mp4":
|
||||||
case "avi":
|
case "avi":
|
||||||
case "mkv":
|
case "mkv":
|
||||||
case "mov":
|
case "mov":
|
||||||
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
|
return <FileVideo className={`${iconClass} ${colorClass}`} />;
|
||||||
case "mp3":
|
case "mp3":
|
||||||
case "wav":
|
case "wav":
|
||||||
case "flac":
|
case "flac":
|
||||||
case "ogg":
|
case "ogg":
|
||||||
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
|
return <FileAudio className={`${iconClass} ${colorClass}`} />;
|
||||||
case "zip":
|
case "zip":
|
||||||
case "tar":
|
case "tar":
|
||||||
case "gz":
|
case "gz":
|
||||||
case "rar":
|
case "rar":
|
||||||
case "7z":
|
case "7z":
|
||||||
return <Archive className={`${iconClass} text-muted-foreground`} />;
|
return <Archive className={`${iconClass} ${colorClass}`} />;
|
||||||
case "js":
|
case "js":
|
||||||
case "ts":
|
case "ts":
|
||||||
case "jsx":
|
case "jsx":
|
||||||
@@ -150,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
|||||||
case "rb":
|
case "rb":
|
||||||
case "go":
|
case "go":
|
||||||
case "rs":
|
case "rs":
|
||||||
return <Code className={`${iconClass} text-muted-foreground`} />;
|
return <Code className={`${iconClass} ${colorClass}`} />;
|
||||||
case "json":
|
case "json":
|
||||||
case "xml":
|
case "xml":
|
||||||
case "yaml":
|
case "yaml":
|
||||||
@@ -159,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
|||||||
case "ini":
|
case "ini":
|
||||||
case "conf":
|
case "conf":
|
||||||
case "config":
|
case "config":
|
||||||
return <Settings className={`${iconClass} text-muted-foreground`} />;
|
return <Settings className={`${iconClass} ${colorClass}`} />;
|
||||||
default:
|
default:
|
||||||
return <File className={`${iconClass} text-muted-foreground`} />;
|
return <File className={`${iconClass} ${colorClass}`} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -853,19 +872,8 @@ export function FileManagerGrid({
|
|||||||
onUndo,
|
onUndo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
|
||||||
<div className="flex-shrink-0 border-b border-dark-border">
|
<div className="flex-shrink-0 border-b border-dark-border">
|
||||||
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||||
<button
|
<button
|
||||||
@@ -1051,7 +1059,8 @@ export function FileManagerGrid({
|
|||||||
"group p-3 rounded-lg cursor-pointer",
|
"group p-3 rounded-lg cursor-pointer",
|
||||||
"transition-all duration-150 ease-out",
|
"transition-all duration-150 ease-out",
|
||||||
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
|
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
|
||||||
isSelected && "bg-primary/20 border-primary ring-2 ring-primary/20",
|
isSelected &&
|
||||||
|
"bg-primary/20 border-primary ring-2 ring-primary/20",
|
||||||
dragState.target?.path === file.path &&
|
dragState.target?.path === file.path &&
|
||||||
"bg-muted border-primary border-dashed relative z-10",
|
"bg-muted border-primary border-dashed relative z-10",
|
||||||
dragState.files.some((f) => f.path === file.path) &&
|
dragState.files.some((f) => f.path === file.path) &&
|
||||||
@@ -1312,6 +1321,13 @@ export function FileManagerGrid({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={isLoading}
|
||||||
|
minDuration={600}
|
||||||
|
message={t("common.loading")}
|
||||||
|
showLogo={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
150
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface CompressDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fileNames: string[];
|
||||||
|
onCompress: (archiveName: string, format: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompressDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fileNames,
|
||||||
|
onCompress,
|
||||||
|
}: CompressDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [archiveName, setArchiveName] = useState("");
|
||||||
|
const [format, setFormat] = useState("zip");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && fileNames.length > 0) {
|
||||||
|
// Generate default archive name
|
||||||
|
if (fileNames.length === 1) {
|
||||||
|
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
|
||||||
|
setArchiveName(baseName);
|
||||||
|
} else {
|
||||||
|
setArchiveName("archive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, fileNames]);
|
||||||
|
|
||||||
|
const handleCompress = () => {
|
||||||
|
if (!archiveName.trim()) return;
|
||||||
|
|
||||||
|
// Append extension if not already present
|
||||||
|
let finalName = archiveName.trim();
|
||||||
|
const extensions: Record<string, string> = {
|
||||||
|
zip: ".zip",
|
||||||
|
"tar.gz": ".tar.gz",
|
||||||
|
"tar.bz2": ".tar.bz2",
|
||||||
|
"tar.xz": ".tar.xz",
|
||||||
|
tar: ".tar",
|
||||||
|
"7z": ".7z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedExtension = extensions[format];
|
||||||
|
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
|
||||||
|
finalName += expectedExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompress(finalName, format);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="archiveName">{t("fileManager.archiveName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="archiveName"
|
||||||
|
value={archiveName}
|
||||||
|
onChange={(e) => setArchiveName(e.target.value)}
|
||||||
|
placeholder={t("fileManager.enterArchiveName")}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCompress();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="format">{t("fileManager.compressionFormat")}</Label>
|
||||||
|
<Select value={format} onValueChange={setFormat}>
|
||||||
|
<SelectTrigger id="format">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="zip">ZIP (.zip)</SelectItem>
|
||||||
|
<SelectItem value="tar.gz">TAR.GZ (.tar.gz)</SelectItem>
|
||||||
|
<SelectItem value="tar.bz2">TAR.BZ2 (.tar.bz2)</SelectItem>
|
||||||
|
<SelectItem value="tar.xz">TAR.XZ (.tar.xz)</SelectItem>
|
||||||
|
<SelectItem value="tar">TAR (.tar)</SelectItem>
|
||||||
|
<SelectItem value="7z">7-Zip (.7z)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{t("fileManager.selectedFiles")}:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{fileNames.slice(0, 5).map((name, index) => (
|
||||||
|
<li key={index} className="truncate">
|
||||||
|
• {name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{fileNames.length > 5 && (
|
||||||
|
<li className="text-muted-foreground italic">
|
||||||
|
{t("fileManager.andMoreFiles", {
|
||||||
|
count: fileNames.length - 5,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCompress} disabled={!archiveName.trim()}>
|
||||||
|
{t("fileManager.compress")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Shield } from "lucide-react";
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory" | "link";
|
||||||
|
path: string;
|
||||||
|
permissions?: string;
|
||||||
|
owner?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsDialogProps {
|
||||||
|
file: FileItem | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (file: FileItem, permissions: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
|
||||||
|
const parsePermissions = (
|
||||||
|
perms: string,
|
||||||
|
): { owner: number; group: number; other: number } => {
|
||||||
|
if (!perms) {
|
||||||
|
return { owner: 0, group: 0, other: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If numeric format like "755"
|
||||||
|
if (/^\d{3,4}$/.test(perms)) {
|
||||||
|
const numStr = perms.slice(-3);
|
||||||
|
return {
|
||||||
|
owner: parseInt(numStr[0] || "0", 10),
|
||||||
|
group: parseInt(numStr[1] || "0", 10),
|
||||||
|
other: parseInt(numStr[2] || "0", 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x"
|
||||||
|
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
|
||||||
|
|
||||||
|
const calcBits = (str: string): number => {
|
||||||
|
let value = 0;
|
||||||
|
if (str[0] === "r") value += 4;
|
||||||
|
if (str[1] === "w") value += 2;
|
||||||
|
if (str[2] === "x") value += 1;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
owner: calcBits(cleanPerms.substring(0, 3)),
|
||||||
|
group: calcBits(cleanPerms.substring(3, 6)),
|
||||||
|
other: calcBits(cleanPerms.substring(6, 9)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert individual bits to numeric format
|
||||||
|
const toNumeric = (owner: number, group: number, other: number): string => {
|
||||||
|
return `${owner}${group}${other}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PermissionsDialog({
|
||||||
|
file,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
}: PermissionsDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const initialPerms = parsePermissions(file?.permissions || "644");
|
||||||
|
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
|
||||||
|
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
|
||||||
|
const [ownerExecute, setOwnerExecute] = useState(
|
||||||
|
(initialPerms.owner & 1) !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
|
||||||
|
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
|
||||||
|
const [groupExecute, setGroupExecute] = useState(
|
||||||
|
(initialPerms.group & 1) !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
|
||||||
|
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
|
||||||
|
const [otherExecute, setOtherExecute] = useState(
|
||||||
|
(initialPerms.other & 1) !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset when file changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (file) {
|
||||||
|
const perms = parsePermissions(file.permissions || "644");
|
||||||
|
setOwnerRead((perms.owner & 4) !== 0);
|
||||||
|
setOwnerWrite((perms.owner & 2) !== 0);
|
||||||
|
setOwnerExecute((perms.owner & 1) !== 0);
|
||||||
|
setGroupRead((perms.group & 4) !== 0);
|
||||||
|
setGroupWrite((perms.group & 2) !== 0);
|
||||||
|
setGroupExecute((perms.group & 1) !== 0);
|
||||||
|
setOtherRead((perms.other & 4) !== 0);
|
||||||
|
setOtherWrite((perms.other & 2) !== 0);
|
||||||
|
setOtherExecute((perms.other & 1) !== 0);
|
||||||
|
}
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const calculateOctal = (): string => {
|
||||||
|
const owner =
|
||||||
|
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
|
||||||
|
const group =
|
||||||
|
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
|
||||||
|
const other =
|
||||||
|
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
|
||||||
|
return toNumeric(owner, group, other);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const permissions = calculateOctal();
|
||||||
|
await onSave(file, permissions);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update permissions:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const octal = calculateOctal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
{t("fileManager.changePermissions")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("fileManager.changePermissionsDesc")}:{" "}
|
||||||
|
<span className="font-mono text-foreground">{file.name}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Current info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-400">
|
||||||
|
{t("fileManager.currentPermissions")}
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-lg mt-1">
|
||||||
|
{file.permissions || "644"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-400">
|
||||||
|
{t("fileManager.newPermissions")}
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-lg mt-1">{octal}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner permissions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-6 ml-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="owner-read"
|
||||||
|
checked={ownerRead}
|
||||||
|
onCheckedChange={(checked) => setOwnerRead(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="owner-read" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.read")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="owner-write"
|
||||||
|
checked={ownerWrite}
|
||||||
|
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="owner-write" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.write")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="owner-execute"
|
||||||
|
checked={ownerExecute}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setOwnerExecute(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="owner-execute"
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("fileManager.execute")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group permissions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("fileManager.group")} {file.group && `(${file.group})`}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-6 ml-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="group-read"
|
||||||
|
checked={groupRead}
|
||||||
|
onCheckedChange={(checked) => setGroupRead(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="group-read" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.read")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="group-write"
|
||||||
|
checked={groupWrite}
|
||||||
|
onCheckedChange={(checked) => setGroupWrite(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="group-write" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.write")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="group-execute"
|
||||||
|
checked={groupExecute}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setGroupExecute(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="group-execute"
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("fileManager.execute")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Others permissions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("fileManager.others")}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-6 ml-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="other-read"
|
||||||
|
checked={otherRead}
|
||||||
|
onCheckedChange={(checked) => setOtherRead(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="other-read" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.read")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="other-write"
|
||||||
|
checked={otherWrite}
|
||||||
|
onCheckedChange={(checked) => setOtherWrite(checked === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="other-write" className="text-sm cursor-pointer">
|
||||||
|
{t("fileManager.write")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="other-execute"
|
||||||
|
checked={otherExecute}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setOtherExecute(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="other-execute"
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("fileManager.execute")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,10 +22,15 @@ import {
|
|||||||
updateSSHHost,
|
updateSSHHost,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
exportSSHHostWithCredentials,
|
exportSSHHostWithCredentials,
|
||||||
|
getSSHFolders,
|
||||||
|
updateFolderMetadata,
|
||||||
|
deleteAllHostsInFolder,
|
||||||
|
getServerStatusById,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
|
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -45,16 +50,31 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
|
Palette,
|
||||||
|
Trash,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
|
SSHFolder,
|
||||||
SSHManagerHostViewerProps,
|
SSHManagerHostViewerProps,
|
||||||
} from "../../../../types/index.js";
|
} from "../../../../types/index.js";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
|
import { FolderEditDialog } from "./components/FolderEditDialog";
|
||||||
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||||
|
|
||||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confirmWithToast } = useConfirmation();
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
const { addTab } = useTabs();
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -65,13 +85,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
const [editingFolderName, setEditingFolderName] = useState("");
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [serverStatuses, setServerStatuses] = useState<
|
||||||
|
Map<number, "online" | "offline" | "degraded">
|
||||||
|
>(new Map());
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
|
|
||||||
const handleHostsRefresh = () => {
|
const handleHostsRefresh = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||||
@@ -116,6 +147,159 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchFolderMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const folders = await getSSHFolders();
|
||||||
|
const metadataMap = new Map<string, SSHFolder>();
|
||||||
|
folders.forEach((folder) => {
|
||||||
|
metadataMap.set(folder.name, folder);
|
||||||
|
});
|
||||||
|
setFolderMetadata(metadataMap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch folder metadata:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFolderAppearance = async (
|
||||||
|
folderName: string,
|
||||||
|
color: string,
|
||||||
|
icon: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await updateFolderMetadata(folderName, color, icon);
|
||||||
|
toast.success(t("hosts.folderAppearanceUpdated"));
|
||||||
|
await fetchFolderMetadata();
|
||||||
|
window.dispatchEvent(new CustomEvent("folders:changed"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update folder appearance:", error);
|
||||||
|
toast.error(t("hosts.failedToUpdateFolderAppearance"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAllHostsInFolder = async (folderName: string) => {
|
||||||
|
const hostsInFolder = hostsByFolder[folderName] || [];
|
||||||
|
confirmWithToast(
|
||||||
|
t("hosts.confirmDeleteAllHostsInFolder", {
|
||||||
|
folder: folderName,
|
||||||
|
count: hostsInFolder.length,
|
||||||
|
}),
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const result = await deleteAllHostsInFolder(folderName);
|
||||||
|
toast.success(
|
||||||
|
t("hosts.allHostsInFolderDeleted", {
|
||||||
|
folder: folderName,
|
||||||
|
count: result.deletedCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await fetchHosts();
|
||||||
|
await fetchFolderMetadata();
|
||||||
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||||
|
|
||||||
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
|
refreshServerPolling();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete hosts in folder:", error);
|
||||||
|
toast.error(t("hosts.failedToDeleteHostsInFolder"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hosts.length === 0) return;
|
||||||
|
|
||||||
|
const statusIntervals: NodeJS.Timeout[] = [];
|
||||||
|
const statusCancelled: boolean[] = [];
|
||||||
|
|
||||||
|
hosts.forEach((host, index) => {
|
||||||
|
const statsConfig = (() => {
|
||||||
|
try {
|
||||||
|
return host.statsConfig
|
||||||
|
? JSON.parse(host.statsConfig)
|
||||||
|
: DEFAULT_STATS_CONFIG;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||||
|
|
||||||
|
if (!shouldShowStatus) {
|
||||||
|
setServerStatuses((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(host.id, "offline");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(host.id);
|
||||||
|
if (!statusCancelled[index]) {
|
||||||
|
setServerStatuses((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(
|
||||||
|
host.id,
|
||||||
|
res?.status === "online" ? "online" : "offline",
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!statusCancelled[index]) {
|
||||||
|
const err = error as { response?: { status?: number } };
|
||||||
|
let status: "online" | "offline" | "degraded" = "offline";
|
||||||
|
if (err?.response?.status === 504) {
|
||||||
|
status = "degraded";
|
||||||
|
}
|
||||||
|
setServerStatuses((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(host.id, status);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
const intervalId = setInterval(fetchStatus, 10000);
|
||||||
|
statusIntervals.push(intervalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
statusCancelled.fill(true);
|
||||||
|
statusIntervals.forEach((interval) => clearInterval(interval));
|
||||||
|
};
|
||||||
|
}, [hosts]);
|
||||||
|
|
||||||
|
const getFolderIcon = (folderName: string) => {
|
||||||
|
const metadata = folderMetadata.get(folderName);
|
||||||
|
if (!metadata?.icon) return Folder;
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ComponentType> = {
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMap[metadata.icon] || Folder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderColor = (folderName: string) => {
|
||||||
|
const metadata = folderMetadata.get(folderName);
|
||||||
|
return metadata?.color;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (hostId: number, hostName: string) => {
|
const handleDelete = async (hostId: number, hostName: string) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("hosts.confirmDelete", { name: hostName }),
|
t("hosts.confirmDelete", { name: hostName }),
|
||||||
@@ -854,7 +1038,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<AccordionItem value={folder} className="border-none">
|
<AccordionItem value={folder} className="border-none">
|
||||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<Folder className="h-4 w-4" />
|
{(() => {
|
||||||
|
const FolderIcon = getFolderIcon(folder);
|
||||||
|
const folderColor = getFolderColor(folder);
|
||||||
|
return (
|
||||||
|
<FolderIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={
|
||||||
|
folderColor ? { color: folderColor } : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{editingFolder === folder ? (
|
{editingFolder === folder ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -935,6 +1130,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{folderHosts.length}
|
{folderHosts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{folder !== t("hosts.uncategorized") && (
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingFolderAppearance(folder);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Palette className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("hosts.editFolderAppearance")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteAllHostsInFolder(folder);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
|
||||||
|
>
|
||||||
|
<Trash className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("hosts.deleteAllHostsInFolder")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="p-2">
|
<AccordionContent className="p-2">
|
||||||
@@ -957,6 +1196,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{(() => {
|
||||||
|
const statsConfig = (() => {
|
||||||
|
try {
|
||||||
|
return host.statsConfig
|
||||||
|
? JSON.parse(host.statsConfig)
|
||||||
|
: DEFAULT_STATS_CONFIG;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const shouldShowStatus =
|
||||||
|
statsConfig.statusCheckEnabled !==
|
||||||
|
false;
|
||||||
|
const serverStatus =
|
||||||
|
serverStatuses.get(host.id) ||
|
||||||
|
"degraded";
|
||||||
|
|
||||||
|
return shouldShowStatus ? (
|
||||||
|
<Status
|
||||||
|
status={serverStatus}
|
||||||
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<StatusIndicator />
|
||||||
|
</Status>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
{host.pin && (
|
{host.pin && (
|
||||||
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -1179,6 +1444,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const title = host.name?.trim()
|
||||||
|
? host.name
|
||||||
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
addTab({
|
||||||
|
type: "terminal",
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
|
||||||
|
>
|
||||||
|
<Terminal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open Terminal</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{host.enableFileManager && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const title = host.name?.trim()
|
||||||
|
? host.name
|
||||||
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
addTab({
|
||||||
|
type: "file_manager",
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open File Manager</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const title = host.name?.trim()
|
||||||
|
? host.name
|
||||||
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
addTab({
|
||||||
|
type: "server",
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
|
||||||
|
>
|
||||||
|
<Server className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open Server Details</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -1202,6 +1549,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{editingFolderAppearance && (
|
||||||
|
<FolderEditDialog
|
||||||
|
folderName={editingFolderAppearance}
|
||||||
|
currentColor={getFolderColor(editingFolderAppearance)}
|
||||||
|
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||||
|
open={editingFolderAppearance !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingFolderAppearance(null);
|
||||||
|
}}
|
||||||
|
onSave={async (color, icon) => {
|
||||||
|
await handleSaveFolderAppearance(
|
||||||
|
editingFolderAppearance,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
);
|
||||||
|
setEditingFolderAppearance(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
194
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
194
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface FolderEditDialogProps {
|
||||||
|
folderName: string;
|
||||||
|
currentColor?: string;
|
||||||
|
currentIcon?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (color: string, icon: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_COLORS = [
|
||||||
|
{ value: "#ef4444", label: "Red" },
|
||||||
|
{ value: "#f97316", label: "Orange" },
|
||||||
|
{ value: "#eab308", label: "Yellow" },
|
||||||
|
{ value: "#22c55e", label: "Green" },
|
||||||
|
{ value: "#3b82f6", label: "Blue" },
|
||||||
|
{ value: "#a855f7", label: "Purple" },
|
||||||
|
{ value: "#ec4899", label: "Pink" },
|
||||||
|
{ value: "#6b7280", label: "Gray" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_ICONS = [
|
||||||
|
{ value: "Folder", label: "Folder", Icon: Folder },
|
||||||
|
{ value: "Server", label: "Server", Icon: Server },
|
||||||
|
{ value: "Cloud", label: "Cloud", Icon: Cloud },
|
||||||
|
{ value: "Database", label: "Database", Icon: Database },
|
||||||
|
{ value: "Box", label: "Box", Icon: Box },
|
||||||
|
{ value: "Package", label: "Package", Icon: Package },
|
||||||
|
{ value: "Layers", label: "Layers", Icon: Layers },
|
||||||
|
{ value: "Archive", label: "Archive", Icon: Archive },
|
||||||
|
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
|
||||||
|
{ value: "Globe", label: "Globe", Icon: Globe },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FolderEditDialog({
|
||||||
|
folderName,
|
||||||
|
currentColor,
|
||||||
|
currentIcon,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
}: FolderEditDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedColor, setSelectedColor] = useState(
|
||||||
|
currentColor || AVAILABLE_COLORS[0].value,
|
||||||
|
);
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState(
|
||||||
|
currentIcon || AVAILABLE_ICONS[0].value,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
|
||||||
|
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
|
||||||
|
}
|
||||||
|
}, [open, currentColor, currentIcon]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave(selectedColor, selectedIcon);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save folder metadata:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
{t("hosts.editFolderAppearance")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("hosts.editFolderAppearanceDesc")}:{" "}
|
||||||
|
<span className="font-mono text-foreground">{folderName}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Color Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.folderColor")}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{AVAILABLE_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
|
||||||
|
selectedColor === color.value
|
||||||
|
? "border-white shadow-lg scale-105"
|
||||||
|
: "border-dark-border"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
onClick={() => setSelectedColor(color.value)}
|
||||||
|
title={color.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.folderIcon")}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
|
||||||
|
selectedIcon === value
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-dark-border bg-dark-bg-darker"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedIcon(value)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.preview")}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
|
||||||
|
{(() => {
|
||||||
|
const IconComponent =
|
||||||
|
AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon ||
|
||||||
|
Folder;
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ color: selectedColor }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="font-medium">{folderName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type StatsConfig,
|
type StatsConfig,
|
||||||
DEFAULT_STATS_CONFIG,
|
DEFAULT_STATS_CONFIG,
|
||||||
} from "@/types/stats-widgets";
|
} from "@/types/stats-widgets";
|
||||||
|
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
|
||||||
import {
|
import {
|
||||||
CpuWidget,
|
CpuWidget,
|
||||||
MemoryWidget,
|
MemoryWidget,
|
||||||
@@ -443,17 +444,8 @@ export function Server({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{metricsEnabled && showStatsUI && (
|
{metricsEnabled && showStatsUI && (
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
|
||||||
{isLoadingMetrics && !metrics ? (
|
{!metrics && serverStatus === "offline" ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
<span className="text-gray-300">
|
|
||||||
{t("serverStats.loadingMetrics")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !metrics && serverStatus === "offline" ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||||
@@ -476,6 +468,13 @@ export function Server({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={isLoadingMetrics && !metrics}
|
||||||
|
minDuration={700}
|
||||||
|
message={t("serverStats.loadingMetrics")}
|
||||||
|
showLogo={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal file
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CommandAutocompleteProps {
|
||||||
|
suggestions: string[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (command: string) => void;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandAutocomplete({
|
||||||
|
suggestions,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
position,
|
||||||
|
visible,
|
||||||
|
}: CommandAutocompleteProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRef.current && containerRef.current) {
|
||||||
|
selectedRef.current.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (!visible || suggestions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
|
||||||
|
style={{
|
||||||
|
top: `${position.top}px`,
|
||||||
|
left: `${position.left}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={index === selectedIndex ? selectedRef : null}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||||
|
"hover:bg-dark-hover",
|
||||||
|
index === selectedIndex && "bg-blue-500/20 text-blue-400",
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(suggestion)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
// Optional: update selected index on hover
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
|
||||||
|
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal file
238
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Search, Clock, X, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface CommandHistoryDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
commands: string[];
|
||||||
|
onSelectCommand: (command: string) => void;
|
||||||
|
onDeleteCommand?: (command: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandHistoryDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
commands,
|
||||||
|
onSelectCommand,
|
||||||
|
onDeleteCommand,
|
||||||
|
isLoading = false,
|
||||||
|
}: CommandHistoryDialogProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter commands based on search query
|
||||||
|
const filteredCommands = searchQuery
|
||||||
|
? commands.filter((cmd) =>
|
||||||
|
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: commands;
|
||||||
|
|
||||||
|
// Reset state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
// Focus search input
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRef.current && listRef.current) {
|
||||||
|
selectedRef.current.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (filteredCommands.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev < filteredCommands.length - 1 ? prev + 1 : prev,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredCommands[selectedIndex]) {
|
||||||
|
onSelectCommand(filteredCommands[selectedIndex]);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenChange(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (command: string) => {
|
||||||
|
onSelectCommand(command);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Command History
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
Loading history...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredCommands.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
{searchQuery ? (
|
||||||
|
<>
|
||||||
|
<Search className="h-12 w-12 mb-2 opacity-20" />
|
||||||
|
<p>No commands found matching "{searchQuery}"</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="h-12 w-12 mb-2 opacity-20" />
|
||||||
|
<p>No command history yet</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Execute commands to build your history
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredCommands.map((command, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={index === selectedIndex ? selectedRef : null}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2.5 rounded-md transition-colors group",
|
||||||
|
"font-mono text-sm flex items-center justify-between gap-2",
|
||||||
|
"hover:bg-accent",
|
||||||
|
index === selectedIndex &&
|
||||||
|
"bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50",
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex-1 cursor-pointer"
|
||||||
|
onClick={() => handleSelect(command)}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</span>
|
||||||
|
{onDeleteCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteCommand(command);
|
||||||
|
}}
|
||||||
|
title="Delete command"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="px-6 py-3 border-t border-border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
||||||
|
↑↓
|
||||||
|
</kbd>{" "}
|
||||||
|
Navigate
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
||||||
|
Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
Select
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
Close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{filteredCommands.length} command
|
||||||
|
{filteredCommands.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
@@ -26,6 +27,11 @@ import {
|
|||||||
TERMINAL_FONTS,
|
TERMINAL_FONTS,
|
||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes";
|
||||||
import type { TerminalConfig } from "@/types";
|
import type { TerminalConfig } from "@/types";
|
||||||
|
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||||
|
import { useCommandHistory } from "@/ui/hooks/useCommandHistory";
|
||||||
|
import { CommandHistoryDialog } from "./CommandHistoryDialog";
|
||||||
|
import { CommandAutocomplete } from "./CommandAutocomplete";
|
||||||
|
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
|
||||||
|
|
||||||
interface HostConfig {
|
interface HostConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -112,7 +118,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
const isReadyRef = useRef<boolean>(false);
|
|
||||||
const isFittingRef = useRef(false);
|
const isFittingRef = useRef(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
@@ -123,6 +128,104 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const activityLoggedRef = useRef(false);
|
const activityLoggedRef = useRef(false);
|
||||||
|
const keyHandlerAttachedRef = useRef(false);
|
||||||
|
|
||||||
|
// Command history tracking (Stage 1)
|
||||||
|
const { trackInput, getCurrentCommand, updateCurrentCommand } =
|
||||||
|
useCommandTracker({
|
||||||
|
hostId: hostConfig.id,
|
||||||
|
enabled: true,
|
||||||
|
onCommandExecuted: (command) => {
|
||||||
|
// Add to autocomplete history (Stage 3)
|
||||||
|
if (!autocompleteHistory.current.includes(command)) {
|
||||||
|
autocompleteHistory.current = [
|
||||||
|
command,
|
||||||
|
...autocompleteHistory.current,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create refs for callbacks to avoid triggering useEffect re-runs
|
||||||
|
const getCurrentCommandRef = useRef(getCurrentCommand);
|
||||||
|
const updateCurrentCommandRef = useRef(updateCurrentCommand);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentCommandRef.current = getCurrentCommand;
|
||||||
|
updateCurrentCommandRef.current = updateCurrentCommand;
|
||||||
|
}, [getCurrentCommand, updateCurrentCommand]);
|
||||||
|
|
||||||
|
// Real-time autocomplete (Stage 3)
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] =
|
||||||
|
useState(0);
|
||||||
|
const [autocompletePosition, setAutocompletePosition] = useState({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
const autocompleteHistory = useRef<string[]>([]);
|
||||||
|
const currentAutocompleteCommand = useRef<string>("");
|
||||||
|
|
||||||
|
// Refs for accessing current state in event handlers
|
||||||
|
const showAutocompleteRef = useRef(false);
|
||||||
|
const autocompleteSuggestionsRef = useRef<string[]>([]);
|
||||||
|
const autocompleteSelectedIndexRef = useRef(0);
|
||||||
|
|
||||||
|
// Command history dialog (Stage 2)
|
||||||
|
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||||
|
|
||||||
|
// Load command history when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (showHistoryDialog && hostConfig.id) {
|
||||||
|
setIsLoadingHistory(true);
|
||||||
|
import("@/ui/main-axios.ts")
|
||||||
|
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||||
|
.then((history) => {
|
||||||
|
setCommandHistory(history);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load command history:", error);
|
||||||
|
setCommandHistory([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingHistory(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showHistoryDialog, hostConfig.id]);
|
||||||
|
|
||||||
|
// Load command history for autocomplete on mount (Stage 3)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hostConfig.id) {
|
||||||
|
import("@/ui/main-axios.ts")
|
||||||
|
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||||
|
.then((history) => {
|
||||||
|
autocompleteHistory.current = history;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load autocomplete history:", error);
|
||||||
|
autocompleteHistory.current = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [hostConfig.id]);
|
||||||
|
|
||||||
|
// Sync autocomplete state to refs for event handlers
|
||||||
|
useEffect(() => {
|
||||||
|
showAutocompleteRef.current = showAutocomplete;
|
||||||
|
}, [showAutocomplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autocompleteSuggestionsRef.current = autocompleteSuggestions;
|
||||||
|
}, [autocompleteSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
|
||||||
|
}, [autocompleteSelectedIndex]);
|
||||||
|
|
||||||
const activityLoggingRef = useRef(false);
|
const activityLoggingRef = useRef(false);
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
@@ -158,10 +261,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
isVisibleRef.current = isVisible;
|
isVisibleRef.current = isVisible;
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isReadyRef.current = isReady;
|
|
||||||
}, [isReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
@@ -516,9 +615,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
if (data === "\x00" || data === "\u0000") {
|
// Track command input for history (Stage 1)
|
||||||
return;
|
trackInput(data);
|
||||||
}
|
// Send input to server
|
||||||
ws.send(JSON.stringify({ type: "input", data }));
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -778,6 +877,88 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle command selection from history dialog (Stage 2)
|
||||||
|
const handleSelectCommand = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (!terminal || !webSocketRef.current) return;
|
||||||
|
|
||||||
|
// Send the command to the terminal
|
||||||
|
// Simulate typing the command character by character
|
||||||
|
for (const char of command) {
|
||||||
|
webSocketRef.current.send(
|
||||||
|
JSON.stringify({ type: "input", data: char }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return focus to terminal after selecting command
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.focus();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
[terminal],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle autocomplete selection (mouse click)
|
||||||
|
const handleAutocompleteSelect = useCallback(
|
||||||
|
(selectedCommand: string) => {
|
||||||
|
if (!webSocketRef.current) return;
|
||||||
|
|
||||||
|
const currentCmd = currentAutocompleteCommand.current;
|
||||||
|
const completion = selectedCommand.substring(currentCmd.length);
|
||||||
|
|
||||||
|
// Send completion characters to server
|
||||||
|
for (const char of completion) {
|
||||||
|
webSocketRef.current.send(
|
||||||
|
JSON.stringify({ type: "input", data: char }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current command tracker
|
||||||
|
updateCurrentCommand(selectedCommand);
|
||||||
|
|
||||||
|
// Close autocomplete
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteSuggestions([]);
|
||||||
|
currentAutocompleteCommand.current = "";
|
||||||
|
|
||||||
|
// Return focus to terminal
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal?.focus();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`);
|
||||||
|
},
|
||||||
|
[terminal, updateCurrentCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle command deletion from history dialog
|
||||||
|
const handleDeleteCommand = useCallback(
|
||||||
|
async (command: string) => {
|
||||||
|
if (!hostConfig.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call API to delete command
|
||||||
|
const { deleteCommandFromHistory } = await import(
|
||||||
|
"@/ui/main-axios.ts"
|
||||||
|
);
|
||||||
|
await deleteCommandFromHistory(hostConfig.id, command);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setCommandHistory((prev) => prev.filter((cmd) => cmd !== command));
|
||||||
|
|
||||||
|
// Update autocomplete history
|
||||||
|
autocompleteHistory.current = autocompleteHistory.current.filter(
|
||||||
|
(cmd) => cmd !== command,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Terminal] Command deleted from history: ${command}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete command from history:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hostConfig.id],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current) return;
|
if (!terminal || !xtermRef.current) return;
|
||||||
|
|
||||||
@@ -882,6 +1063,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
|
||||||
|
// Handle Ctrl+R for command history (Stage 2)
|
||||||
|
if (
|
||||||
|
e.ctrlKey &&
|
||||||
|
e.key === "r" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowHistoryDialog(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.backspaceMode === "control-h" &&
|
config.backspaceMode === "control-h" &&
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
@@ -933,21 +1128,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
element?.addEventListener("keydown", handleMacKeyboard, true);
|
element?.addEventListener("keydown", handleMacKeyboard, true);
|
||||||
|
|
||||||
const handleResize = () => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
if (!isVisibleRef.current || !isReadyRef.current) return;
|
if (!isVisibleRef.current || !isReady) return;
|
||||||
performFit();
|
performFit();
|
||||||
}, 100);
|
}, 50);
|
||||||
};
|
});
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(handleResize);
|
resizeObserver.observe(xtermRef.current);
|
||||||
|
|
||||||
if (xtermRef.current) {
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
|
||||||
@@ -960,7 +1149,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
isFittingRef.current = false;
|
isFittingRef.current = false;
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
@@ -977,6 +1165,192 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]);
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
|
|
||||||
|
// Register keyboard handler for autocomplete (Stage 3)
|
||||||
|
// Registered only once when terminal is created
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
|
const handleCustomKey = (e: KeyboardEvent): boolean => {
|
||||||
|
// Only handle keydown events, ignore keyup to prevent double triggering
|
||||||
|
if (e.type !== "keydown") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If autocomplete is showing, handle keys specially
|
||||||
|
if (showAutocompleteRef.current) {
|
||||||
|
// Handle Escape to close autocomplete
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteSuggestions([]);
|
||||||
|
currentAutocompleteCommand.current = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Arrow keys for autocomplete navigation
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const currentIndex = autocompleteSelectedIndexRef.current;
|
||||||
|
const suggestionsLength = autocompleteSuggestionsRef.current.length;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
const newIndex =
|
||||||
|
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
|
||||||
|
setAutocompleteSelectedIndex(newIndex);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
const newIndex =
|
||||||
|
currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
|
||||||
|
setAutocompleteSelectedIndex(newIndex);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter to confirm autocomplete selection
|
||||||
|
if (
|
||||||
|
e.key === "Enter" &&
|
||||||
|
autocompleteSuggestionsRef.current.length > 0
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const selectedCommand =
|
||||||
|
autocompleteSuggestionsRef.current[
|
||||||
|
autocompleteSelectedIndexRef.current
|
||||||
|
];
|
||||||
|
const currentCmd = currentAutocompleteCommand.current;
|
||||||
|
const completion = selectedCommand.substring(currentCmd.length);
|
||||||
|
|
||||||
|
// Send completion characters to server
|
||||||
|
if (webSocketRef.current?.readyState === 1) {
|
||||||
|
for (const char of completion) {
|
||||||
|
webSocketRef.current.send(
|
||||||
|
JSON.stringify({ type: "input", data: char }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current command tracker
|
||||||
|
updateCurrentCommandRef.current(selectedCommand);
|
||||||
|
|
||||||
|
// Close autocomplete
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteSuggestions([]);
|
||||||
|
currentAutocompleteCommand.current = "";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Tab to cycle through suggestions
|
||||||
|
if (
|
||||||
|
e.key === "Tab" &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.shiftKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentIndex = autocompleteSelectedIndexRef.current;
|
||||||
|
const suggestionsLength = autocompleteSuggestionsRef.current.length;
|
||||||
|
const newIndex =
|
||||||
|
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
|
||||||
|
setAutocompleteSelectedIndex(newIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any other key while autocomplete is showing, close it and let key through
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteSuggestions([]);
|
||||||
|
currentAutocompleteCommand.current = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Tab for autocomplete (when autocomplete is not showing)
|
||||||
|
if (
|
||||||
|
e.key === "Tab" &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.shiftKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const currentCmd = getCurrentCommandRef.current().trim();
|
||||||
|
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||||
|
// Filter commands that start with current input
|
||||||
|
const matches = autocompleteHistory.current
|
||||||
|
.filter(
|
||||||
|
(cmd) =>
|
||||||
|
cmd.startsWith(currentCmd) &&
|
||||||
|
cmd !== currentCmd &&
|
||||||
|
cmd.length > currentCmd.length,
|
||||||
|
)
|
||||||
|
.slice(0, 10); // Show up to 10 matches
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
// Only one match - auto-complete directly
|
||||||
|
const completedCommand = matches[0];
|
||||||
|
const completion = completedCommand.substring(currentCmd.length);
|
||||||
|
|
||||||
|
for (const char of completion) {
|
||||||
|
webSocketRef.current.send(
|
||||||
|
JSON.stringify({ type: "input", data: char }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrentCommandRef.current(completedCommand);
|
||||||
|
} else if (matches.length > 1) {
|
||||||
|
// Multiple matches - show selection list
|
||||||
|
currentAutocompleteCommand.current = currentCmd;
|
||||||
|
setAutocompleteSuggestions(matches);
|
||||||
|
setAutocompleteSelectedIndex(0);
|
||||||
|
|
||||||
|
// Calculate position (below or above cursor based on available space)
|
||||||
|
const cursorY = terminal.buffer.active.cursorY;
|
||||||
|
const cursorX = terminal.buffer.active.cursorX;
|
||||||
|
const rect = xtermRef.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect) {
|
||||||
|
const cellHeight =
|
||||||
|
terminal.rows > 0 ? rect.height / terminal.rows : 20;
|
||||||
|
const cellWidth =
|
||||||
|
terminal.cols > 0 ? rect.width / terminal.cols : 10;
|
||||||
|
|
||||||
|
// Estimate autocomplete menu height (max-h-[240px] from component)
|
||||||
|
const menuHeight = 240;
|
||||||
|
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||||
|
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||||
|
const spaceAbove = rect.top + cursorY * cellHeight;
|
||||||
|
|
||||||
|
// Show above cursor if not enough space below
|
||||||
|
const showAbove =
|
||||||
|
spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||||
|
|
||||||
|
setAutocompletePosition({
|
||||||
|
top: showAbove
|
||||||
|
? rect.top + cursorY * cellHeight - menuHeight
|
||||||
|
: cursorBottomY,
|
||||||
|
left: rect.left + cursorX * cellWidth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // Prevent default Tab behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let terminal handle all other keys
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
terminal.attachCustomKeyEventHandler(handleCustomKey);
|
||||||
|
}, [terminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !hostConfig || !visible) return;
|
if (!terminal || !hostConfig || !visible) return;
|
||||||
|
|
||||||
@@ -1103,17 +1477,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isConnecting && (
|
<CommandHistoryDialog
|
||||||
<div
|
open={showHistoryDialog}
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
onOpenChange={setShowHistoryDialog}
|
||||||
style={{ backgroundColor }}
|
commands={commandHistory}
|
||||||
>
|
onSelectCommand={handleSelectCommand}
|
||||||
<div className="flex items-center gap-3">
|
onDeleteCommand={handleDeleteCommand}
|
||||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
isLoading={isLoadingHistory}
|
||||||
<span className="text-gray-300">{t("terminal.connecting")}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
<CommandAutocomplete
|
||||||
)}
|
visible={showAutocomplete}
|
||||||
|
suggestions={autocompleteSuggestions}
|
||||||
|
selectedIndex={autocompleteSelectedIndex}
|
||||||
|
position={autocompletePosition}
|
||||||
|
onSelect={handleAutocompleteSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={isConnecting}
|
||||||
|
minDuration={800}
|
||||||
|
message={t("terminal.connecting")}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
|
showLogo={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,9 @@ import {
|
|||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
||||||
import { getSSHHosts } from "@/ui/main-axios.ts";
|
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
|
import type { SSHFolder } from "@/types/index.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -114,7 +115,11 @@ export function LeftSidebar({
|
|||||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||||
const openSshManagerTab = () => {
|
const openSshManagerTab = () => {
|
||||||
if (sshManagerTab || isSplitScreenActive) return;
|
if (isSplitScreenActive) return;
|
||||||
|
if (sshManagerTab) {
|
||||||
|
setCurrentTab(sshManagerTab.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
|
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
|
||||||
setCurrentTab(id);
|
setCurrentTab(id);
|
||||||
};
|
};
|
||||||
@@ -145,6 +150,22 @@ export function LeftSidebar({
|
|||||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFolderMetadata = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const folders = await getSSHFolders();
|
||||||
|
const metadataMap = new Map<string, SSHFolder>();
|
||||||
|
folders.forEach((folder) => {
|
||||||
|
metadataMap.set(folder.name, folder);
|
||||||
|
});
|
||||||
|
setFolderMetadata(metadataMap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch folder metadata:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchHosts = React.useCallback(async () => {
|
const fetchHosts = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -210,13 +231,18 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchHosts, 300000);
|
fetchFolderMetadata();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
|
}, 300000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts, fetchFolderMetadata]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleHostsChanged = () => {
|
const handleHostsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
};
|
};
|
||||||
const handleCredentialsChanged = () => {
|
const handleCredentialsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
@@ -239,7 +265,7 @@ export function LeftSidebar({
|
|||||||
handleCredentialsChanged as EventListener,
|
handleCredentialsChanged as EventListener,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts, fetchFolderMetadata]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
@@ -396,13 +422,11 @@ export function LeftSidebar({
|
|||||||
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
|
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={openSshManagerTab}
|
onClick={openSshManagerTab}
|
||||||
disabled={!!sshManagerTab || isSplitScreenActive}
|
disabled={isSplitScreenActive}
|
||||||
title={
|
title={
|
||||||
sshManagerTab
|
isSplitScreenActive
|
||||||
? t("interface.sshManagerAlreadyOpen")
|
? t("interface.disabledDuringSplitScreen")
|
||||||
: isSplitScreenActive
|
: undefined
|
||||||
? t("interface.disabledDuringSplitScreen")
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HardDrive strokeWidth="2.5" />
|
<HardDrive strokeWidth="2.5" />
|
||||||
@@ -437,15 +461,20 @@ export function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
{sortedFolders.map((folder, idx) => {
|
||||||
<FolderCard
|
const metadata = folderMetadata.get(folder);
|
||||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
return (
|
||||||
folderName={folder}
|
<FolderCard
|
||||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||||
isFirst={idx === 0}
|
folderName={folder}
|
||||||
isLast={idx === sortedFolders.length - 1}
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
/>
|
isFirst={idx === 0}
|
||||||
))}
|
isLast={idx === sortedFolders.length - 1}
|
||||||
|
folderColor={metadata?.color}
|
||||||
|
folderIcon={metadata?.icon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<Separator className="p-0.25 mt-1 mb-1" />
|
<Separator className="p-0.25 mt-1 mb-1" />
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { CardTitle } from "@/components/ui/card.tsx";
|
import { CardTitle } from "@/components/ui/card.tsx";
|
||||||
import { ChevronDown, Folder } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
|
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
@@ -40,11 +52,15 @@ interface FolderCardProps {
|
|||||||
hosts: SSHHost[];
|
hosts: SSHHost[];
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
|
folderColor?: string;
|
||||||
|
folderIcon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderCard({
|
export function FolderCard({
|
||||||
folderName,
|
folderName,
|
||||||
hosts,
|
hosts,
|
||||||
|
folderColor,
|
||||||
|
folderIcon,
|
||||||
}: FolderCardProps): React.ReactElement {
|
}: FolderCardProps): React.ReactElement {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
@@ -52,6 +68,30 @@ export function FolderCard({
|
|||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconMap: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}>
|
||||||
|
> = {
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FolderIcon =
|
||||||
|
folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
|
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
|
||||||
<div
|
<div
|
||||||
@@ -59,7 +99,11 @@ export function FolderCard({
|
|||||||
>
|
>
|
||||||
<div className="flex gap-2 pr-10">
|
<div className="flex gap-2 pr-10">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<Folder size={16} strokeWidth={3} />
|
<FolderIcon
|
||||||
|
size={16}
|
||||||
|
strokeWidth={3}
|
||||||
|
style={folderColor ? { color: folderColor } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="mb-0 leading-tight break-words text-md">
|
<CardTitle className="mb-0 leading-tight break-words text-md">
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface TabProps {
|
|||||||
disableClose?: boolean;
|
disableClose?: boolean;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isDragOver?: boolean;
|
isDragOver?: boolean;
|
||||||
|
isValidDropTarget?: boolean;
|
||||||
|
isHoveredDropTarget?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tab({
|
export function Tab({
|
||||||
@@ -44,6 +46,8 @@ export function Tab({
|
|||||||
disableClose = false,
|
disableClose = false,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isDragOver = false,
|
isDragOver = false,
|
||||||
|
isValidDropTarget = false,
|
||||||
|
isHoveredDropTarget = false,
|
||||||
}: TabProps): React.ReactElement {
|
}: TabProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -54,12 +58,21 @@ export function Tab({
|
|||||||
isDragOver &&
|
isDragOver &&
|
||||||
"bg-background/40 text-muted-foreground border-border opacity-60",
|
"bg-background/40 text-muted-foreground border-border opacity-60",
|
||||||
isDragging && "opacity-70",
|
isDragging && "opacity-70",
|
||||||
|
isHoveredDropTarget &&
|
||||||
|
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
|
||||||
|
!isHoveredDropTarget &&
|
||||||
|
isValidDropTarget &&
|
||||||
|
"border-blue-400/50 bg-background/90",
|
||||||
!isDragOver &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
|
!isValidDropTarget &&
|
||||||
|
!isHoveredDropTarget &&
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-background text-foreground border-border z-10",
|
"bg-background text-foreground border-border z-10",
|
||||||
!isDragOver &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
|
!isValidDropTarget &&
|
||||||
|
!isHoveredDropTarget &&
|
||||||
!isActive &&
|
!isActive &&
|
||||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
import { User, Shield, AlertCircle } from "lucide-react";
|
import { User, Shield, AlertCircle } from "lucide-react";
|
||||||
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -93,6 +94,9 @@ export function UserProfile({
|
|||||||
const [deletePassword, setDeletePassword] = useState("");
|
const [deletePassword, setDeletePassword] = useState("");
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
|
||||||
|
localStorage.getItem("fileColorCoding") !== "false",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserInfo();
|
fetchUserInfo();
|
||||||
@@ -134,6 +138,13 @@ export function UserProfile({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileColorCodingToggle = (enabled: boolean) => {
|
||||||
|
setFileColorCoding(enabled);
|
||||||
|
localStorage.setItem("fileColorCoding", enabled.toString());
|
||||||
|
// Trigger a re-render by dispatching a custom event
|
||||||
|
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
@@ -331,6 +342,23 @@ export function UserProfile({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("profile.fileColorCoding")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.fileColorCodingDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fileColorCoding}
|
||||||
|
onCheckedChange={handleFileColorCodingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
147
src/ui/hooks/useCommandHistory.ts
Normal file
147
src/ui/hooks/useCommandHistory.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { getCommandHistory, saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface UseCommandHistoryOptions {
|
||||||
|
hostId?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandHistoryResult {
|
||||||
|
suggestions: string[];
|
||||||
|
getSuggestions: (input: string) => string[];
|
||||||
|
saveCommand: (command: string) => Promise<void>;
|
||||||
|
clearSuggestions: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing command history and autocomplete suggestions
|
||||||
|
*/
|
||||||
|
export function useCommandHistory({
|
||||||
|
hostId,
|
||||||
|
enabled = true,
|
||||||
|
}: UseCommandHistoryOptions): CommandHistoryResult {
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const historyCache = useRef<Map<number, string[]>>(new Map());
|
||||||
|
|
||||||
|
// Fetch command history when hostId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !hostId) {
|
||||||
|
setCommandHistory([]);
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = historyCache.current.get(hostId);
|
||||||
|
if (cached) {
|
||||||
|
setCommandHistory(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from server
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const history = await getCommandHistory(hostId);
|
||||||
|
setCommandHistory(history);
|
||||||
|
historyCache.current.set(hostId, history);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch command history:", error);
|
||||||
|
setCommandHistory([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchHistory();
|
||||||
|
}, [hostId, enabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get command suggestions based on current input
|
||||||
|
*/
|
||||||
|
const getSuggestions = useCallback(
|
||||||
|
(input: string): string[] => {
|
||||||
|
if (!input || input.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
const matches = commandHistory.filter((cmd) =>
|
||||||
|
cmd.startsWith(trimmedInput),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return up to 10 suggestions, excluding exact matches
|
||||||
|
const filtered = matches
|
||||||
|
.filter((cmd) => cmd !== trimmedInput)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
setSuggestions(filtered);
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
[commandHistory],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a command to history
|
||||||
|
*/
|
||||||
|
const saveCommand = useCallback(
|
||||||
|
async (command: string) => {
|
||||||
|
if (!enabled || !hostId || !command || command.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedCommand = command.trim();
|
||||||
|
|
||||||
|
// Skip if it's the same as the last command
|
||||||
|
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save to server
|
||||||
|
await saveCommandToHistory(hostId, trimmedCommand);
|
||||||
|
|
||||||
|
// Update local state - add to beginning
|
||||||
|
setCommandHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
trimmedCommand,
|
||||||
|
...prev.filter((c) => c !== trimmedCommand),
|
||||||
|
];
|
||||||
|
// Keep max 500 commands in memory
|
||||||
|
const limited = newHistory.slice(0, 500);
|
||||||
|
historyCache.current.set(hostId, limited);
|
||||||
|
return limited;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save command to history:", error);
|
||||||
|
// Still update local state even if server save fails
|
||||||
|
setCommandHistory((prev) => {
|
||||||
|
const newHistory = [
|
||||||
|
trimmedCommand,
|
||||||
|
...prev.filter((c) => c !== trimmedCommand),
|
||||||
|
];
|
||||||
|
return newHistory.slice(0, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, hostId, commandHistory],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear current suggestions
|
||||||
|
*/
|
||||||
|
const clearSuggestions = useCallback(() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
getSuggestions,
|
||||||
|
saveCommand,
|
||||||
|
clearSuggestions,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
144
src/ui/hooks/useCommandTracker.ts
Normal file
144
src/ui/hooks/useCommandTracker.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useRef, useCallback } from "react";
|
||||||
|
import { saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface UseCommandTrackerOptions {
|
||||||
|
hostId?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
onCommandExecuted?: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandTrackerResult {
|
||||||
|
trackInput: (data: string) => void;
|
||||||
|
getCurrentCommand: () => string;
|
||||||
|
clearCurrentCommand: () => void;
|
||||||
|
updateCurrentCommand: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track terminal input and save executed commands to history
|
||||||
|
* Works with SSH terminals by monitoring input data
|
||||||
|
*/
|
||||||
|
export function useCommandTracker({
|
||||||
|
hostId,
|
||||||
|
enabled = true,
|
||||||
|
onCommandExecuted,
|
||||||
|
}: UseCommandTrackerOptions): CommandTrackerResult {
|
||||||
|
const currentCommandRef = useRef<string>("");
|
||||||
|
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track input data and detect command execution
|
||||||
|
*/
|
||||||
|
const trackInput = useCallback(
|
||||||
|
(data: string) => {
|
||||||
|
if (!enabled || !hostId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle each character
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const char = data[i];
|
||||||
|
const charCode = char.charCodeAt(0);
|
||||||
|
|
||||||
|
// Detect escape sequences (e.g., arrow keys, function keys)
|
||||||
|
if (charCode === 27) {
|
||||||
|
// ESC
|
||||||
|
isInEscapeSequenceRef.current = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip characters that are part of escape sequences
|
||||||
|
if (isInEscapeSequenceRef.current) {
|
||||||
|
// Common escape sequence endings
|
||||||
|
if (
|
||||||
|
(charCode >= 65 && charCode <= 90) || // A-Z
|
||||||
|
(charCode >= 97 && charCode <= 122) || // a-z
|
||||||
|
charCode === 126 // ~
|
||||||
|
) {
|
||||||
|
isInEscapeSequenceRef.current = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key (CR or LF)
|
||||||
|
if (charCode === 13 || charCode === 10) {
|
||||||
|
// \r or \n
|
||||||
|
const command = currentCommandRef.current.trim();
|
||||||
|
|
||||||
|
// Save non-empty commands
|
||||||
|
if (command.length > 0) {
|
||||||
|
// Save to history (async, don't wait)
|
||||||
|
saveCommandToHistory(hostId, command).catch((error) => {
|
||||||
|
console.error("Failed to save command to history:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for external handling
|
||||||
|
if (onCommandExecuted) {
|
||||||
|
onCommandExecuted(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current command
|
||||||
|
currentCommandRef.current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Backspace/Delete
|
||||||
|
if (charCode === 8 || charCode === 127) {
|
||||||
|
// Backspace or DEL
|
||||||
|
if (currentCommandRef.current.length > 0) {
|
||||||
|
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Ctrl+C, Ctrl+D, etc. - clear current command
|
||||||
|
if (charCode === 3 || charCode === 4) {
|
||||||
|
currentCommandRef.current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Ctrl+U (clear line) - common in terminals
|
||||||
|
if (charCode === 21) {
|
||||||
|
currentCommandRef.current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add printable characters to current command
|
||||||
|
if (charCode >= 32 && charCode <= 126) {
|
||||||
|
// Printable ASCII
|
||||||
|
currentCommandRef.current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, hostId, onCommandExecuted],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current command being typed
|
||||||
|
*/
|
||||||
|
const getCurrentCommand = useCallback(() => {
|
||||||
|
return currentCommandRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the current command buffer
|
||||||
|
*/
|
||||||
|
const clearCurrentCommand = useCallback(() => {
|
||||||
|
currentCommandRef.current = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current command buffer (used for autocomplete)
|
||||||
|
*/
|
||||||
|
const updateCurrentCommand = useCallback((command: string) => {
|
||||||
|
currentCommandRef.current = command;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackInput,
|
||||||
|
getCurrentCommand,
|
||||||
|
clearCurrentCommand,
|
||||||
|
updateCurrentCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
|
|||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
SSHHostData,
|
SSHHostData,
|
||||||
|
SSHFolder,
|
||||||
TunnelConfig,
|
TunnelConfig,
|
||||||
TunnelStatus,
|
TunnelStatus,
|
||||||
FileManagerFile,
|
FileManagerFile,
|
||||||
@@ -1520,6 +1521,145 @@ export async function moveSSHItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function changeSSHPermissions(
|
||||||
|
sessionId: string,
|
||||||
|
path: string,
|
||||||
|
permissions: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
fileLogger.info("Changing SSH file permissions", {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileLogger.success("SSH file permissions changed successfully", {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Failed to change SSH file permissions", error, {
|
||||||
|
operation: "change_permissions",
|
||||||
|
sessionId,
|
||||||
|
path,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
handleApiError(error, "change SSH permissions");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractSSHArchive(
|
||||||
|
sessionId: string,
|
||||||
|
archivePath: string,
|
||||||
|
extractPath?: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ success: boolean; message: string; extractPath: string }> {
|
||||||
|
try {
|
||||||
|
fileLogger.info("Extracting archive", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileLogger.success("Archive extracted successfully", {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath: response.data.extractPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Failed to extract archive", error, {
|
||||||
|
operation: "extract_archive",
|
||||||
|
sessionId,
|
||||||
|
archivePath,
|
||||||
|
extractPath,
|
||||||
|
});
|
||||||
|
handleApiError(error, "extract archive");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressSSHFiles(
|
||||||
|
sessionId: string,
|
||||||
|
paths: string[],
|
||||||
|
archiveName: string,
|
||||||
|
format?: string,
|
||||||
|
hostId?: number,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ success: boolean; message: string; archivePath: string }> {
|
||||||
|
try {
|
||||||
|
fileLogger.info("Compressing files", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archiveName,
|
||||||
|
format,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archiveName,
|
||||||
|
format: format || "zip",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileLogger.success("Files compressed successfully", {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archivePath: response.data.archivePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Failed to compress files", error, {
|
||||||
|
operation: "compress_files",
|
||||||
|
sessionId,
|
||||||
|
paths,
|
||||||
|
archiveName,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
handleApiError(error, "compress files");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FILE MANAGER DATA
|
// FILE MANAGER DATA
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2411,6 +2551,92 @@ export async function renameFolder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Fetching SSH folders", {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authApi.get("/ssh/folders");
|
||||||
|
|
||||||
|
sshLogger.success("SSH folders fetched successfully", {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
count: response.data.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to fetch SSH folders", error, {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
});
|
||||||
|
handleApiError(error, "fetch SSH folders");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFolderMetadata(
|
||||||
|
name: string,
|
||||||
|
color?: string,
|
||||||
|
icon?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Updating folder metadata", {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
await authApi.put("/ssh/folders/metadata", {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
sshLogger.success("Folder metadata updated successfully", {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to update folder metadata", error, {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
handleApiError(error, "update folder metadata");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllHostsInFolder(
|
||||||
|
folderName: string,
|
||||||
|
): Promise<{ deletedCount: number }> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Deleting all hosts in folder", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authApi.delete(
|
||||||
|
`/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||||
|
);
|
||||||
|
|
||||||
|
sshLogger.success("All hosts in folder deleted successfully", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
deletedCount: response.data.deletedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
handleApiError(error, "delete hosts in folder");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameCredentialFolder(
|
export async function renameCredentialFolder(
|
||||||
oldName: string,
|
oldName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
@@ -2631,3 +2857,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
|
|||||||
throw handleApiError(error, "reset recent activity");
|
throw handleApiError(error, "reset recent activity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMMAND HISTORY API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a command to history for a specific host
|
||||||
|
*/
|
||||||
|
export async function saveCommandToHistory(
|
||||||
|
hostId: number,
|
||||||
|
command: string,
|
||||||
|
): Promise<{ id: number; command: string; executedAt: string }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/terminal/command_history", {
|
||||||
|
hostId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "save command to history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get command history for a specific host
|
||||||
|
* Returns array of unique commands ordered by most recent
|
||||||
|
*/
|
||||||
|
export async function getCommandHistory(hostId: number): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get(`/terminal/command_history/${hostId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch command history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific command from history
|
||||||
|
*/
|
||||||
|
export async function deleteCommandFromHistory(
|
||||||
|
hostId: number,
|
||||||
|
command: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/terminal/command_history/delete", {
|
||||||
|
hostId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "delete command from history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear command history for a specific host (optional feature)
|
||||||
|
*/
|
||||||
|
export async function clearCommandHistory(
|
||||||
|
hostId: number,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete(
|
||||||
|
`/terminal/command_history/${hostId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "clear command history");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user