mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -0,0 +1,483 @@
|
||||
import type { Express } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { execChannel, type SSHSession } from "./session.js";
|
||||
|
||||
type FileActionRoutesDeps = {
|
||||
ctx: PluginContext;
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileActionRoutes(
|
||||
app: Express,
|
||||
{
|
||||
ctx,
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileActionRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/copyItem:
|
||||
* post:
|
||||
* summary: Copy a file or directory
|
||||
* description: Copies a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* sourcePath:
|
||||
* type: string
|
||||
* targetDir:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item copied successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to copy item.
|
||||
*/
|
||||
app.post("/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId } = req.body;
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId || !sourcePath || !targetDir) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
ctx.log.error(
|
||||
`Copy command timed out after 60 seconds: ${sourcePath} -> ${targetPath} (${copyCommand})`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Copy operation timed out",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Copy operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error("SSH copyItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorData = "";
|
||||
let stdoutData = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
errorData += output;
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (code !== 0) {
|
||||
const fullErrorInfo =
|
||||
errorData || stdoutData || "No error message available";
|
||||
ctx.log.error(
|
||||
`SSH copyItem command failed with code ${code}: ${sourcePath} -> ${targetPath} (${copyCommand}) ${fullErrorInfo}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Copy failed: ${fullErrorInfo}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Copy failed: ${fullErrorInfo}`,
|
||||
},
|
||||
debug: {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
exitCode: code,
|
||||
command: copyCommand,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const copySuccessful =
|
||||
stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
|
||||
if (copySuccessful) {
|
||||
ctx.log.info(
|
||||
`Item copied successfully: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, hostId=${hostId})`,
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item copied successfully",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Successfully copied to: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.log.warn(
|
||||
`Copy completed but without success confirmation: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, code=${code}, stdout=${stdoutData.substring(0, 200)})`,
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Copy may have completed",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "warning",
|
||||
message: `Copy completed but verification uncertain for: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error("SSH copyItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/executeFile:
|
||||
* post:
|
||||
* summary: Execute a file
|
||||
* description: Executes a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* filePath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File execution result.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 500:
|
||||
* description: Failed to execute file.
|
||||
*/
|
||||
app.post("/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
ctx.log.error(
|
||||
`SSH connection not found or not connected for executeFile: ${sessionId} (hasConnection=${!!sshConn}, isConnected=${sshConn?.isConnected})`,
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
ctx.log.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to check file executability" });
|
||||
}
|
||||
|
||||
let checkResult = "";
|
||||
checkStream.on("data", (data) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
|
||||
checkStream.on("close", () => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
execChannel(sshConn, executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
ctx.log.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
ctx.log.info(
|
||||
`File execution completed: ${sessionId} (${filePath}, exitCode=${actualExitCode}, outputLength=${cleanOutput.length}, errorLength=${errorOutput.length})`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
output: cleanOutput,
|
||||
error: errorOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
ctx.log.error("SSH executeFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Execution stream error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/changePermissions:
|
||||
* post:
|
||||
* summary: Change file permissions
|
||||
* description: Changes the permissions of a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* permissions:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Permissions changed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 408:
|
||||
* description: Permission change timed out.
|
||||
* 500:
|
||||
* description: Failed to change permissions.
|
||||
*/
|
||||
app.post("/changePermissions", async (req, res) => {
|
||||
const { sessionId, path, permissions } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
ctx.log.error(
|
||||
`SSH connection not found or not connected for changePermissions: ${sessionId} (hasConnection=${!!sshConn}, isConnected=${sshConn?.isConnected})`,
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
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)",
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
ctx.log.info(
|
||||
`Changing file permissions: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
);
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
ctx.log.error(
|
||||
`changePermissions command timeout: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
);
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error(
|
||||
`SSH changePermissions exec error: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
err,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
ctx.log.info(
|
||||
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
ctx.log.error(
|
||||
`chmod command failed: ${sessionId} (${path}, permissions=${octalPerms}, exitCode=${code}, error=${errorOutput})`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error(
|
||||
`SSH changePermissions stream error: ${sessionId} (${path}, permissions=${octalPerms})`,
|
||||
streamErr,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while changing permissions" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The tables come from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface BookmarkTables {
|
||||
table: Table;
|
||||
pinnedTable: Table;
|
||||
shortcutsTable: Table;
|
||||
transferRecentTable: Table;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent/pinned/shortcut files and recent transfer destinations, adopted from
|
||||
* core's host.ts and host-file-manager-bookmark-routes.ts. Kept simple, direct
|
||||
* Drizzle calls rather than a repository layer, since each is a handful of
|
||||
* lines and nothing else in the plugin reuses them.
|
||||
*/
|
||||
export function registerBookmarkRoutes(
|
||||
app: Express,
|
||||
ctx: PluginContext,
|
||||
tables: BookmarkTables,
|
||||
): void {
|
||||
const { table, pinnedTable, shortcutsTable, transferRecentTable } = tables;
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/recent:
|
||||
* get:
|
||||
* summary: Get recent files
|
||||
* description: Retrieves a list of recent files for a specific host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch recent files.
|
||||
*/
|
||||
app.get("/recent", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
if (!hostId) {
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.orderBy(desc(table.lastOpened))
|
||||
.limit(20);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to fetch recent files", err as Error);
|
||||
res.status(500).json({ error: "Failed to fetch recent files" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/recent:
|
||||
* post:
|
||||
* summary: Add recent file
|
||||
* description: Adds a file to the list of recent files for a host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to add recent file.
|
||||
*/
|
||||
app.post("/recent", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.hostId, hostId),
|
||||
eq(table.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (existing[0]) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ name: name ?? existing[0].name, lastOpened: now })
|
||||
.where(eq(table.id, existing[0].id));
|
||||
} else {
|
||||
await drizzle.insert(table).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name ?? path,
|
||||
lastOpened: now,
|
||||
});
|
||||
}
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Recent file added" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to add recent file", err as Error);
|
||||
res.status(500).json({ error: "Failed to add recent file" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/recent:
|
||||
* delete:
|
||||
* summary: Remove recent file
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove recent file.
|
||||
*/
|
||||
app.delete("/recent", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.hostId, hostId),
|
||||
eq(table.path, path),
|
||||
),
|
||||
);
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Recent file removed" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to remove recent file", err as Error);
|
||||
res.status(500).json({ error: "Failed to remove recent file" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/pinned:
|
||||
* get:
|
||||
* summary: Get pinned files
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of pinned files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch pinned files.
|
||||
*/
|
||||
app.get("/pinned", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
if (!hostId) {
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(pinnedTable)
|
||||
.where(
|
||||
and(eq(pinnedTable.userId, userId), eq(pinnedTable.hostId, hostId)),
|
||||
)
|
||||
.orderBy(desc(pinnedTable.pinnedAt));
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to fetch pinned files", err as Error);
|
||||
res.status(500).json({ error: "Failed to fetch pinned files" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/pinned:
|
||||
* post:
|
||||
* summary: Add pinned file
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File pinned.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: File already pinned.
|
||||
* 500:
|
||||
* description: Failed to pin file.
|
||||
*/
|
||||
app.post("/pinned", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select()
|
||||
.from(pinnedTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pinnedTable.userId, userId),
|
||||
eq(pinnedTable.hostId, hostId),
|
||||
eq(pinnedTable.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
return res.status(409).json({ error: "File already pinned" });
|
||||
}
|
||||
|
||||
await drizzle.insert(pinnedTable).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name ?? path,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
});
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "File pinned" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to pin file", err as Error);
|
||||
res.status(500).json({ error: "Failed to pin file" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/pinned:
|
||||
* delete:
|
||||
* summary: Remove pinned file
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Pinned file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove pinned file.
|
||||
*/
|
||||
app.delete("/pinned", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(pinnedTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pinnedTable.userId, userId),
|
||||
eq(pinnedTable.hostId, hostId),
|
||||
eq(pinnedTable.path, path),
|
||||
),
|
||||
);
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Pinned file removed" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to remove pinned file", err as Error);
|
||||
res.status(500).json({ error: "Failed to remove pinned file" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/shortcuts:
|
||||
* get:
|
||||
* summary: Get shortcuts
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of shortcuts.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch shortcuts.
|
||||
*/
|
||||
app.get("/shortcuts", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
if (!hostId) {
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(shortcutsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(shortcutsTable.userId, userId),
|
||||
eq(shortcutsTable.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(shortcutsTable.createdAt));
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to fetch shortcuts", err as Error);
|
||||
res.status(500).json({ error: "Failed to fetch shortcuts" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/shortcuts:
|
||||
* post:
|
||||
* summary: Add shortcut
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: Shortcut already exists.
|
||||
* 500:
|
||||
* description: Failed to add shortcut.
|
||||
*/
|
||||
app.post("/shortcuts", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select()
|
||||
.from(shortcutsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(shortcutsTable.userId, userId),
|
||||
eq(shortcutsTable.hostId, hostId),
|
||||
eq(shortcutsTable.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
return res.status(409).json({ error: "Shortcut already exists" });
|
||||
}
|
||||
|
||||
await drizzle.insert(shortcutsTable).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name ?? path,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Shortcut added" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to add shortcut", err as Error);
|
||||
res.status(500).json({ error: "Failed to add shortcut" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/shortcuts:
|
||||
* delete:
|
||||
* summary: Remove shortcut
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove shortcut.
|
||||
*/
|
||||
app.delete("/shortcuts", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(shortcutsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(shortcutsTable.userId, userId),
|
||||
eq(shortcutsTable.hostId, hostId),
|
||||
eq(shortcutsTable.path, path),
|
||||
),
|
||||
);
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Shortcut removed" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to remove shortcut", err as Error);
|
||||
res.status(500).json({ error: "Failed to remove shortcut" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/transfer-recent:
|
||||
* get:
|
||||
* summary: Get recent transfer destinations
|
||||
* description: Retrieves recently used host-to-host transfer destinations for a source host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sourceHostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent transfer destinations.
|
||||
* 400:
|
||||
* description: Invalid userId or sourceHostId.
|
||||
* 500:
|
||||
* description: Failed to fetch recent destinations.
|
||||
*/
|
||||
app.get("/transfer-recent", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
|
||||
? req.query.sourceHostId[0]
|
||||
: req.query.sourceHostId;
|
||||
const sourceHostId = sourceHostIdQuery
|
||||
? parseInt(sourceHostIdQuery as string)
|
||||
: null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
if (!sourceHostId) {
|
||||
return res.status(400).json({ error: "Source host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(transferRecentTable)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecentTable.userId, userId),
|
||||
eq(transferRecentTable.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecentTable.lastUsed))
|
||||
.limit(10);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
ctx.log.error(
|
||||
"Failed to fetch transfer recent destinations",
|
||||
err as Error,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch recent destinations" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/transfer-recent:
|
||||
* post:
|
||||
* summary: Save a recent transfer destination
|
||||
* tags:
|
||||
* - File Manager
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent destination saved.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to save recent destination.
|
||||
*/
|
||||
app.post("/transfer-recent", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!sourceHostId ||
|
||||
!destHostId ||
|
||||
!destPath
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select()
|
||||
.from(transferRecentTable)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecentTable.userId, userId),
|
||||
eq(transferRecentTable.sourceHostId, sourceHostId),
|
||||
eq(transferRecentTable.destHostId, destHostId),
|
||||
eq(transferRecentTable.destPath, destPath),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (existing[0]) {
|
||||
await drizzle
|
||||
.update(transferRecentTable)
|
||||
.set({
|
||||
destPathLabel: destPathLabel ?? existing[0].destPathLabel,
|
||||
lastUsed: now,
|
||||
})
|
||||
.where(eq(transferRecentTable.id, existing[0].id));
|
||||
} else {
|
||||
await drizzle.insert(transferRecentTable).values({
|
||||
userId,
|
||||
sourceHostId,
|
||||
destHostId,
|
||||
destPath,
|
||||
destPathLabel: destPathLabel ?? destPath,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep only the 10 most recent destinations per source host.
|
||||
const all = await drizzle
|
||||
.select()
|
||||
.from(transferRecentTable)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecentTable.userId, userId),
|
||||
eq(transferRecentTable.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecentTable.lastUsed));
|
||||
const stale = all.slice(10);
|
||||
for (const row of stale) {
|
||||
await drizzle
|
||||
.delete(transferRecentTable)
|
||||
.where(eq(transferRecentTable.id, row.id));
|
||||
}
|
||||
|
||||
await ctx.db.persist();
|
||||
res.json({ message: "Recent destination saved" });
|
||||
} catch (err) {
|
||||
ctx.log.error("Failed to save transfer recent destination", err as Error);
|
||||
res.status(500).json({ error: "Failed to save recent destination" });
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
export interface DirectTransferEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
|
||||
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
|
||||
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
|
||||
}
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function formatHost(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
export function buildDirectSshCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
return [
|
||||
"ssh",
|
||||
"-o BatchMode=yes",
|
||||
"-o ConnectTimeout=5",
|
||||
"-o StrictHostKeyChecking=yes",
|
||||
`-p ${endpoint.port}`,
|
||||
quoteShell(target),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildDirectProbeCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
): string {
|
||||
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
|
||||
}
|
||||
|
||||
export function buildDirectRsyncCommand(
|
||||
endpoint: DirectTransferEndpoint,
|
||||
sourcePaths: string[],
|
||||
destPath: string,
|
||||
destIsDirectory: boolean,
|
||||
): string {
|
||||
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
|
||||
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
|
||||
const targetPath = destIsDirectory
|
||||
? `${destPath.replace(/\/+$/, "") || "/"}/`
|
||||
: destPath;
|
||||
const sources = sourcePaths.map(quoteShell).join(" ");
|
||||
const target = quoteShell(`${targetHost}:${targetPath}`);
|
||||
|
||||
return [
|
||||
"rsync -a --partial --append-verify --protect-args --info=progress2",
|
||||
`-e ${quoteShell(sshTransport)}`,
|
||||
"--",
|
||||
sources,
|
||||
target,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function shouldUseDirectTransfer(
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
|
||||
): boolean {
|
||||
if (directMs <= 0 || relayMs <= 0) return false;
|
||||
return directMs <= relayMs * (1 - minImprovement);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { Express } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { getMimeType } from "./utils.js";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
ctx: PluginContext;
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
function escapeSingleQuotes(path: string): string {
|
||||
return path.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
function downloadViaExec(
|
||||
session: SSHSession,
|
||||
filePath: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(session, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
const chunks: Buffer[] = [];
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(stderr.trim() || `cat exited with code ${code}`),
|
||||
);
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerFileDownloadRoutes(
|
||||
app: Express,
|
||||
{
|
||||
ctx,
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileDownloadRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/downloadFile:
|
||||
* post:
|
||||
* summary: Download a file
|
||||
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file content.
|
||||
* 400:
|
||||
* description: Missing required parameters or file too large.
|
||||
* 500:
|
||||
* description: Failed to download file.
|
||||
*/
|
||||
app.post("/downloadFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, hostId } = req.body;
|
||||
const userId = ctx.currentActor()!;
|
||||
const downloadStartTime = Date.now();
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
ctx.log.warn(
|
||||
`Missing download parameters: ${sessionId} (hasFilePath=${!!filePath})`,
|
||||
);
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
ctx.log.info(`File download started: ${sessionId} (${filePath})`);
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
ctx.log.warn(
|
||||
`SSH session not found or not connected for download: ${sessionId} (isConnected=${sshConn?.isConnected})`,
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
ctx.log.info(
|
||||
`Downloading file via legacy exec/cat (SCP legacy mode): ${sessionId} (${filePath})`,
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await downloadViaExec(sshConn, filePath);
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
ctx.log.info(
|
||||
`File download completed (legacy mode): ${sessionId} (${filePath}, hostId=${hostId}, bytes=${data.length}, duration=${Date.now() - downloadStartTime}ms)`,
|
||||
);
|
||||
return res.json({
|
||||
content: data.toString("base64"),
|
||||
fileName,
|
||||
size: data.length,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.log.error("Legacy exec/cat download failed:", err as Error);
|
||||
return res.status(500).json({
|
||||
error: `Failed to download file: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
`Opening SFTP channel for downloadFile: ${sessionId} (${filePath})`,
|
||||
);
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
ctx.log.error("File stat failed for download:", statErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Cannot access file: ${statErr.message}` });
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
ctx.log.warn(
|
||||
`Attempted to download non-file: ${sessionId} (${filePath}, isFile=${stats.isFile()}, isDirectory=${stats.isDirectory()})`,
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot download directories or special files" });
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
ctx.log.warn(
|
||||
`File too large for download: ${sessionId} (${filePath}, fileSize=${stats.size}, maxSize=${MAX_FILE_SIZE})`,
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
|
||||
});
|
||||
}
|
||||
|
||||
sftp.readFile(filePath, (readErr, data) => {
|
||||
if (readErr) {
|
||||
ctx.log.error("File read failed for download:", readErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Failed to read file: ${readErr.message}` });
|
||||
}
|
||||
|
||||
const base64Content = data.toString("base64");
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
ctx.log.info(
|
||||
`File download completed: ${sessionId} (${filePath}, hostId=${hostId}, bytes=${stats.size}, duration=${Date.now() - downloadStartTime}ms)`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
content: base64Content,
|
||||
fileName: fileName,
|
||||
size: stats.size,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ctx.log.error("SFTP connection failed for download:", err);
|
||||
return res.status(500).json({ error: "SFTP connection failed" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/downloadFileStream:
|
||||
* post:
|
||||
* summary: Stream-download a file
|
||||
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Binary file stream.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Download failed.
|
||||
*/
|
||||
app.post("/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
ctx.log.info(
|
||||
`Streaming file via legacy exec/cat (SCP legacy mode): ${sessionId} (${filePath})`,
|
||||
);
|
||||
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${streamErr.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
(resolve, reject) => {
|
||||
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
},
|
||||
);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
readStream.pipe(res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import type { Express } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||
import {
|
||||
formatMtime,
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
parseLsDateToTimestamp,
|
||||
} from "./utils.js";
|
||||
|
||||
type FileListingRoutesDeps = {
|
||||
ctx: PluginContext;
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
activeListRequests: Record<string, boolean>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileListingRoutes(
|
||||
app: Express,
|
||||
{
|
||||
ctx,
|
||||
sshSessions,
|
||||
activeListRequests,
|
||||
verifySessionOwnership,
|
||||
}: FileListingRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/listFiles:
|
||||
* get:
|
||||
* summary: List files in a directory
|
||||
* description: Lists the files and directories in a given path on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of files and directories.
|
||||
* 400:
|
||||
* description: Session ID is required or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to list files.
|
||||
*/
|
||||
app.get("/listFiles", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = (req.query.path as string) || "/";
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
// Drop concurrent requests for the same session+path — each would open
|
||||
// a new SSH channel and can exceed the server's per-connection channel limit.
|
||||
const listKey = `${sessionId}:${sshPath}`;
|
||||
if (activeListRequests[listKey]) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "List request already in progress" });
|
||||
}
|
||||
activeListRequests[listKey] = true;
|
||||
res.on("finish", () => {
|
||||
delete activeListRequests[listKey];
|
||||
});
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
ctx.log.info(
|
||||
`Opening SFTP channel for listFiles: ${sessionId} (${sshPath})`,
|
||||
);
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.readdir(sshPath, (readdirErr, list) => {
|
||||
if (readdirErr) {
|
||||
ctx.log.warn(
|
||||
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
|
||||
const symlinks: Array<{ index: number; path: string }> = [];
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const entry of list) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
|
||||
const attrs = entry.attrs;
|
||||
const permissions = modeToPermissions(attrs.mode);
|
||||
const isDirectory = attrs.isDirectory();
|
||||
const isLink = attrs.isSymbolicLink();
|
||||
|
||||
const fileEntry = {
|
||||
name: entry.filename,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : attrs.size,
|
||||
modified: formatMtime(attrs.mtime),
|
||||
modifiedTimestamp: attrs.mtime,
|
||||
permissions,
|
||||
owner: String(attrs.uid),
|
||||
group: String(attrs.gid),
|
||||
linkTarget: undefined as string | undefined,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, entry.filename)
|
||||
: false,
|
||||
};
|
||||
|
||||
if (isLink) {
|
||||
symlinks.push({ index: files.length, path: fileEntry.path });
|
||||
}
|
||||
|
||||
files.push(fileEntry);
|
||||
}
|
||||
|
||||
if (symlinks.length === 0) {
|
||||
sshConn.activeOperations--;
|
||||
return res.json({ files, path: sshPath });
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let responded = false;
|
||||
|
||||
const sendResponse = () => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
sshConn.activeOperations--;
|
||||
res.json({ files, path: sshPath });
|
||||
};
|
||||
|
||||
const readlinkTimeout = setTimeout(sendResponse, 5000);
|
||||
|
||||
for (const link of symlinks) {
|
||||
sftp.readlink(link.path, (linkErr, target) => {
|
||||
resolved++;
|
||||
if (!linkErr && target) {
|
||||
files[link.index].linkTarget = target;
|
||||
}
|
||||
if (resolved === symlinks.length) {
|
||||
clearTimeout(readlinkTimeout);
|
||||
sendResponse();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
ctx.log.warn(
|
||||
`SFTP failed for listFiles, trying fallback: ${err.message}`,
|
||||
);
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
sshConn.isConnected = false;
|
||||
sshConn.sftp = undefined;
|
||||
}
|
||||
tryFallbackMethod();
|
||||
});
|
||||
} catch (sftpErr: unknown) {
|
||||
const errMsg = getErrorMessage(sftpErr);
|
||||
ctx.log.warn(`SFTP connection error, trying fallback: ${errMsg}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "SSH session disconnected", disconnected: true });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
execChannel(
|
||||
sshConn,
|
||||
`command ls -la --color=never '${escapedPath}'`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
ctx.log.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const isPermissionDenied =
|
||||
errorData.toLowerCase().includes("permission denied") ||
|
||||
errorData.toLowerCase().includes("access denied");
|
||||
|
||||
if (isPermissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
ctx.log.info(
|
||||
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
|
||||
);
|
||||
tryWithSudo();
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
ctx.log.warn(
|
||||
`Permission denied for listFiles, sudo required: ${sshPath}`,
|
||||
);
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot access ${sshPath}`,
|
||||
needsSudo: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
ctx.log.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Command failed: ${errorData}` });
|
||||
}
|
||||
sshConn.activeOperations--;
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
ctx.log.error(`Fallback listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tryWithSudo = () => {
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
|
||||
|
||||
execChannel(sshConn, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
ctx.log.error("SSH sudo listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
sshConn.activeOperations--;
|
||||
|
||||
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
|
||||
|
||||
if (
|
||||
data.toLowerCase().includes("sorry, try again") ||
|
||||
data.toLowerCase().includes("incorrect password") ||
|
||||
errorData.toLowerCase().includes("sorry, try again")
|
||||
) {
|
||||
sshConn.sudoPassword = undefined;
|
||||
return res.status(403).json({
|
||||
error: "Sudo authentication failed. Please try again.",
|
||||
needsSudo: true,
|
||||
sudoFailed: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (code !== 0 && !data.trim()) {
|
||||
ctx.log.error(
|
||||
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Sudo command failed: ${errorData || data}` });
|
||||
}
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
modifiedTimestamp: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
});
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg = getErrorMessage(execErr);
|
||||
ctx.log.error(`Sudo listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
trySFTP();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||
|
||||
export interface DeleteCommand {
|
||||
command: string;
|
||||
commandWithSuccess: string;
|
||||
}
|
||||
|
||||
function quotePosixPath(path: string): string {
|
||||
return `'${path.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function buildDeleteCommand(
|
||||
itemPath: string,
|
||||
isDirectory: boolean,
|
||||
): DeleteCommand {
|
||||
if (isWindowsSftpPath(itemPath)) {
|
||||
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
|
||||
const command = isDirectory
|
||||
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
|
||||
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
};
|
||||
}
|
||||
|
||||
const path = quotePosixPath(itemPath);
|
||||
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command} && echo "SUCCESS"`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
execChannel,
|
||||
execWithSudo,
|
||||
getSessionSftp,
|
||||
type SSHSession,
|
||||
} from "./session.js";
|
||||
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
|
||||
import { buildDeleteCommand } from "./operation-commands.js";
|
||||
import {
|
||||
emptyTrash,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "./trash-service.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
ctx: PluginContext;
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
const TRASH_RETENTION_DAYS_KEY = "trashRetentionDays";
|
||||
|
||||
export function registerFileOperationRoutes(
|
||||
app: Express,
|
||||
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
|
||||
): void {
|
||||
// NOTE: trash retention is an install-wide admin setting, not a per-host
|
||||
// access check, so it stays on PermissionManager.isAdmin rather than
|
||||
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const getTrashRetentionDays = async (): Promise<number> => {
|
||||
try {
|
||||
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
|
||||
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
};
|
||||
|
||||
async function ownedSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<SSHSession | null> {
|
||||
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
|
||||
const session = sshSessions[sessionId];
|
||||
if (!sessionId || !session?.isConnected) {
|
||||
res.status(400).json({ error: "SSH connection not established" });
|
||||
return null;
|
||||
}
|
||||
if (!verifySessionOwnership(session, ctx.currentActor()!)) {
|
||||
res.status(403).json({ error: "Session access denied" });
|
||||
return null;
|
||||
}
|
||||
session.lastActive = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
app.get("/trash", async (req, res) => {
|
||||
const session = await ownedSession(req, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
const retentionDays = await getTrashRetentionDays();
|
||||
res.json({
|
||||
items: await listTrash(await getSessionSftp(session), retentionDays),
|
||||
retentionDays,
|
||||
canManageRetention: await permissionManager.isAdmin(
|
||||
ctx.currentActor()!,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to list trash", error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/trash/:id/restore", async (req, res) => {
|
||||
const session = await ownedSession(req, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({
|
||||
item: await restoreTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
res
|
||||
.status(message.includes("already exists") ? 409 : 500)
|
||||
.json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/trash/:id", async (req, res) => {
|
||||
const session = await ownedSession(req, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
await permanentlyDeleteTrashItem(
|
||||
await getSessionSftp(session),
|
||||
req.params.id,
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/trash", async (req, res) => {
|
||||
const session = await ownedSession(req, res);
|
||||
if (!session) return;
|
||||
try {
|
||||
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/trash-retention", async (req, res) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const retentionDays = Number(req.body?.retentionDays);
|
||||
if (
|
||||
!Number.isInteger(retentionDays) ||
|
||||
retentionDays < 1 ||
|
||||
retentionDays > 3650
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Retention must be between 1 and 3650 days" });
|
||||
}
|
||||
await ctx.settings.set(TRASH_RETENTION_DAYS_KEY, String(retentionDays));
|
||||
return res.json({ retentionDays });
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/createFile:
|
||||
* post:
|
||||
* summary: Create a file
|
||||
* description: Creates an empty file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* fileName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create file.
|
||||
*/
|
||||
app.post("/createFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath || !fileName) {
|
||||
return res.status(400).json({ error: "File path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = filePath.endsWith("/")
|
||||
? filePath + fileName
|
||||
: filePath + "/" + fileName;
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
ctx.log.error("SSH createFile error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
ctx.log.error(`Permission denied creating file: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
ctx.log.error(
|
||||
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `File creation failed: ${errorData}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
ctx.log.error("SSH createFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/createFolder:
|
||||
* post:
|
||||
* summary: Create a folder
|
||||
* description: Creates a new folder on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* folderName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create folder.
|
||||
*/
|
||||
app.post("/createFolder", async (req, res) => {
|
||||
const { sessionId, path: folderPath, folderName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!folderPath || !folderName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Folder path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = folderPath.endsWith("/")
|
||||
? folderPath + folderName
|
||||
: folderPath + "/" + folderName;
|
||||
ctx.log.info(`Creating directory: ${sessionId} (${fullPath})`);
|
||||
try {
|
||||
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
|
||||
res.json({
|
||||
message: "Folder created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error("SFTP createFolder failed", error as Error);
|
||||
const code = (error as { code?: string | number }).code;
|
||||
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/deleteItem:
|
||||
* delete:
|
||||
* summary: Delete a file or directory
|
||||
* description: Deletes a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* isDirectory:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to delete item.
|
||||
*/
|
||||
app.delete("/deleteItem", async (req, res) => {
|
||||
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!itemPath) {
|
||||
return res.status(400).json({ error: "Item path is required" });
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
`Deleting item: ${sessionId} (${itemPath}, type=${isDirectory ? "directory" : "file"})`,
|
||||
);
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
if (!permanent) {
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const retentionDays = await getTrashRetentionDays();
|
||||
await listTrash(sftp, retentionDays);
|
||||
const item = await moveToTrash(sftp, itemPath);
|
||||
ctx.log.info(
|
||||
`Item moved to trash: ${sessionId} (${itemPath}, trashId=${item.id})`,
|
||||
);
|
||||
return res.json({
|
||||
message: "Item moved to trash",
|
||||
path: itemPath,
|
||||
trashItem: item,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
`Failed to move item to trash: ${sessionId} (${itemPath})`,
|
||||
error as Error,
|
||||
);
|
||||
return res.status(409).json({
|
||||
error: (error as Error).message,
|
||||
trashUnavailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
||||
itemPath,
|
||||
Boolean(isDirectory),
|
||||
);
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (useSudo && sshConn.sudoPassword) {
|
||||
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
|
||||
(result) => {
|
||||
if (
|
||||
result.code === 0 ||
|
||||
(!result.stderr.includes("Permission denied") &&
|
||||
!result.stdout.includes("Permission denied"))
|
||||
) {
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${result.stderr || result.stdout}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
||||
if (err) {
|
||||
ctx.log.error("SSH deleteItem error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
let permissionDenied = false;
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
permissionDenied = true;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (permissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
executeDelete(true).then(resolve);
|
||||
return;
|
||||
}
|
||||
ctx.log.error(`Permission denied deleting: ${itemPath}`);
|
||||
res.status(403).json({
|
||||
error: `Permission denied: Cannot delete ${itemPath}.`,
|
||||
needsSudo: true,
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
ctx.log.info(
|
||||
`Item deleted successfully: ${sessionId} (${itemPath})`,
|
||||
);
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const detail =
|
||||
errorData.trim() ||
|
||||
outputData.trim() ||
|
||||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
||||
ctx.log.error(
|
||||
`Delete failed for ${itemPath}: ${detail} (sessionId=${sessionId})`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${detail}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
ctx.log.error("SSH deleteItem stream error:", streamErr);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await executeDelete(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/renameItem:
|
||||
* put:
|
||||
* summary: Rename a file or directory
|
||||
* description: Renames a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item renamed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to rename item.
|
||||
*/
|
||||
app.put("/renameItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
|
||||
const newPath = oldDir + newName;
|
||||
ctx.log.info(`Renaming item: ${sessionId} (${oldPath} -> ${newPath})`);
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
ctx.log.error("SSH renameItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
ctx.log.error(`Permission denied renaming: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
ctx.log.info(
|
||||
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
ctx.log.error(
|
||||
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Rename failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
ctx.log.error("SSH renameItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/file-manager/moveItem:
|
||||
* put:
|
||||
* summary: Move a file or directory
|
||||
* description: Moves a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item moved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 408:
|
||||
* description: Move operation timed out.
|
||||
* 500:
|
||||
* description: Failed to move item.
|
||||
*/
|
||||
app.put("/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new path are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
error: "Move operation timed out. SSH connection may be unstable.",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Move operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error("SSH moveItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
ctx.log.error(`Permission denied moving: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
ctx.log.error(
|
||||
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Move failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
ctx.log.error("SSH moveItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginContext | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginCtx(ctx: PluginContext | null): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function pluginCtx(): PluginContext {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
promisifySftpMkdir,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
promisifySftpRmdir,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
const DEFAULT_DIR_MODE = 0o755;
|
||||
import type { SSHSession } from "./session.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("files.sftp", {userId}). */
|
||||
export interface FilesService {
|
||||
list: (
|
||||
hostId: number,
|
||||
path: string,
|
||||
) => Promise<
|
||||
Array<{ name: string; type: "file" | "directory" | "link"; size?: number }>
|
||||
>;
|
||||
read: (hostId: number, path: string) => Promise<string>;
|
||||
write: (hostId: number, path: string, content: string) => Promise<void>;
|
||||
stat: (
|
||||
hostId: number,
|
||||
path: string,
|
||||
) => Promise<{ size: number; isDirectory: boolean; mtime: number }>;
|
||||
delete: (hostId: number, path: string) => Promise<void>;
|
||||
mkdir: (hostId: number, path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short-lived SFTP connection for a service call, outside the browse
|
||||
* session map: a caller reaches this from any request the acting user makes,
|
||||
* not necessarily one that has an open file-manager tab.
|
||||
*/
|
||||
async function withServiceSftp<T>(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
fn: (sftp: import("ssh2").SFTPWrapper) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return ctx.ssh.withConnection<T, import("ssh2").Client>(
|
||||
hostId,
|
||||
{ pool: "file-manager-service", purpose: "file-manager" },
|
||||
(client) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
client.sftp(async (err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
try {
|
||||
resolve(await fn(sftp));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createFilesService(
|
||||
ctx: PluginContext,
|
||||
deps: {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
},
|
||||
): FilesService {
|
||||
void deps;
|
||||
return {
|
||||
async list(hostId, path) {
|
||||
return withServiceSftp(ctx, hostId, async (sftp) => {
|
||||
const entries = await promisifySftpReaddir(sftp, path);
|
||||
return entries
|
||||
.filter((e) => e.filename !== "." && e.filename !== "..")
|
||||
.map((e) => ({
|
||||
name: e.filename,
|
||||
type: e.attrs.isDirectory()
|
||||
? ("directory" as const)
|
||||
: e.attrs.isSymbolicLink()
|
||||
? ("link" as const)
|
||||
: ("file" as const),
|
||||
size: e.attrs.isDirectory() ? undefined : e.attrs.size,
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
async read(hostId, path) {
|
||||
return withServiceSftp(
|
||||
ctx,
|
||||
hostId,
|
||||
(sftp) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const stream = sftp.createReadStream(path);
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("end", () =>
|
||||
resolve(Buffer.concat(chunks).toString("utf8")),
|
||||
);
|
||||
stream.on("error", reject);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async write(hostId, path, content) {
|
||||
await withServiceSftp(
|
||||
ctx,
|
||||
hostId,
|
||||
(sftp) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(path, { flags: "w" });
|
||||
stream.on("close", () => resolve());
|
||||
stream.on("error", reject);
|
||||
stream.end(content, "utf8");
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async stat(hostId, path) {
|
||||
return withServiceSftp(ctx, hostId, async (sftp) => {
|
||||
const stats = await promisifySftpStat(sftp, path);
|
||||
return {
|
||||
size: stats.size,
|
||||
isDirectory: stats.isDirectory(),
|
||||
mtime: stats.mtime,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async delete(hostId, path) {
|
||||
await withServiceSftp(ctx, hostId, async (sftp) => {
|
||||
const stats = await promisifySftpStat(sftp, path);
|
||||
if (stats.isDirectory()) {
|
||||
await promisifySftpRmdir(sftp, path);
|
||||
} else {
|
||||
await promisifySftpUnlink(sftp, path);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async mkdir(hostId, path) {
|
||||
await withServiceSftp(ctx, hostId, (sftp) =>
|
||||
promisifySftpMkdir(sftp, path, DEFAULT_DIR_MODE),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
|
||||
// Serializes SSH channel open requests so only one channel negotiation is
|
||||
// in-flight at a time per session. Once the channel is established the slot
|
||||
// is released immediately so the next open can proceed; the channels
|
||||
// themselves remain open concurrently.
|
||||
export class ChannelOpenSerializer {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(action: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(
|
||||
() => action(),
|
||||
() => action(),
|
||||
);
|
||||
this.tail = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
|
||||
channelOpener: ChannelOpenSerializer;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
scpLegacy?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
}
|
||||
|
||||
export function execWithSudo(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
}));
|
||||
}
|
||||
|
||||
export function execWithSudoBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
|
||||
|
||||
execChannel(session, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (settled) return;
|
||||
let stdout = Buffer.concat(stdoutChunks);
|
||||
const sudoPromptMatch = stdout
|
||||
.toString("utf8", 0, Math.min(stdout.length, 256))
|
||||
.match(/^\[sudo\] password for .+?:\s*/);
|
||||
if (sudoPromptMatch) {
|
||||
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
|
||||
}
|
||||
settled = true;
|
||||
resolve({ stdout, stderr, code: code || 0 });
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
|
||||
stream.write(`${sudoPassword}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function execBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
maxStdoutBytes?: number,
|
||||
): Promise<{
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
code: number;
|
||||
exceededLimit?: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
execChannel(session, command, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
code: number,
|
||||
extra: { exceededLimit?: boolean } = {},
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr,
|
||||
code,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
|
||||
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
|
||||
finish(1, { exceededLimit: true });
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => finish(code || 0));
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
stderr = stderr || streamErr.message;
|
||||
finish(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionSftp(
|
||||
session: SSHSession,
|
||||
): Promise<import("ssh2").SFTPWrapper> {
|
||||
if (session.sftp) {
|
||||
return Promise.resolve(session.sftp);
|
||||
}
|
||||
|
||||
if (session.sftpPending) {
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
|
||||
session.channelOpener.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
|
||||
session.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
session.sftp = sftp;
|
||||
sftp.on("error", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
sftp.on("close", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
resolve(sftp);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
session.sftpPending = openOnce()
|
||||
.catch((err: Error) => {
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
|
||||
setTimeout(() => openOnce().then(resolve, reject), 500),
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
session.sftpPending = undefined;
|
||||
});
|
||||
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
export function execChannel(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
stream: import("ssh2").ClientChannel,
|
||||
) => void,
|
||||
): void {
|
||||
session.channelOpener
|
||||
.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(
|
||||
(stream) => callback(undefined, stream),
|
||||
(err: Error) => callback(err, undefined as never),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export const SFTP_OPEN_READ = 0x00000001;
|
||||
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||
|
||||
export function promisifySftpStat(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpUnlink(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpRmdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpMkdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(path, { mode }, (err) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpChmod(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.chmod(path, mode, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpReaddir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.readdir(path, (err, list) => {
|
||||
if (err) reject(err);
|
||||
else resolve(list);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpOpen(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
flags: number,
|
||||
mode: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.open(path, flags, mode, (err, handle) => {
|
||||
if (err) reject(err);
|
||||
else resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpClose(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.close(handle, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpFstat(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.fstat(handle, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshAuthOutcome,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level primitives for the interactive /connect route, which drives its
|
||||
* own ssh2 Client through the TOTP/Warpgate parking flow rather than letting
|
||||
* ctx.ssh.connect finish the handshake itself.
|
||||
*/
|
||||
export function prepareSsh(
|
||||
host: PluginSshHost,
|
||||
options: {
|
||||
purpose?: "file-manager" | "file-transfer";
|
||||
client: Client;
|
||||
serverHostId?: number;
|
||||
log?: (level: "info" | "warning" | "error", message: string) => void;
|
||||
},
|
||||
): Promise<{ config: ConnectConfig; outcome: PluginSshAuthOutcome }> {
|
||||
return pluginSsh().prepare(host, options) as Promise<{
|
||||
config: ConnectConfig;
|
||||
outcome: PluginSshAuthOutcome;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function openSshTransport(
|
||||
host: PluginSshHost,
|
||||
config: ConnectConfig,
|
||||
): Promise<{ jumpClient: Client | null; via: string }> {
|
||||
return pluginSsh().openTransport(
|
||||
host,
|
||||
config as unknown as Record<string, unknown>,
|
||||
) as Promise<{
|
||||
jumpClient: Client | null;
|
||||
via: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const classifyKeyboardInteractive: PluginSsh["classifyKeyboardInteractive"] =
|
||||
(round, host) => pluginSsh().classifyKeyboardInteractive(round, host);
|
||||
|
||||
export const autoResponses: PluginSsh["autoResponses"] = (prompts, password) =>
|
||||
pluginSsh().autoResponses(prompts, password);
|
||||
|
||||
export const requiresSecret: PluginSsh["requiresSecret"] = (authType) =>
|
||||
pluginSsh().requiresSecret(authType);
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* The paths and hosts a user recently opened in the file manager.
|
||||
*
|
||||
* Adopted from core's file_manager_recent, so the column and index names are
|
||||
* the legacy ones: the rename carries the existing rows and index across.
|
||||
*/
|
||||
export const recent = adoptLegacyTable(
|
||||
"file_manager_recent",
|
||||
defineTable(
|
||||
"recent",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
name: text().notNull(),
|
||||
path: text().notNull(),
|
||||
lastOpened: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_file_manager_recent_user", columns: ["userId", "hostId"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Paths a user pinned for quick access on a host. */
|
||||
export const pinned = adoptLegacyTable(
|
||||
"file_manager_pinned",
|
||||
defineTable(
|
||||
"pinned",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
name: text().notNull(),
|
||||
path: text().notNull(),
|
||||
pinnedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_file_manager_pinned_user",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Named folder shortcuts a user saved on a host. */
|
||||
export const shortcuts = adoptLegacyTable(
|
||||
"file_manager_shortcuts",
|
||||
defineTable(
|
||||
"shortcuts",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
name: text().notNull(),
|
||||
path: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_file_manager_shortcuts_user",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Recently used destinations for host-to-host transfers. */
|
||||
export const transferRecent = adoptLegacyTable(
|
||||
"transfer_recent",
|
||||
defineTable(
|
||||
"transfer_recent",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
sourceHostId: refHost(),
|
||||
destHostId: refHost(),
|
||||
destPath: text().notNull(),
|
||||
destPathLabel: text().notNull(),
|
||||
lastUsed: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_transfer_recent_user", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [recent, pinned, shortcuts, transferRecent];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
export class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferStalledError extends Error {
|
||||
readonly byteOffset?: number;
|
||||
readonly segmentIndex?: number;
|
||||
|
||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||
this.name = "TransferStalledError";
|
||||
this.byteOffset = byteOffset;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferConnectionLostError extends Error {
|
||||
constructor(message = "Transfer SSH connection lost") {
|
||||
super(message);
|
||||
this.name = "TransferConnectionLostError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("no response from server") ||
|
||||
msg.includes("connection lost") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("etimedout") ||
|
||||
msg.includes("socket hang up") ||
|
||||
msg.includes("protocol error") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("channel open failure")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableTransferError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof TransferStalledError ||
|
||||
err instanceof TransferConnectionLostError ||
|
||||
isRecoverableTransferConnectionError(err)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { networkInterfaces } from "os";
|
||||
import { normalizeSftpPath } from "./transfer-paths.js";
|
||||
|
||||
let cachedLocalAddresses: Set<string> | null = null;
|
||||
|
||||
export function normalizeHostAddress(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed.split(":")[0] ?? trimmed;
|
||||
}
|
||||
|
||||
function getLocalAddresses(): Set<string> {
|
||||
if (cachedLocalAddresses) return cachedLocalAddresses;
|
||||
|
||||
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
if (!ifaces) continue;
|
||||
for (const iface of ifaces) {
|
||||
if (!iface.internal && iface.family === "IPv4") {
|
||||
addresses.add(iface.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedLocalAddresses = addresses;
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function isLocalSshEndpoint(ip?: string): boolean {
|
||||
if (!ip) return false;
|
||||
const bare = normalizeHostAddress(ip);
|
||||
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
||||
return true;
|
||||
}
|
||||
return getLocalAddresses().has(bare);
|
||||
}
|
||||
|
||||
export function escapeShell(s: string): string {
|
||||
return s.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
export function isRootOnlyPath(path: string): boolean {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
return (
|
||||
normalized === "/" ||
|
||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||
/^[A-Za-z]:$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPermissionError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("eacces") ||
|
||||
msg.includes("access denied")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface TransferIntegrityResult {
|
||||
algorithm: "sha256";
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export async function hashSftpFile(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<string> {
|
||||
const stream = sftp.createReadStream(path);
|
||||
const hash = createHash("sha256");
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (shouldAbort()) {
|
||||
const error = createAbortError();
|
||||
stream.destroy(error);
|
||||
throw error;
|
||||
}
|
||||
hash.update(chunk);
|
||||
}
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (shouldAbort()) throw createAbortError();
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export async function verifySftpFileIntegrity(
|
||||
sourceSftp: SFTPWrapper,
|
||||
destSftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destPath: string,
|
||||
shouldAbort: () => boolean = () => false,
|
||||
createAbortError: () => Error = () => new Error("Transfer cancelled"),
|
||||
): Promise<TransferIntegrityResult> {
|
||||
const [sourceDigest, destDigest] = await Promise.all([
|
||||
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
|
||||
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
|
||||
]);
|
||||
|
||||
if (sourceDigest !== destDigest) {
|
||||
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
|
||||
}
|
||||
|
||||
return { algorithm: "sha256", digest: sourceDigest };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export type TransferPlatform = "windows" | "unix";
|
||||
|
||||
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
|
||||
export function isWindowsSftpPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function inferPlatformFromPath(path: string): TransferPlatform | null {
|
||||
if (isWindowsSftpPath(path)) return "windows";
|
||||
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
|
||||
return "unix";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSftpPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return path.startsWith("/") ? "/" : ".";
|
||||
return normalized.replace(/([^:])\/+/g, "$1/");
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
return normalized.substring(idx + 1) || normalized;
|
||||
}
|
||||
|
||||
export function dirname(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return normalized;
|
||||
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
|
||||
const parent = normalized.substring(0, idx);
|
||||
if (!parent) return "/";
|
||||
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
|
||||
return parent;
|
||||
}
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function joinPath(base: string, ...parts: string[]): string {
|
||||
let result = base.replace(/\\/g, "/");
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!segment) continue;
|
||||
if (result.endsWith("/")) {
|
||||
result += segment;
|
||||
} else {
|
||||
result = `${result}/${segment}`;
|
||||
}
|
||||
}
|
||||
return normalizeSftpPath(result);
|
||||
}
|
||||
|
||||
export interface PathSegments {
|
||||
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
|
||||
root: string;
|
||||
/** Path segments below the root (may be empty). */
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
export function splitPathSegments(path: string): PathSegments {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
|
||||
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (posixDrive) {
|
||||
return {
|
||||
root: posixDrive[1],
|
||||
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (drive) {
|
||||
return {
|
||||
root: drive[1],
|
||||
segments: (drive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.startsWith("/")) {
|
||||
return {
|
||||
root: "/",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: "",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPathFromSegments(
|
||||
root: string,
|
||||
segments: string[],
|
||||
count: number,
|
||||
): string {
|
||||
const slice = segments.slice(0, count);
|
||||
if (/^\/[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (/^[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (root === "/") {
|
||||
return slice.length ? `/${slice.join("/")}` : "/";
|
||||
}
|
||||
if (root === "") {
|
||||
return slice.join("/");
|
||||
}
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
|
||||
export function pathsOverlap(source: string, dest: string): boolean {
|
||||
const norm = (p: string) => {
|
||||
const n = normalizeSftpPath(p).toLowerCase();
|
||||
return n || (isWindowsSftpPath(p) ? p : "/");
|
||||
};
|
||||
const s = norm(source);
|
||||
const d = norm(dest);
|
||||
const sep = "/";
|
||||
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
|
||||
}
|
||||
|
||||
export function getWorkingDir(paths: string[]): string {
|
||||
if (paths.length === 0) return "/";
|
||||
const parents = paths.map((p) => dirname(p));
|
||||
const first = parents[0];
|
||||
if (parents.every((p) => p === first)) {
|
||||
return first;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
|
||||
export function sftpPathToLocalPath(sftpPath: string): string {
|
||||
const normalized = sftpPath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.slice(1).replace(/\//g, "\\");
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.replace(/\//g, "\\");
|
||||
}
|
||||
return sftpPath;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { TransferPlatform } from "./transfer-paths.js";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
||||
|
||||
export interface TransferScanSummary {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
largestFileBytes: number;
|
||||
/** Share of total bytes in likely incompressible file types (0–1). */
|
||||
incompressibleRatio: number;
|
||||
/** Share inferred from bounded content samples, when available. */
|
||||
sampledIncompressibleRatio?: number;
|
||||
}
|
||||
|
||||
const INCOMPRESSIBLE_EXT =
|
||||
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export function isLikelyIncompressiblePath(path: string): boolean {
|
||||
return INCOMPRESSIBLE_EXT.test(path);
|
||||
}
|
||||
|
||||
export function buildTransferScanSummary(
|
||||
items: Array<{ sourcePath: string; size: number }>,
|
||||
): TransferScanSummary {
|
||||
let totalBytes = 0;
|
||||
let largestFileBytes = 0;
|
||||
let incompressibleBytes = 0;
|
||||
|
||||
for (const item of items) {
|
||||
totalBytes += item.size;
|
||||
largestFileBytes = Math.max(largestFileBytes, item.size);
|
||||
if (isLikelyIncompressiblePath(item.sourcePath)) {
|
||||
incompressibleBytes += item.size;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
largestFileBytes,
|
||||
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateIncompressibleSample(data: Buffer): boolean {
|
||||
if (data.length === 0) return false;
|
||||
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
|
||||
}
|
||||
|
||||
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
|
||||
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose tar vs per-item SFTP for directory / multi-file transfers.
|
||||
* Single-file stream transfers bypass this entirely.
|
||||
*/
|
||||
export function resolveArchiveTransferMethod(
|
||||
preference: TransferMethodPreference,
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): "tar" | "item_sftp" {
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "item_sftp") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
|
||||
}
|
||||
|
||||
// auto
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (fileCount === 0) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
// Multi-item sets with a large file: tar only when data is likely compressible
|
||||
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount >= 100) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount > 5 && incompressibleRatio < 0.7) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
export type ArchiveTransferReasonKey =
|
||||
| "user_item_sftp"
|
||||
| "user_tar"
|
||||
| "tar_unavailable"
|
||||
| "windows_host"
|
||||
| "auto_multi_large"
|
||||
| "auto_single_large_in_archive"
|
||||
| "auto_many_incompressible"
|
||||
| "auto_many_files"
|
||||
| "auto_default";
|
||||
|
||||
/** i18n key suffix under transfer.methodReason.* */
|
||||
export function getArchiveTransferReasonKey(
|
||||
preference: TransferMethodPreference,
|
||||
resolvedMethod: "tar" | "item_sftp",
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): ArchiveTransferReasonKey {
|
||||
if (preference === "item_sftp") return "user_item_sftp";
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
|
||||
}
|
||||
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "windows_host";
|
||||
}
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "tar_unavailable";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes } = summary;
|
||||
const incompressibleRatio = effectiveIncompressibleRatio(summary);
|
||||
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "auto_multi_large";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "auto_single_large_in_archive";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
|
||||
if (
|
||||
fileCount >= 100 ||
|
||||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
|
||||
(fileCount > 5 && incompressibleRatio < 0.7)
|
||||
) {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
if (resolvedMethod === "tar") {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
return "auto_default";
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { basename, joinPath } from "./transfer-paths.js";
|
||||
import {
|
||||
SFTP_OPEN_READ,
|
||||
promisifySftpClose,
|
||||
promisifySftpOpen,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpStat,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface FileWorkItem {
|
||||
sourcePath: string;
|
||||
destPath: string;
|
||||
mode: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function collectFileWorkItems(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destRoot: string,
|
||||
destBaseName?: string,
|
||||
): Promise<FileWorkItem[]> {
|
||||
const stats = await promisifySftpStat(sftp, sourcePath);
|
||||
const name = destBaseName ?? basename(sourcePath);
|
||||
const destPath = joinPath(destRoot, name);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return [
|
||||
{
|
||||
sourcePath,
|
||||
destPath,
|
||||
mode: stats.mode & 0o7777,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: FileWorkItem[] = [];
|
||||
const walk = async (srcDir: string, dstDir: string) => {
|
||||
const entries = await promisifySftpReaddir(sftp, srcDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
const srcChild = joinPath(srcDir, entry.filename);
|
||||
const dstChild = joinPath(dstDir, entry.filename);
|
||||
|
||||
if (entry.attrs.isDirectory()) {
|
||||
await walk(srcChild, dstChild);
|
||||
} else if (entry.attrs.isFile()) {
|
||||
items.push({
|
||||
sourcePath: srcChild,
|
||||
destPath: dstChild,
|
||||
mode: entry.attrs.mode & 0o7777,
|
||||
size: entry.attrs.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(sourcePath, destPath);
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function readSftpSample(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
fileSize: number,
|
||||
): Promise<Buffer> {
|
||||
const sampleSize = Math.min(64 * 1024, fileSize);
|
||||
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||
try {
|
||||
const buffer = Buffer.alloc(sampleSize);
|
||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
||||
if (err) reject(err);
|
||||
else resolve(count);
|
||||
});
|
||||
});
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await promisifySftpClose(sftp, handle).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||
|
||||
export interface SegmentCopyJob {
|
||||
offset: number;
|
||||
length: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
export function clampParallelSegmentCount(value?: number): number {
|
||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function buildSegmentCopyJobs(
|
||||
fileSize: number,
|
||||
initialOffset: number,
|
||||
destResumeSize: number,
|
||||
): SegmentCopyJob[] {
|
||||
const jobs: SegmentCopyJob[] = [];
|
||||
for (
|
||||
let offset = initialOffset;
|
||||
offset < fileSize;
|
||||
offset += SFTP_XFER_SEGMENT_SIZE
|
||||
) {
|
||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||
if (destResumeSize >= offset + length) {
|
||||
continue;
|
||||
}
|
||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||
jobs.push({
|
||||
offset: start,
|
||||
length: offset + length - start,
|
||||
segmentIndex,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
buildPathFromSegments,
|
||||
joinPath,
|
||||
normalizeSftpPath,
|
||||
splitPathSegments,
|
||||
} from "./transfer-paths.js";
|
||||
import { isRootOnlyPath } from "./transfer-host-utils.js";
|
||||
import {
|
||||
promisifySftpMkdir,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpRmdir,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export async function ensureDirectoryTreeSftp(
|
||||
sftp: SFTPWrapper,
|
||||
dirPath: string,
|
||||
created: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
const normalized = normalizeSftpPath(dirPath);
|
||||
if (!normalized || isRootOnlyPath(normalized)) return;
|
||||
|
||||
const { root, segments } = splitPathSegments(normalized);
|
||||
if (segments.length === 0) return;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const current = buildPathFromSegments(root, segments, i + 1);
|
||||
if (created.has(current)) continue;
|
||||
|
||||
try {
|
||||
await promisifySftpMkdir(sftp, current, 0o755);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
try {
|
||||
const stats = await promisifySftpStat(sftp, current);
|
||||
if (!stats.isDirectory()) throw err;
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
created.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePathSftp(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
let stats: import("ssh2").Stats;
|
||||
try {
|
||||
stats = await promisifySftpStat(sftp, path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await promisifySftpReaddir(sftp, path);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
||||
}
|
||||
await promisifySftpRmdir(sftp, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await promisifySftpUnlink(sftp, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||
|
||||
export type TransferHopId =
|
||||
"source_read" | "dest_sftp_write" | "dest_local_write";
|
||||
|
||||
export interface TransferHopMetrics {
|
||||
id: TransferHopId;
|
||||
bytes: number;
|
||||
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
||||
spanMs: number;
|
||||
mbPerSec: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
prepareDestMs?: number;
|
||||
compressMs?: number;
|
||||
transferMs?: number;
|
||||
extractMs?: number;
|
||||
verifyMs?: number;
|
||||
directBenchmarkMs?: number;
|
||||
relayBenchmarkMs?: number;
|
||||
sourceDeleteMs?: number;
|
||||
totalMs?: number;
|
||||
transferBytes?: number;
|
||||
endToEndMbPerSec?: number;
|
||||
hops?: TransferHopMetrics[];
|
||||
}
|
||||
|
||||
export function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
export function computeTransferMbPerSec(
|
||||
bytes: number,
|
||||
ms: number,
|
||||
): number | undefined {
|
||||
if (ms <= 0 || bytes <= 0) return undefined;
|
||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||
}
|
||||
|
||||
export interface HopWallClock {
|
||||
firstAt: number | null;
|
||||
lastAt: number | null;
|
||||
}
|
||||
|
||||
export function createHopWallClock(): HopWallClock {
|
||||
return { firstAt: null, lastAt: null };
|
||||
}
|
||||
|
||||
export function noteHopStart(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
if (clock.firstAt === null) clock.firstAt = t;
|
||||
}
|
||||
|
||||
export function noteHopEnd(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
clock.lastAt = t;
|
||||
}
|
||||
|
||||
export function hopSpanMs(clock: HopWallClock): number {
|
||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||
}
|
||||
|
||||
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||
let pending = 0;
|
||||
let lastFlush = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (pending > 0) {
|
||||
onProgress?.(pending);
|
||||
pending = 0;
|
||||
lastFlush = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
add(bytes: number) {
|
||||
pending += bytes;
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelinedXferStats {
|
||||
bytes: number;
|
||||
sourceReadSpanMs: number;
|
||||
destWriteSpanMs: number;
|
||||
destWriteKind: "sftp" | "local";
|
||||
}
|
||||
|
||||
export function createEmptyXferStats(): PipelinedXferStats {
|
||||
return {
|
||||
bytes: 0,
|
||||
sourceReadSpanMs: 0,
|
||||
destWriteSpanMs: 0,
|
||||
destWriteKind: "sftp",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeXferStats(
|
||||
target: PipelinedXferStats,
|
||||
source: PipelinedXferStats,
|
||||
): void {
|
||||
target.bytes += source.bytes;
|
||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||
target.destWriteKind = source.destWriteKind;
|
||||
}
|
||||
|
||||
export function buildTransferHopTimings(
|
||||
stats: PipelinedXferStats,
|
||||
transferMs: number,
|
||||
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
||||
const hops: TransferHopMetrics[] = [];
|
||||
|
||||
const sourceRate = computeTransferMbPerSec(
|
||||
stats.bytes,
|
||||
stats.sourceReadSpanMs,
|
||||
);
|
||||
if (sourceRate !== undefined) {
|
||||
hops.push({
|
||||
id: "source_read",
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.sourceReadSpanMs,
|
||||
mbPerSec: sourceRate,
|
||||
});
|
||||
}
|
||||
|
||||
const destHopId: TransferHopId =
|
||||
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
||||
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
||||
if (destRate !== undefined) {
|
||||
hops.push({
|
||||
id: destHopId,
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.destWriteSpanMs,
|
||||
mbPerSec: destRate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
transferBytes: stats.bytes,
|
||||
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
||||
hops,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// TODO(file-manager): move this to ctx.kv, see FINISH-LIST.md
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface TransferPerformanceProfile {
|
||||
throughputBps: number;
|
||||
failureRate: number;
|
||||
samples: number;
|
||||
preferredLanes: number;
|
||||
pipelineConcurrency: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TransferTuning {
|
||||
parallelSegmentCount: number;
|
||||
pipelineConcurrency: number;
|
||||
}
|
||||
|
||||
export interface DirectRouteProfile {
|
||||
directMs: number;
|
||||
relayMs: number;
|
||||
failureRate: number;
|
||||
benchmarkSamples: number;
|
||||
outcomeSamples: number;
|
||||
benchmarkedAt: number;
|
||||
cooldownUntil?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PROFILES = 128;
|
||||
const STORE_VERSION = 1;
|
||||
const STORE_FILENAME = "adaptive-transfer-profiles.json";
|
||||
const profiles = new Map<string, TransferPerformanceProfile>();
|
||||
const directRoutes = new Map<string, DirectRouteProfile>();
|
||||
let loadedPath: string | undefined;
|
||||
let loadPromise: Promise<void> | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistPromise = Promise.resolve();
|
||||
|
||||
interface PersistedProfiles {
|
||||
version: typeof STORE_VERSION;
|
||||
profiles: Record<string, TransferPerformanceProfile>;
|
||||
directRoutes: Record<string, DirectRouteProfile>;
|
||||
}
|
||||
|
||||
function validDirectRoute(value: unknown): value is DirectRouteProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<DirectRouteProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.directMs) &&
|
||||
Number(profile.directMs) > 0 &&
|
||||
Number.isFinite(profile.relayMs) &&
|
||||
Number(profile.relayMs) > 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.benchmarkSamples) &&
|
||||
Number(profile.benchmarkSamples) > 0 &&
|
||||
Number.isFinite(profile.outcomeSamples) &&
|
||||
Number(profile.outcomeSamples) >= 0 &&
|
||||
Number.isFinite(profile.benchmarkedAt) &&
|
||||
(profile.cooldownUntil === undefined ||
|
||||
Number.isFinite(profile.cooldownUntil)) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, STORE_FILENAME);
|
||||
}
|
||||
|
||||
function profileId(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function validProfile(value: unknown): value is TransferPerformanceProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<TransferPerformanceProfile>;
|
||||
return (
|
||||
Number.isFinite(profile.throughputBps) &&
|
||||
Number(profile.throughputBps) >= 0 &&
|
||||
Number.isFinite(profile.failureRate) &&
|
||||
Number(profile.failureRate) >= 0 &&
|
||||
Number(profile.failureRate) <= 1 &&
|
||||
Number.isFinite(profile.samples) &&
|
||||
Number(profile.samples) > 0 &&
|
||||
Number.isFinite(profile.preferredLanes) &&
|
||||
Number.isFinite(profile.pipelineConcurrency) &&
|
||||
Number.isFinite(profile.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function trimProfiles(now = Date.now()): void {
|
||||
for (const [key, profile] of profiles) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
|
||||
}
|
||||
const recent = [...profiles.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
profiles.clear();
|
||||
for (const [key, profile] of recent) profiles.set(key, profile);
|
||||
|
||||
for (const [key, profile] of directRoutes) {
|
||||
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
|
||||
}
|
||||
const recentRoutes = [...directRoutes.entries()]
|
||||
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_PROFILES);
|
||||
directRoutes.clear();
|
||||
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
|
||||
}
|
||||
|
||||
async function persistProfiles(): Promise<void> {
|
||||
trimProfiles();
|
||||
const target = storePath();
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
const payload: PersistedProfiles = {
|
||||
version: STORE_VERSION,
|
||||
profiles: Object.fromEntries(profiles),
|
||||
directRoutes: Object.fromEntries(directRoutes),
|
||||
};
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(temporary, JSON.stringify(payload), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(temporary, target);
|
||||
} catch {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function queuePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = undefined;
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
}, 250);
|
||||
persistTimer.unref?.();
|
||||
}
|
||||
|
||||
export async function initializeTransferProfiles(): Promise<void> {
|
||||
const target = storePath();
|
||||
if (loadedPath === target) return loadPromise;
|
||||
loadedPath = target;
|
||||
loadPromise = (async () => {
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(target, "utf8"),
|
||||
) as Partial<PersistedProfiles>;
|
||||
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
||||
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
||||
profiles.set(key, profile);
|
||||
}
|
||||
}
|
||||
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
|
||||
directRoutes.set(key, profile);
|
||||
}
|
||||
}
|
||||
trimProfiles();
|
||||
} catch {
|
||||
// Missing or malformed local learning data must not affect transfers.
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export async function flushTransferProfiles(): Promise<void> {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
}
|
||||
persistPromise = persistPromise.then(persistProfiles);
|
||||
await persistPromise;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function selectTransferTuning(
|
||||
fileSize: number,
|
||||
profile?: TransferPerformanceProfile,
|
||||
requestedLanes?: number,
|
||||
): TransferTuning {
|
||||
if (fileSize < 32 * MB) {
|
||||
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
|
||||
}
|
||||
|
||||
let lanes = fileSize >= 1024 * MB ? 4 : 2;
|
||||
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
|
||||
|
||||
if (profile) {
|
||||
lanes = profile.preferredLanes;
|
||||
pipelineConcurrency = profile.pipelineConcurrency;
|
||||
if (profile.failureRate >= 0.2) {
|
||||
lanes = Math.min(lanes, 2);
|
||||
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedLanes !== undefined) lanes = requestedLanes;
|
||||
|
||||
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
|
||||
return {
|
||||
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
|
||||
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTransferProfile(
|
||||
previous: TransferPerformanceProfile | undefined,
|
||||
observation: {
|
||||
bytes: number;
|
||||
durationMs: number;
|
||||
lanes: number;
|
||||
pipelineConcurrency: number;
|
||||
failed: boolean;
|
||||
now?: number;
|
||||
},
|
||||
): TransferPerformanceProfile {
|
||||
const now = observation.now ?? Date.now();
|
||||
const throughputBps =
|
||||
observation.durationMs > 0
|
||||
? (observation.bytes * 1000) / observation.durationMs
|
||||
: 0;
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const smoothedThroughput = previous
|
||||
? previous.throughputBps * (1 - weight) + throughputBps * weight
|
||||
: throughputBps;
|
||||
const failure = observation.failed ? 1 : 0;
|
||||
const failureRate = previous
|
||||
? previous.failureRate * (1 - weight) + failure * weight
|
||||
: failure;
|
||||
|
||||
let preferredLanes = observation.lanes;
|
||||
let pipelineConcurrency = observation.pipelineConcurrency;
|
||||
if (failureRate >= 0.2) {
|
||||
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
|
||||
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
|
||||
} else if (
|
||||
!observation.failed &&
|
||||
previous &&
|
||||
previous.samples >= 2 &&
|
||||
throughputBps > previous.throughputBps * 1.25
|
||||
) {
|
||||
preferredLanes = Math.min(8, preferredLanes + 1);
|
||||
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
|
||||
}
|
||||
|
||||
return {
|
||||
throughputBps: smoothedThroughput,
|
||||
failureRate,
|
||||
samples: (previous?.samples ?? 0) + 1,
|
||||
preferredLanes,
|
||||
pipelineConcurrency,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTransferProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): TransferPerformanceProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = profiles.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
profiles.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordTransferProfile(
|
||||
key: string,
|
||||
observation: Parameters<typeof updateTransferProfile>[1],
|
||||
): TransferPerformanceProfile {
|
||||
const profile = updateTransferProfile(getTransferProfile(key), observation);
|
||||
profiles.set(profileId(key), profile);
|
||||
trimProfiles(observation.now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getDirectRouteProfile(
|
||||
key: string,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile | undefined {
|
||||
const id = profileId(key);
|
||||
const profile = directRoutes.get(id);
|
||||
if (!profile) return undefined;
|
||||
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
|
||||
directRoutes.delete(id);
|
||||
queuePersist();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function recordDirectRouteBenchmark(
|
||||
key: string,
|
||||
directMs: number,
|
||||
relayMs: number,
|
||||
now = Date.now(),
|
||||
): DirectRouteProfile {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
const weight = previous ? 0.25 : 1;
|
||||
const profile: DirectRouteProfile = {
|
||||
directMs: previous
|
||||
? previous.directMs * (1 - weight) + directMs * weight
|
||||
: directMs,
|
||||
relayMs: previous
|
||||
? previous.relayMs * (1 - weight) + relayMs * weight
|
||||
: relayMs,
|
||||
failureRate: previous?.failureRate ?? 0,
|
||||
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
|
||||
outcomeSamples: previous?.outcomeSamples ?? 0,
|
||||
benchmarkedAt: now,
|
||||
cooldownUntil: previous?.cooldownUntil,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
trimProfiles(now);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function recordDirectRouteOutcome(
|
||||
key: string,
|
||||
failed: boolean,
|
||||
now = Date.now(),
|
||||
cooldownMs = 10 * 60 * 1000,
|
||||
): DirectRouteProfile | undefined {
|
||||
const previous = getDirectRouteProfile(key, now);
|
||||
if (!previous) return undefined;
|
||||
const failure = failed ? 1 : 0;
|
||||
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
|
||||
const profile = {
|
||||
...previous,
|
||||
failureRate: previous.failureRate * (1 - weight) + failure * weight,
|
||||
outcomeSamples: previous.outcomeSamples + 1,
|
||||
cooldownUntil: failed ? now + cooldownMs : undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
directRoutes.set(profileId(key), profile);
|
||||
queuePersist();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getRecentDirectRouteDecision(
|
||||
key: string,
|
||||
maxAgeMs: number,
|
||||
now = Date.now(),
|
||||
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
|
||||
const profile = getDirectRouteProfile(key, now);
|
||||
if (!profile) return undefined;
|
||||
if ((profile.cooldownUntil ?? 0) > now) {
|
||||
return {
|
||||
useDirect: false,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
|
||||
return {
|
||||
useDirect:
|
||||
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
|
||||
directMs: profile.directMs,
|
||||
relayMs: profile.relayMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearTransferProfiles(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
loadedPath = undefined;
|
||||
loadPromise = undefined;
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper, Stats } from "ssh2";
|
||||
|
||||
export interface TrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
originalPath: string;
|
||||
isDirectory: boolean;
|
||||
deletedAt: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type StoredTrashItem = TrashItem & { trashPath: string };
|
||||
|
||||
const TRASH_DIR = ".termix-trash";
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
|
||||
function call<T>(
|
||||
run: (done: (error: Error | undefined, value: T) => void) => void,
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
run((error, value) => (error ? reject(error) : resolve(value)));
|
||||
});
|
||||
}
|
||||
|
||||
function stat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.stat(target, done));
|
||||
}
|
||||
|
||||
function lstat(sftp: SFTPWrapper, target: string) {
|
||||
return call<Stats>((done) => sftp.lstat(target, done));
|
||||
}
|
||||
|
||||
function readdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
|
||||
}
|
||||
|
||||
function readFile(sftp: SFTPWrapper, target: string) {
|
||||
return call<Buffer>((done) => sftp.readFile(target, done));
|
||||
}
|
||||
|
||||
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
|
||||
return call<void>((done) => sftp.writeFile(target, data, done));
|
||||
}
|
||||
|
||||
function rename(sftp: SFTPWrapper, from: string, to: string) {
|
||||
return call<void>((done) => sftp.rename(from, to, done));
|
||||
}
|
||||
|
||||
function unlink(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.unlink(target, done));
|
||||
}
|
||||
|
||||
function mkdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.mkdir(target, done));
|
||||
}
|
||||
|
||||
function rmdir(sftp: SFTPWrapper, target: string) {
|
||||
return call<void>((done) => sftp.rmdir(target, done));
|
||||
}
|
||||
|
||||
async function exists(sftp: SFTPWrapper, target: string) {
|
||||
try {
|
||||
await stat(sftp, target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
|
||||
const normalized = target.replace(/\\/g, "/");
|
||||
const root = normalized.startsWith("/") ? "/" : "";
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
for (const part of parts) {
|
||||
current =
|
||||
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
|
||||
if (await exists(sftp, current)) continue;
|
||||
await mkdir(sftp, current);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
|
||||
const targetStat = await lstat(sftp, target);
|
||||
if (!targetStat.isDirectory()) {
|
||||
await unlink(sftp, target);
|
||||
return;
|
||||
}
|
||||
for (const entry of await readdir(sftp, target)) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await removeTree(sftp, path.posix.join(target, entry.filename));
|
||||
}
|
||||
await rmdir(sftp, target);
|
||||
}
|
||||
|
||||
async function trashPaths(sftp: SFTPWrapper) {
|
||||
const home = await call<string>((done) => sftp.realpath(".", done));
|
||||
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
|
||||
const files = path.posix.join(root, "files");
|
||||
const info = path.posix.join(root, "info");
|
||||
await ensureDirectory(sftp, files);
|
||||
await ensureDirectory(sftp, info);
|
||||
return { root, files, info };
|
||||
}
|
||||
|
||||
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
|
||||
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
|
||||
const root = path.posix.normalize(trashRoot);
|
||||
return (
|
||||
normalized !== "." &&
|
||||
normalized !== "/" &&
|
||||
!/^[A-Za-z]:\/?$/.test(normalized) &&
|
||||
normalized !== root &&
|
||||
!normalized.startsWith(`${root}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function publicItem(item: StoredTrashItem): TrashItem {
|
||||
const { trashPath: _trashPath, ...result } = item;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readStoredItem(
|
||||
sftp: SFTPWrapper,
|
||||
dirs: { root: string; files: string; info: string },
|
||||
id: string,
|
||||
): Promise<StoredTrashItem> {
|
||||
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
|
||||
const parsed = JSON.parse(
|
||||
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
|
||||
"utf8",
|
||||
),
|
||||
) as StoredTrashItem;
|
||||
if (
|
||||
parsed.id !== id ||
|
||||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
|
||||
!isSafeTrashSource(parsed.originalPath, dirs.root)
|
||||
) {
|
||||
throw new Error("Invalid trash metadata");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function moveToTrash(
|
||||
sftp: SFTPWrapper,
|
||||
itemPath: string,
|
||||
): Promise<TrashItem> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
if (!isSafeTrashSource(itemPath, dirs.root)) {
|
||||
throw new Error("This path cannot be moved to trash");
|
||||
}
|
||||
const itemStat = await lstat(sftp, itemPath);
|
||||
const id = crypto.randomUUID();
|
||||
const trashPath = path.posix.join(dirs.files, id);
|
||||
const item: StoredTrashItem = {
|
||||
id,
|
||||
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
|
||||
originalPath: itemPath,
|
||||
trashPath,
|
||||
isDirectory: itemStat.isDirectory(),
|
||||
deletedAt: new Date().toISOString(),
|
||||
size: itemStat.size,
|
||||
};
|
||||
|
||||
await rename(sftp, itemPath, trashPath);
|
||||
try {
|
||||
await writeFile(
|
||||
sftp,
|
||||
path.posix.join(dirs.info, `${id}.json`),
|
||||
JSON.stringify(item),
|
||||
);
|
||||
} catch (error) {
|
||||
await rename(sftp, trashPath, itemPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function listTrash(
|
||||
sftp: SFTPWrapper,
|
||||
retentionDays: number,
|
||||
): Promise<TrashItem[]> {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
const items: TrashItem[] = [];
|
||||
for (const entry of await readdir(sftp, dirs.info)) {
|
||||
if (!entry.filename.endsWith(".json")) continue;
|
||||
const id = entry.filename.slice(0, -5);
|
||||
try {
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (new Date(item.deletedAt).getTime() < cutoff) {
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
continue;
|
||||
}
|
||||
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
|
||||
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
|
||||
} catch {
|
||||
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
}
|
||||
|
||||
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.originalPath)) {
|
||||
throw new Error("A file already exists at the original path");
|
||||
}
|
||||
await rename(sftp, item.trashPath, item.originalPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
return publicItem(item);
|
||||
}
|
||||
|
||||
export async function permanentlyDeleteTrashItem(
|
||||
sftp: SFTPWrapper,
|
||||
id: string,
|
||||
) {
|
||||
const dirs = await trashPaths(sftp);
|
||||
const item = await readStoredItem(sftp, dirs, id);
|
||||
if (await exists(sftp, item.trashPath))
|
||||
await removeTree(sftp, item.trashPath);
|
||||
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
|
||||
}
|
||||
|
||||
export async function emptyTrash(sftp: SFTPWrapper) {
|
||||
const items = await listTrash(sftp, 365_000);
|
||||
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
|
||||
return items.length;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
export function isExecutableFile(
|
||||
permissions: string,
|
||||
fileName: string,
|
||||
): boolean {
|
||||
const hasExecutePermission =
|
||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||
|
||||
const scriptExtensions = [
|
||||
".sh",
|
||||
".py",
|
||||
".pl",
|
||||
".rb",
|
||||
".js",
|
||||
".php",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
];
|
||||
const hasScriptExtension = scriptExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const executableExtensions = [".bin", ".exe", ".out"];
|
||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||
|
||||
return (
|
||||
hasExecutePermission &&
|
||||
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
|
||||
);
|
||||
}
|
||||
|
||||
export function modeToPermissions(mode: number): string {
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFLNK = 0o120000;
|
||||
const S_IFMT = 0o170000;
|
||||
|
||||
const type = mode & S_IFMT;
|
||||
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
|
||||
|
||||
const perms = [
|
||||
mode & 0o400 ? "r" : "-",
|
||||
mode & 0o200 ? "w" : "-",
|
||||
mode & 0o100 ? "x" : "-",
|
||||
mode & 0o040 ? "r" : "-",
|
||||
mode & 0o020 ? "w" : "-",
|
||||
mode & 0o010 ? "x" : "-",
|
||||
mode & 0o004 ? "r" : "-",
|
||||
mode & 0o002 ? "w" : "-",
|
||||
mode & 0o001 ? "x" : "-",
|
||||
].join("");
|
||||
|
||||
return prefix + perms;
|
||||
}
|
||||
|
||||
export function parseLsDateToTimestamp(dateStr: string): number {
|
||||
const parts = dateStr.trim().split(/\s+/);
|
||||
if (parts.length < 3) return 0;
|
||||
|
||||
const [month, day, timeOrYear] = parts;
|
||||
const monthIndex = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
].indexOf(month);
|
||||
if (monthIndex === -1) return 0;
|
||||
|
||||
const dayNum = parseInt(day, 10);
|
||||
const now = new Date();
|
||||
|
||||
if (timeOrYear.includes(":")) {
|
||||
const [hours, minutes] = timeOrYear.split(":").map(Number);
|
||||
let year = now.getFullYear();
|
||||
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
|
||||
if (candidate > now) year -= 1;
|
||||
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
|
||||
}
|
||||
|
||||
const year = parseInt(timeOrYear, 10);
|
||||
if (isNaN(year)) return 0;
|
||||
return new Date(year, monthIndex, dayNum).getTime() / 1000;
|
||||
}
|
||||
|
||||
export function formatMtime(mtime: number): string {
|
||||
const date = new Date(mtime * 1000);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate().toString().padStart(2, " ");
|
||||
const now = new Date();
|
||||
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (date > sixMonthsAgo) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${month} ${day} ${hours}:${minutes}`;
|
||||
}
|
||||
return `${month} ${day} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
js: "text/javascript",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
export function detectBinary(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
|
||||
const sampleSize = Math.min(buffer.length, 8192);
|
||||
let nullBytes = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
if (++nullBytes > 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return nullBytes / sampleSize > 0.01;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManager } from "./FileManager.tsx";
|
||||
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface FileManagerAppProps {
|
||||
hostId?: string;
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({
|
||||
hostId,
|
||||
initialPath,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileManager
|
||||
initialHost={hostConfig}
|
||||
initialPath={initialPath}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileManagerApp;
|
||||
@@ -0,0 +1,622 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
Copy,
|
||||
Scissors,
|
||||
Trash2,
|
||||
Info,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
FilePlus,
|
||||
RefreshCw,
|
||||
Clipboard,
|
||||
Eye,
|
||||
Terminal,
|
||||
Play,
|
||||
Star,
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
ArrowRightLeft,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
executable?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
files: FileItem[];
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onDownload?: (files: FileItem[]) => void;
|
||||
onRename?: (file: FileItem) => void;
|
||||
onCopy?: (files: FileItem[]) => void;
|
||||
onCut?: (files: FileItem[]) => void;
|
||||
onDelete?: (files: FileItem[]) => void;
|
||||
onProperties?: (file: FileItem) => void;
|
||||
onUpload?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onPaste?: () => void;
|
||||
onPreview?: (file: FileItem) => void;
|
||||
hasClipboard?: boolean;
|
||||
onDragToDesktop?: () => void;
|
||||
onOpenTerminal?: (path: string) => void;
|
||||
onRunExecutable?: (file: FileItem) => void;
|
||||
onPinFile?: (file: FileItem) => void;
|
||||
onUnpinFile?: (file: FileItem) => void;
|
||||
onAddShortcut?: (path: string) => void;
|
||||
isPinned?: (file: FileItem) => boolean;
|
||||
currentPath?: string;
|
||||
onExtractArchive?: (file: FileItem) => void;
|
||||
onCompress?: (files: FileItem[]) => void;
|
||||
onCopyPath?: (files: FileItem[]) => void;
|
||||
onCopyFolderLink?: (path: string) => void;
|
||||
onTransferToHost?: (files: FileItem[], move: boolean) => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
action: () => void;
|
||||
shortcut?: string;
|
||||
separator?: boolean;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function FileManagerContextMenu({
|
||||
x,
|
||||
y,
|
||||
files,
|
||||
isVisible,
|
||||
onClose,
|
||||
onDownload,
|
||||
onRename,
|
||||
onCopy,
|
||||
onCut,
|
||||
onDelete,
|
||||
onProperties,
|
||||
onUpload,
|
||||
onNewFolder,
|
||||
onNewFile,
|
||||
onRefresh,
|
||||
onPaste,
|
||||
onPreview,
|
||||
hasClipboard = false,
|
||||
onDragToDesktop,
|
||||
onOpenTerminal,
|
||||
onRunExecutable,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onAddShortcut,
|
||||
isPinned,
|
||||
currentPath,
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
onCopyPath,
|
||||
onCopyFolderLink,
|
||||
onTransferToHost,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setIsMounted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMounted(true);
|
||||
|
||||
let cleanupFn: (() => void) | null = null;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-context-menu]");
|
||||
|
||||
if (!menuElement?.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside, true);
|
||||
document.addEventListener("contextmenu", handleRightClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
cleanupFn = () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (cleanupFn) {
|
||||
cleanupFn();
|
||||
}
|
||||
};
|
||||
}, [isVisible, x, y, onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !menuRef.current) return;
|
||||
const menuWidth = menuRef.current.offsetWidth;
|
||||
const menuHeight = menuRef.current.offsetHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let adjustedX = x;
|
||||
let adjustedY = y;
|
||||
if (x + menuWidth > viewportWidth)
|
||||
adjustedX = viewportWidth - menuWidth - 10;
|
||||
if (y + menuHeight > viewportHeight)
|
||||
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
|
||||
adjustedX = Math.max(8, adjustedX);
|
||||
adjustedY = Math.max(8, adjustedY);
|
||||
setMenuPosition({ x: adjustedX, y: adjustedY });
|
||||
}, [isVisible, x, y, files.length]);
|
||||
|
||||
const isFileContext = files.length > 0;
|
||||
const isSingleFile = files.length === 1;
|
||||
const isMultipleFiles = files.length > 1;
|
||||
const hasFiles = files.some((f) => f.type === "file");
|
||||
const hasExecutableFiles = files.some(
|
||||
(f) => f.type === "file" && f.executable,
|
||||
);
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (isFileContext) {
|
||||
if (onOpenTerminal) {
|
||||
const targetPath = isSingleFile
|
||||
? files[0].type === "directory"
|
||||
? files[0].path
|
||||
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
|
||||
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
|
||||
|
||||
menuItems.push({
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
label:
|
||||
files[0].type === "directory"
|
||||
? t("fileManager.openTerminalInFolder")
|
||||
: t("fileManager.openTerminalInFileLocation"),
|
||||
action: () => onOpenTerminal(targetPath),
|
||||
shortcut: "Ctrl+Shift+T",
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
|
||||
menuItems.push({
|
||||
icon: <Play className="size-3.5" />,
|
||||
label: t("fileManager.run"),
|
||||
action: () => onRunExecutable(files[0]),
|
||||
shortcut: "Enter",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
onOpenTerminal ||
|
||||
(isSingleFile && hasExecutableFiles && onRunExecutable)
|
||||
) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (hasFiles && onPreview) {
|
||||
menuItems.push({
|
||||
icon: <Eye className="size-3.5" />,
|
||||
label: t("fileManager.preview"),
|
||||
action: () => onPreview(files[0]),
|
||||
disabled: !isSingleFile || files[0].type !== "file",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasFiles && onDownload) {
|
||||
menuItems.push({
|
||||
icon: <Download className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.downloadFiles", { count: files.length })
|
||||
: t("fileManager.downloadFile"),
|
||||
action: () => onDownload(files),
|
||||
shortcut: "Ctrl+D",
|
||||
});
|
||||
}
|
||||
|
||||
if (isFileContext && onTransferToHost) {
|
||||
const isOnlyDirectories =
|
||||
files.length > 0 && files.every((f) => f.type === "directory");
|
||||
menuItems.push({
|
||||
icon: <ArrowRightLeft className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("transfer.copyItemsToHost", { count: files.length })
|
||||
: isOnlyDirectories && isSingleFile
|
||||
? t("transfer.copyFolderToHost")
|
||||
: t("transfer.copyToHost"),
|
||||
action: () => onTransferToHost(files, false),
|
||||
});
|
||||
menuItems.push({
|
||||
icon: <ArrowRightLeft className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("transfer.moveItemsToHost", { count: files.length })
|
||||
: isOnlyDirectories && isSingleFile
|
||||
? t("transfer.moveFolderToHost")
|
||||
: t("transfer.moveToHost"),
|
||||
action: () => onTransferToHost(files, true),
|
||||
});
|
||||
}
|
||||
|
||||
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="size-3.5" />,
|
||||
label: t("fileManager.extractArchive"),
|
||||
action: () => onExtractArchive(files[0]),
|
||||
shortcut: "Ctrl+E",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileContext && onCompress) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.compressFiles")
|
||||
: t("fileManager.compressFile"),
|
||||
action: () => onCompress(files),
|
||||
shortcut: "Ctrl+Shift+C",
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
if (isCurrentlyPinned && onUnpinFile) {
|
||||
menuItems.push({
|
||||
icon: (
|
||||
<Star className="size-3.5 fill-accent-brand text-accent-brand" />
|
||||
),
|
||||
label: t("fileManager.unpinFile"),
|
||||
action: () => onUnpinFile(files[0]),
|
||||
});
|
||||
} else if (!isCurrentlyPinned && onPinFile) {
|
||||
menuItems.push({
|
||||
icon: <Star className="size-3.5" />,
|
||||
label: t("fileManager.pinFile"),
|
||||
action: () => onPinFile(files[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
|
||||
menuItems.push({
|
||||
icon: <Bookmark className="size-3.5" />,
|
||||
label: t("fileManager.addToShortcuts"),
|
||||
action: () => onAddShortcut(files[0].path),
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) {
|
||||
menuItems.push({
|
||||
icon: <Link className="size-3.5" />,
|
||||
label: t("fileManager.copyFolderLink"),
|
||||
action: () => onCopyFolderLink(files[0].path),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||
(isSingleFile &&
|
||||
files[0].type === "file" &&
|
||||
(onPinFile || onUnpinFile)) ||
|
||||
(isSingleFile &&
|
||||
files[0].type === "directory" &&
|
||||
(onAddShortcut || onCopyFolderLink))
|
||||
) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (isSingleFile && onRename) {
|
||||
menuItems.push({
|
||||
icon: <Edit3 className="size-3.5" />,
|
||||
label: t("fileManager.rename"),
|
||||
action: () => onRename(files[0]),
|
||||
shortcut: "F6",
|
||||
});
|
||||
}
|
||||
|
||||
if (onCopy) {
|
||||
menuItems.push({
|
||||
icon: <Copy className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.copyFiles", { count: files.length })
|
||||
: t("fileManager.copy"),
|
||||
action: () => onCopy(files),
|
||||
shortcut: "Ctrl+C",
|
||||
});
|
||||
}
|
||||
|
||||
if (onCut) {
|
||||
menuItems.push({
|
||||
icon: <Scissors className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.cutFiles", { count: files.length })
|
||||
: t("fileManager.cut"),
|
||||
action: () => onCut(files),
|
||||
shortcut: "Ctrl+X",
|
||||
});
|
||||
}
|
||||
|
||||
if (onCopyPath) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.copyPaths")
|
||||
: t("fileManager.copyPath"),
|
||||
action: () => onCopyPath(files),
|
||||
shortcut: "Ctrl+Shift+P",
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (isSingleFile && onProperties) {
|
||||
menuItems.push({
|
||||
icon: <Info className="size-3.5" />,
|
||||
label: t("fileManager.properties"),
|
||||
action: () => onProperties(files[0]),
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onProperties) || onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
menuItems.push({
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.deleteFiles", { count: files.length })
|
||||
: t("fileManager.delete"),
|
||||
action: () => onDelete(files),
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (onOpenTerminal && currentPath) {
|
||||
menuItems.push({
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
label: t("fileManager.openTerminalHere"),
|
||||
action: () => onOpenTerminal(currentPath),
|
||||
shortcut: "Ctrl+Shift+T",
|
||||
});
|
||||
}
|
||||
|
||||
if (onUpload) {
|
||||
menuItems.push({
|
||||
icon: <Upload className="size-3.5" />,
|
||||
label: t("fileManager.uploadFile"),
|
||||
action: onUpload,
|
||||
shortcut: "Ctrl+U",
|
||||
});
|
||||
}
|
||||
|
||||
if ((onOpenTerminal && currentPath) || onUpload) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (onNewFolder) {
|
||||
menuItems.push({
|
||||
icon: <FolderPlus className="size-3.5" />,
|
||||
label: t("fileManager.newFolder"),
|
||||
action: onNewFolder,
|
||||
shortcut: "Ctrl+Shift+N",
|
||||
});
|
||||
}
|
||||
|
||||
if (onNewFile) {
|
||||
menuItems.push({
|
||||
icon: <FilePlus className="size-3.5" />,
|
||||
label: t("fileManager.newFile"),
|
||||
action: onNewFile,
|
||||
shortcut: "Ctrl+N",
|
||||
});
|
||||
}
|
||||
|
||||
if (onNewFolder || onNewFile) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
if (onRefresh) {
|
||||
menuItems.push({
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
label: t("fileManager.refresh"),
|
||||
action: onRefresh,
|
||||
shortcut: "Ctrl+Y",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasClipboard && onPaste) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="size-3.5" />,
|
||||
label: t("fileManager.paste"),
|
||||
action: onPaste,
|
||||
shortcut: "Ctrl+V",
|
||||
});
|
||||
}
|
||||
|
||||
if (onCopyFolderLink && currentPath) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
menuItems.push({
|
||||
icon: <Link className="size-3.5" />,
|
||||
label: t("fileManager.copyCurrentFolderLink"),
|
||||
action: () => onCopyFolderLink(currentPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
|
||||
const prevItem = index > 0 ? menuItems[index - 1] : null;
|
||||
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
|
||||
|
||||
if (prevItem?.separator || nextItem?.separator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const finalMenuItems = filteredMenuItems.filter((item, index) => {
|
||||
if (!item.separator) return true;
|
||||
return index > 0 && index < filteredMenuItems.length - 1;
|
||||
});
|
||||
|
||||
const renderShortcut = (shortcut: string) => {
|
||||
const keys = shortcut.split("+");
|
||||
if (keys.length === 1) {
|
||||
return <Kbd>{keys[0]}</Kbd>;
|
||||
}
|
||||
return (
|
||||
<Kbd>
|
||||
{keys.map((key, index) => (
|
||||
<>
|
||||
<KbdKey key={`key-${index}`}>{key}</KbdKey>
|
||||
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
|
||||
</>
|
||||
))}
|
||||
</Kbd>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isVisible && !isMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||
!isMounted && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
className={cn(
|
||||
"fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
|
||||
)}
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
|
||||
}}
|
||||
>
|
||||
{finalMenuItems.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
className="my-1 border-t border-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
|
||||
"hover:bg-accent-brand/10 hover:text-accent-brand",
|
||||
item.disabled &&
|
||||
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
|
||||
item.danger &&
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.action();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 text-muted-foreground">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="flex-1 leading-tight">{item.label}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<div className="ml-auto flex-shrink-0 opacity-50">
|
||||
{renderShortcut(item.shortcut)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
import {
|
||||
TOTPDialog,
|
||||
SSHAuthDialog,
|
||||
WarpgateDialog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
|
||||
import type { PendingSudoOperation } from "./file-manager-types.ts";
|
||||
|
||||
type FileManagerDialogsProps = {
|
||||
compressDialogFiles: FileItem[];
|
||||
setCompressDialogFiles: (files: FileItem[]) => void;
|
||||
handleCompress: (archiveName: string, format: string) => void | Promise<void>;
|
||||
totpRequired: boolean;
|
||||
totpPrompt: string;
|
||||
handleTotpSubmit: (code: string) => void | Promise<void>;
|
||||
handleTotpCancel: () => void;
|
||||
warpgateRequired: boolean;
|
||||
warpgateUrl: string;
|
||||
warpgateSecurityKey: string;
|
||||
handleWarpgateContinue: () => void | Promise<void>;
|
||||
handleWarpgateCancel: () => void;
|
||||
handleWarpgateOpenUrl: () => void;
|
||||
currentHost: SSHHost | null;
|
||||
showAuthDialog: boolean;
|
||||
authDialogReason: "no_keyboard" | "auth_failed" | "timeout";
|
||||
handleAuthDialogSubmit: (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => void | Promise<void>;
|
||||
handleAuthDialogCancel: () => void;
|
||||
permissionsDialogFile: FileItem | null;
|
||||
setPermissionsDialogFile: (file: FileItem | null) => void;
|
||||
handleSavePermissions: (file: FileItem, permissions: string) => Promise<void>;
|
||||
sudoDialogOpen: boolean;
|
||||
setSudoDialogOpen: (open: boolean) => void;
|
||||
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
|
||||
handleSudoPasswordSubmit: (password: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function FileManagerDialogs({
|
||||
compressDialogFiles,
|
||||
setCompressDialogFiles,
|
||||
handleCompress,
|
||||
totpRequired,
|
||||
totpPrompt,
|
||||
handleTotpSubmit,
|
||||
handleTotpCancel,
|
||||
warpgateRequired,
|
||||
warpgateUrl,
|
||||
warpgateSecurityKey,
|
||||
handleWarpgateContinue,
|
||||
handleWarpgateCancel,
|
||||
handleWarpgateOpenUrl,
|
||||
currentHost,
|
||||
showAuthDialog,
|
||||
authDialogReason,
|
||||
handleAuthDialogSubmit,
|
||||
handleAuthDialogCancel,
|
||||
permissionsDialogFile,
|
||||
setPermissionsDialogFile,
|
||||
handleSavePermissions,
|
||||
sudoDialogOpen,
|
||||
setSudoDialogOpen,
|
||||
setPendingSudoOperation,
|
||||
handleSudoPasswordSubmit,
|
||||
}: FileManagerDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||
onCompress={handleCompress}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
|
||||
<SudoPasswordDialog
|
||||
open={sudoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSudoDialogOpen(open);
|
||||
if (!open) setPendingSudoOperation(null);
|
||||
}}
|
||||
onSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,920 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
Star,
|
||||
Clock,
|
||||
Bookmark,
|
||||
File,
|
||||
Folder,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost } from "@/types";
|
||||
import {
|
||||
getRecentFiles,
|
||||
getPinnedFiles,
|
||||
getFolderShortcuts,
|
||||
removeRecentFile,
|
||||
removePinnedFile,
|
||||
removeFolderShortcut,
|
||||
} from "./api/file-manager-data-api";
|
||||
import { listSSHFiles } from "./api/ssh-file-operations-api";
|
||||
import { type DiskFilesystem } from "@/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import FolderTree from "@/components/folder.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
// ─── Interfaces ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecentFileData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PinnedFileData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ShortcutData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DirectoryItemData {
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SidebarItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: "recent" | "pinned" | "shortcut" | "folder";
|
||||
lastAccessed?: string;
|
||||
isExpanded?: boolean;
|
||||
children?: SidebarItem[];
|
||||
}
|
||||
|
||||
interface FileManagerSidebarProps {
|
||||
currentHost: SSHHost;
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onFileOpen?: (file: SidebarItem) => void;
|
||||
/** Full file-manager context menu (same as main grid). */
|
||||
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number;
|
||||
onOpenTrash?: () => void;
|
||||
diskInfo?: {
|
||||
usedHuman: string;
|
||||
totalHuman: string;
|
||||
percent: number;
|
||||
mount?: string | null;
|
||||
filesystems?: DiskFilesystem[];
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Storage meter ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StorageMeter({
|
||||
storage,
|
||||
filesystems,
|
||||
selectedMount,
|
||||
onSelectMount,
|
||||
}: {
|
||||
storage: { percent: number; usedHuman: string; totalHuman: string };
|
||||
filesystems: DiskFilesystem[];
|
||||
selectedMount: string | null;
|
||||
onSelectMount: (mount: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasPicker = filesystems.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{hasPicker ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex min-w-0 items-center gap-1 transition-colors hover:text-foreground">
|
||||
<span className="truncate">
|
||||
{selectedMount ?? t("fileManager.disk")}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
|
||||
>
|
||||
{filesystems.map((fs) => (
|
||||
<DropdownMenuItem
|
||||
key={fs.mount}
|
||||
onSelect={() => onSelectMount(fs.mount)}
|
||||
className="gap-3"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
fs.mount === selectedMount ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{fs.mount}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
|
||||
{fs.usedHuman && fs.totalHuman
|
||||
? `${fs.usedHuman}/${fs.totalHuman}`
|
||||
: "N/A"}
|
||||
{fs.percent != null ? ` · ${fs.percent}%` : ""}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{selectedMount ?? t("fileManager.disk")}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-accent-brand">
|
||||
{storage.percent}% {t("fileManager.used")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-none overflow-hidden border border-border">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${storage.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
|
||||
{storage.usedHuman} {t("fileManager.of")} {storage.totalHuman}{" "}
|
||||
{t("fileManager.used").toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FileManagerSidebar({
|
||||
currentHost,
|
||||
currentPath,
|
||||
onPathChange,
|
||||
onFileOpen,
|
||||
onItemContextMenu,
|
||||
sshSessionId,
|
||||
refreshTrigger,
|
||||
onOpenTrash,
|
||||
diskInfo,
|
||||
}: FileManagerSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ── Quick access state (API-backed) ──────────────────────────────────────────
|
||||
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
|
||||
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
|
||||
|
||||
// ── Directory tree state ──────────────────────────────────────────────────────
|
||||
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
|
||||
|
||||
// ── Storage state ─────────────────────────────────────────────────────────────
|
||||
const [selectedMount, setSelectedMount] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Tracks which folder paths have already been lazy-loaded so we don't
|
||||
* re-fetch on every re-selection / collapse-reopen.
|
||||
*/
|
||||
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
|
||||
|
||||
// ── Context menu state ────────────────────────────────────────────────────────
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
isVisible: boolean;
|
||||
item: SidebarItem | null;
|
||||
}>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
isVisible: false,
|
||||
item: null,
|
||||
});
|
||||
|
||||
// ─── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── API: Quick access ────────────────────────────────────────────────────────
|
||||
|
||||
const loadQuickAccessData = useCallback(async () => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
const recentData = await getRecentFiles(currentHost.id);
|
||||
const recentItems = (recentData as unknown as RecentFileData[])
|
||||
.slice(0, 5)
|
||||
.map((item: RecentFileData) => ({
|
||||
id: `recent-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "recent" as const,
|
||||
lastAccessed: item.lastOpened,
|
||||
}));
|
||||
setRecentItems(recentItems);
|
||||
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map(
|
||||
(item: PinnedFileData) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "pinned" as const,
|
||||
}),
|
||||
);
|
||||
setPinnedItems(pinnedItems);
|
||||
|
||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||
const shortcutItems = (shortcutData as unknown as ShortcutData[]).map(
|
||||
(item: ShortcutData) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "shortcut" as const,
|
||||
}),
|
||||
);
|
||||
setShortcuts(shortcutItems);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick access data:", error);
|
||||
setRecentItems([]);
|
||||
setPinnedItems([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost?.id]);
|
||||
|
||||
// ─── API: Directory tree ──────────────────────────────────────────────────────
|
||||
|
||||
const loadDirectoryTree = useCallback(
|
||||
async (attempt = 0) => {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles
|
||||
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||
.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
type: "folder" as const,
|
||||
isExpanded: true,
|
||||
children: rootTreeItems,
|
||||
},
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409 && attempt < 3) {
|
||||
// Another request was already listing "/" — retry after a short delay
|
||||
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load directory tree:", error);
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Lazily fetches subdirectory contents and patches them into the tree state.
|
||||
* Called the first time a folder is expanded via FolderTree's onSelect.
|
||||
*/
|
||||
const loadSubdirectory = useCallback(
|
||||
async (folderId: string, folderPath: string): Promise<boolean> => {
|
||||
if (!sshSessionId) return false;
|
||||
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||
const subFolders = subFiles
|
||||
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||
.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.path.replace(/\//g, "-")}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree((prevTree) => {
|
||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items.map((item) => {
|
||||
if (item.id === folderId) {
|
||||
return { ...item, children: subTreeItems };
|
||||
}
|
||||
if (item.children) {
|
||||
return { ...item, children: updateChildren(item.children) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return updateChildren(prevTree);
|
||||
});
|
||||
loadedFoldersRef.current.add(folderPath);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409) {
|
||||
// Another request was listing this path — retry after the lock clears
|
||||
setTimeout(() => void loadSubdirectory(folderId, folderPath), 600);
|
||||
return false;
|
||||
}
|
||||
loadedFoldersRef.current.delete(folderPath);
|
||||
console.error("Failed to load subdirectory:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [loadQuickAccessData, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadedFoldersRef.current = new Set(["/"]);
|
||||
loadDirectoryTree();
|
||||
}
|
||||
}, [loadDirectoryTree, sshSessionId]);
|
||||
|
||||
// When currentPath changes externally (grid navigation), ensure the parent
|
||||
// directory is loaded in the tree so the selection highlight can appear.
|
||||
useEffect(() => {
|
||||
if (!sshSessionId || currentPath === "/") return;
|
||||
|
||||
const parentPath =
|
||||
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === parentPath) return item;
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parent = findByPath(directoryTree);
|
||||
if (parent && !loadedFoldersRef.current.has(parent.path)) {
|
||||
void loadSubdirectory(parent.id, parent.path);
|
||||
}
|
||||
}, [currentPath, directoryTree, loadSubdirectory, sshSessionId]);
|
||||
|
||||
// ─── Quick-access mutation handlers ──────────────────────────────────────────
|
||||
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removeRecentFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(
|
||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove recent file:", error);
|
||||
toast.error(t("fileManager.removeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removePinnedFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin file:", error);
|
||||
toast.error(t("fileManager.unpinFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removeFolderShortcut(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove shortcut:", error);
|
||||
toast.error(t("fileManager.removeShortcutFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllRecent = async () => {
|
||||
if (!currentHost?.id || recentItems.length === 0) return;
|
||||
try {
|
||||
await Promise.all(
|
||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||
);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear recent files:", error);
|
||||
toast.error(t("fileManager.clearFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Quick-access item click ──────────────────────────────────────────────────
|
||||
|
||||
const handleQuickAccessClick = (item: SidebarItem) => {
|
||||
if (item.type === "recent" || item.type === "pinned") {
|
||||
if (onFileOpen) {
|
||||
onFileOpen(item);
|
||||
} else {
|
||||
const directory =
|
||||
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||
onPathChange(directory);
|
||||
}
|
||||
} else if (item.type === "shortcut") {
|
||||
onPathChange(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
|
||||
|
||||
/**
|
||||
* Called by FolderTree whenever the user selects (clicks) a tree item.
|
||||
* We navigate to the folder and lazily load children on first visit.
|
||||
*/
|
||||
const handleDirectorySelect = useCallback(
|
||||
async (id: string) => {
|
||||
// Walk the tree to find the item by id
|
||||
const findItem = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item;
|
||||
if (item.children) {
|
||||
const found = findItem(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const item = findItem(directoryTree);
|
||||
if (!item) return;
|
||||
|
||||
// Navigate to path
|
||||
onPathChange(item.path);
|
||||
|
||||
// Lazy-load children the first time this folder is expanded
|
||||
if (
|
||||
sshSessionId &&
|
||||
item.path !== "/" &&
|
||||
!loadedFoldersRef.current.has(item.path)
|
||||
) {
|
||||
await loadSubdirectory(id, item.path);
|
||||
}
|
||||
},
|
||||
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
|
||||
);
|
||||
|
||||
// ─── Context menu ─────────────────────────────────────────────────────────────
|
||||
|
||||
const findTreeItemById = useCallback(
|
||||
(items: SidebarItem[], id: string): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item;
|
||||
if (item.children) {
|
||||
const found = findTreeItemById(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onItemContextMenu) {
|
||||
onItemContextMenu(e, item);
|
||||
return;
|
||||
}
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
|
||||
};
|
||||
|
||||
const handleTreeContextMenu = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const row = target.closest<HTMLElement>("[data-id]");
|
||||
if (!row) return;
|
||||
const id = row.getAttribute("data-id");
|
||||
if (!id) return;
|
||||
const item = findTreeItemById(directoryTree, id);
|
||||
if (!item || item.type !== "folder") return;
|
||||
handleItemContextMenu(e, item);
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible || onItemContextMenu) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-sidebar-context-menu]");
|
||||
if (!menuElement?.contains(target)) closeContextMenu();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeContextMenu();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [contextMenu.isVisible, onItemContextMenu]);
|
||||
|
||||
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
|
||||
|
||||
const { selectedTreeId, ancestorIds } = useMemo(() => {
|
||||
if (currentPath === "/")
|
||||
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
|
||||
|
||||
const ancestors: string[] = [];
|
||||
const findByPath = (
|
||||
items: SidebarItem[],
|
||||
path: string[],
|
||||
): string | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
ancestors.push(...path, "root");
|
||||
return item.id;
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children, [...path, item.id]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const id = findByPath(directoryTree, []);
|
||||
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
|
||||
}, [currentPath, directoryTree]);
|
||||
|
||||
// ─── Render helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively renders directory tree items using FolderTree.Item + Content.
|
||||
*
|
||||
* FolderTree.Item detects "has children" via React.Children.count > 0.
|
||||
* By always wrapping children in <FolderTree.Content> (even when the
|
||||
* children array is empty), every directory shows the expand chevron.
|
||||
* FolderTree.Content internally shows nothing when its own children are
|
||||
* absent, so an unloaded folder simply expands to an empty state while
|
||||
* the async fetch fills it in.
|
||||
*/
|
||||
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
|
||||
<FolderTree.Item key={item.id} id={item.id} label={item.name}>
|
||||
<FolderTree.Content>
|
||||
{item.children?.map((child) => renderFolderTreeItem(child))}
|
||||
</FolderTree.Content>
|
||||
</FolderTree.Item>
|
||||
);
|
||||
|
||||
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
|
||||
const dirPath =
|
||||
item.type === "shortcut"
|
||||
? item.path
|
||||
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||
const isActive = currentPath === dirPath;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider transition-colors text-left border-l-2",
|
||||
isActive
|
||||
? "bg-accent-brand/10 text-accent-brand border-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
|
||||
)}
|
||||
onClick={() => handleQuickAccessClick(item)}
|
||||
onContextMenu={(e) => handleItemContextMenu(e, item)}
|
||||
title={item.path}
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<span className="flex-1 truncate">{item.name}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSection = (
|
||||
title: string,
|
||||
items: SidebarItem[],
|
||||
renderItem: (item: SidebarItem) => React.ReactNode,
|
||||
) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((item) => renderItem(item))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hasQuickAccessItems =
|
||||
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const storageFilesystems = diskInfo?.filesystems ?? [];
|
||||
const activeStorageMount = selectedMount ?? diskInfo?.mount ?? null;
|
||||
const selectedFs =
|
||||
selectedMount !== null
|
||||
? (storageFilesystems.find((fs) => fs.mount === selectedMount) ?? null)
|
||||
: null;
|
||||
|
||||
const storage =
|
||||
selectedFs && selectedFs.percent !== null
|
||||
? {
|
||||
percent: selectedFs.percent,
|
||||
usedHuman: selectedFs.usedHuman ?? "N/A",
|
||||
totalHuman: selectedFs.totalHuman ?? "N/A",
|
||||
}
|
||||
: diskInfo && diskInfo.percent !== null
|
||||
? {
|
||||
percent: diskInfo.percent,
|
||||
usedHuman: diskInfo.usedHuman,
|
||||
totalHuman: diskInfo.totalHuman,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-card overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||
{onOpenTrash && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-muted border-l-2 border-transparent"
|
||||
onClick={onOpenTrash}
|
||||
>
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>{t("fileManager.trash")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* ── Recent files ──────────────────────────────────────── */}
|
||||
{renderSection(t("fileManager.recent"), recentItems, (item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<File
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
currentPath ===
|
||||
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Pinned files ───────────────────────────────────────── */}
|
||||
{renderSection(t("fileManager.pinned"), pinnedItems, (item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<Star
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
currentPath ===
|
||||
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
|
||||
? "text-accent-brand fill-accent-brand"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Folder shortcuts ───────────────────────────────────── */}
|
||||
{renderSection(t("fileManager.folderShortcuts"), shortcuts, (item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<Folder
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
currentPath === item.path
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Directory tree ─────────────────────────────────────── */}
|
||||
<div
|
||||
className={cn(
|
||||
hasQuickAccessItems && "border-t border-border mt-1 pt-1",
|
||||
)}
|
||||
>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("fileManager.directories")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div onContextMenu={handleTreeContextMenu}>
|
||||
<FolderTree.Root
|
||||
id="sidebar-directory-tree"
|
||||
defaultExpanded={["root"]}
|
||||
selectedId={selectedTreeId}
|
||||
expandedIds={ancestorIds}
|
||||
onSelect={(id) => handleDirectorySelect(id)}
|
||||
className="bg-transparent border-0 rounded-none shadow-none"
|
||||
>
|
||||
{directoryTree.map((item) => renderFolderTreeItem(item))}
|
||||
</FolderTree.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Storage — mobile only (inside scroll) ──────────────── */}
|
||||
{storage && (
|
||||
<div className="md:hidden">
|
||||
<div className="border-t border-border mx-0 my-2" />
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("fileManager.storage")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<StorageMeter
|
||||
storage={storage}
|
||||
filesystems={storageFilesystems}
|
||||
selectedMount={activeStorageMount}
|
||||
onSelectMount={setSelectedMount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */}
|
||||
{storage && (
|
||||
<div className="hidden md:flex flex-col p-3 gap-2 border-t border-border shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("fileManager.storage")}
|
||||
</span>
|
||||
<StorageMeter
|
||||
storage={storage}
|
||||
filesystems={storageFilesystems}
|
||||
selectedMount={activeStorageMount}
|
||||
onSelectMount={setSelectedMount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */}
|
||||
{!onItemContextMenu && contextMenu.isVisible && contextMenu.item && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" />
|
||||
|
||||
<div
|
||||
data-sidebar-context-menu
|
||||
className="fixed bg-card border border-border rounded-none shadow-xl min-w-[180px] z-50 overflow-hidden"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{contextMenu.item.type === "recent" && (
|
||||
<>
|
||||
<button
|
||||
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={() => {
|
||||
handleRemoveRecentFile(contextMenu.item!);
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Clock className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.removeFromRecentFiles")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{recentItems.length > 1 && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<button
|
||||
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => {
|
||||
handleClearAllRecent();
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Clock className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.clearAllRecentFiles")}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu.item.type === "pinned" && (
|
||||
<button
|
||||
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={() => {
|
||||
handleUnpinFile(contextMenu.item!);
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Star className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">{t("fileManager.unpinFile")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{contextMenu.item.type === "shortcut" && (
|
||||
<button
|
||||
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={() => {
|
||||
handleRemoveShortcut(contextMenu.item!);
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<Bookmark className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.removeShortcut")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Grid3X3,
|
||||
Laptop,
|
||||
Layout,
|
||||
List,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { FileItem } from "@/types/index";
|
||||
|
||||
type SortBy = "name" | "modified" | "size";
|
||||
type SortOrder = "asc" | "desc";
|
||||
type ViewMode = "grid" | "list";
|
||||
type Density = "comfortable" | "compact";
|
||||
|
||||
type FileManagerToolbarProps = {
|
||||
t: (key: string) => string;
|
||||
currentPath: string;
|
||||
navIndex: number;
|
||||
navHistoryLength: number;
|
||||
isLoading: boolean;
|
||||
sshSessionId: string | null;
|
||||
selectedFiles: FileItem[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
density: Density;
|
||||
setDensity: (density: Density) => void;
|
||||
sortBy: SortBy;
|
||||
setSortBy: (sortBy: SortBy) => void;
|
||||
sortOrder: SortOrder;
|
||||
setSortOrder: (sortOrder: SortOrder) => void;
|
||||
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
goUp: () => void;
|
||||
navigateTo: (path: string) => void;
|
||||
handleRefreshDirectory: () => void;
|
||||
handleDeleteFiles: (files: FileItem[]) => void;
|
||||
handleCopyFiles: (files: FileItem[]) => void;
|
||||
handleFilesDropped: (fileList: FileList) => void;
|
||||
handleCreateNewFolder: () => void;
|
||||
handleCreateNewFile: () => void;
|
||||
/** Desktop app only: show the Local | Remote split-view toggle. */
|
||||
showLocalPaneToggle?: boolean;
|
||||
localPaneOpen?: boolean;
|
||||
onToggleLocalPane?: () => void;
|
||||
/** Desktop directories sidebar (mobile uses the overlay instead). */
|
||||
sidebarOpen?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
};
|
||||
|
||||
function Breadcrumb({
|
||||
currentPath,
|
||||
navigateTo,
|
||||
t,
|
||||
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
|
||||
return (
|
||||
<>
|
||||
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
|
||||
{currentPath.split("/").map((part, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
{part === "" && i === 0 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTo("/");
|
||||
}}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{t("fileManager.root")}
|
||||
</button>
|
||||
) : part !== "" ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTo(arr.slice(0, i + 1).join("/") || "/");
|
||||
}}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
) : null}
|
||||
{i < arr.length - 1 && part !== "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{i === 0 && arr.length > 1 && part === "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PathBar({
|
||||
currentPath,
|
||||
navigateTo,
|
||||
t,
|
||||
className,
|
||||
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
|
||||
className: string;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(currentPath);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
doneRef.current = false;
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isEditing]);
|
||||
|
||||
const commit = (path: string) => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
setIsEditing(false);
|
||||
const trimmed = path.trim();
|
||||
if (trimmed && trimmed !== currentPath) {
|
||||
navigateTo(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit(value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => commit(value)}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} cursor-text`}
|
||||
onClick={() => {
|
||||
setValue(currentPath);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileManagerToolbar({
|
||||
t,
|
||||
currentPath,
|
||||
navIndex,
|
||||
navHistoryLength,
|
||||
isLoading,
|
||||
sshSessionId,
|
||||
selectedFiles,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
density,
|
||||
setDensity,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setMobileSidebarOpen,
|
||||
goBack,
|
||||
goForward,
|
||||
goUp,
|
||||
navigateTo,
|
||||
handleRefreshDirectory,
|
||||
handleDeleteFiles,
|
||||
handleCopyFiles,
|
||||
handleFilesDropped,
|
||||
handleCreateNewFolder,
|
||||
handleCreateNewFile,
|
||||
showLocalPaneToggle = false,
|
||||
localPaneOpen = false,
|
||||
onToggleLocalPane,
|
||||
sidebarOpen = true,
|
||||
onToggleSidebar,
|
||||
}: FileManagerToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
// Below md the sidebar is an overlay; above it, a persisted
|
||||
// show/hide of the directories panel.
|
||||
const isDesktop =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(min-width: 768px)").matches;
|
||||
if (isDesktop && onToggleSidebar) onToggleSidebar();
|
||||
else setMobileSidebarOpen((open) => !open);
|
||||
}}
|
||||
className={`size-8 rounded-none ${sidebarOpen ? "" : "md:bg-accent-brand/10 md:text-accent-brand"}`}
|
||||
title={t("fileManager.toggleSidebar")}
|
||||
aria-pressed={!sidebarOpen}
|
||||
>
|
||||
<Layout className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goBack}
|
||||
disabled={navIndex <= 0}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goForward}
|
||||
disabled={navIndex >= navHistoryLength - 1}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goUp}
|
||||
disabled={currentPath === "/"}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefreshDirectory}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PathBar
|
||||
currentPath={currentPath}
|
||||
navigateTo={navigateTo}
|
||||
t={t}
|
||||
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleDeleteFiles(selectedFiles)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleCopyFiles(selectedFiles)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-28 md:w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFiles")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showLocalPaneToggle && (
|
||||
<Button
|
||||
variant={localPaneOpen ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={onToggleLocalPane}
|
||||
title={
|
||||
localPaneOpen
|
||||
? t("fileManager.hideLocalFiles")
|
||||
: t("fileManager.showLocalFiles")
|
||||
}
|
||||
aria-pressed={localPaneOpen}
|
||||
className={`hidden md:inline-flex size-8 rounded-none border border-border ${localPaneOpen ? "bg-accent-brand/10 text-accent-brand border-accent-brand/40" : ""}`}
|
||||
>
|
||||
<Laptop className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center border border-border rounded-none overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`size-8 rounded-none ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<Grid3X3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`size-8 rounded-none border-l border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={density === "compact" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setDensity(density === "compact" ? "comfortable" : "compact")
|
||||
}
|
||||
className={`size-8 rounded-none border-l border-border ${density === "compact" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
title={t(
|
||||
density === "compact"
|
||||
? "fileManager.comfortableLayout"
|
||||
: "fileManager.compactLayout",
|
||||
)}
|
||||
>
|
||||
<Rows3 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="hidden md:block cursor-pointer"
|
||||
title={t("fileManager.upload")}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files) handleFilesDropped(files);
|
||||
}}
|
||||
/>
|
||||
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
|
||||
<Upload className="size-3.5" /> {t("fileManager.upload")}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t("fileManager.new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44 rounded-none border-border bg-card"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFolder(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FolderPlus className="size-4 text-accent-brand" />
|
||||
{t("fileManager.newFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFile(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FilePlus className="size-4 text-muted-foreground" />
|
||||
{t("fileManager.newFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
|
||||
{t("fileManager.sortBy")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(value) => setSortBy(value as SortBy)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="name"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByName")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="modified"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByDate")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="size"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortBySize")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortOrder}
|
||||
onValueChange={(value) => setSortOrder(value as SortOrder)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="asc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.ascending")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="desc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.descending")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
|
||||
<PathBar
|
||||
currentPath={currentPath}
|
||||
navigateTo={navigateTo}
|
||||
t={t}
|
||||
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RotateCcw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
emptySSHTrash,
|
||||
getSSHTrash,
|
||||
permanentlyDeleteSSHTrashItem,
|
||||
restoreSSHTrashItem,
|
||||
updateSSHTrashRetention,
|
||||
type TrashItem,
|
||||
} from "./api/ssh-file-operations-api";
|
||||
|
||||
export function FileManagerTrashDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<TrashItem[]>([]);
|
||||
const [retentionDays, setRetentionDays] = useState(7);
|
||||
const [canManageRetention, setCanManageRetention] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getSSHTrash(sessionId);
|
||||
setItems(result.items);
|
||||
setRetentionDays(result.retentionDays);
|
||||
setCanManageRetention(result.canManageRetention);
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [load, open]);
|
||||
|
||||
async function restore(item: TrashItem) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await restoreSSHTrashItem(sessionId, item.id);
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
onChanged();
|
||||
toast.success(t("fileManager.trashRestored", { name: item.name }));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashRestoreFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: TrashItem) {
|
||||
if (!sessionId) return;
|
||||
if (
|
||||
!window.confirm(t("fileManager.trashDeleteConfirm", { name: item.name }))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await permanentlyDeleteSSHTrashItem(sessionId, item.id);
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
toast.success(
|
||||
t("fileManager.trashPermanentlyDeleted", { name: item.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashDeleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function empty() {
|
||||
if (!sessionId || items.length === 0) return;
|
||||
if (!window.confirm(t("fileManager.trashEmptyConfirm"))) return;
|
||||
try {
|
||||
await emptySSHTrash(sessionId);
|
||||
setItems([]);
|
||||
toast.success(t("fileManager.trashEmptied"));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashDeleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85dvh] w-[calc(100vw-2rem)] flex-col sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 pr-8">
|
||||
<DialogTitle>{t("fileManager.trash")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("fileManager.trashDescription", { days: retentionDays })}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/trash"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canManageRetention && (
|
||||
<div className="flex shrink-0 items-center gap-2 border border-border p-2 text-xs">
|
||||
<span className="flex-1">{t("fileManager.trashRetention")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={retentionDays}
|
||||
onChange={(event) => setRetentionDays(Number(event.target.value))}
|
||||
className="h-8 w-20 border border-border bg-background px-2"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!sessionId || retentionDays < 1 || retentionDays > 3650)
|
||||
return;
|
||||
try {
|
||||
await updateSSHTrashRetention(sessionId, retentionDays);
|
||||
toast.success(t("fileManager.trashRetentionSaved"));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashRetentionFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto border border-border">
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("fileManager.trashEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-2 border-b border-border p-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3"
|
||||
>
|
||||
<Trash2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={item.originalPath}
|
||||
>
|
||||
{item.originalPath}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(item.deletedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void restore(item)}
|
||||
>
|
||||
<RotateCcw className="mr-1 size-3.5" />
|
||||
{t("fileManager.trashRestore")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void remove(item)}
|
||||
>
|
||||
{t("fileManager.trashDeletePermanently")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={items.length === 0}
|
||||
onClick={() => void empty()}
|
||||
>
|
||||
{t("fileManager.trashEmptyAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { FolderSearch } from "lucide-react";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostFilesTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.fileManager")}
|
||||
icon={<FolderSearch className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableFileManagerMonitor")}
|
||||
description={t("hosts.enableFileManagerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableFileManager}
|
||||
onChange={(v) => setField("enableFileManager", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.scpLegacyLabel")}
|
||||
description={t("hosts.scpLegacyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.scpLegacy}
|
||||
onChange={(v) => setField("scpLegacy", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.defaultPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={form.defaultPath}
|
||||
onChange={(e) => setField("defaultPath", e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.fileManagerPathHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
Clipboard,
|
||||
Edit3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
export interface LocalFileContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Entries the menu acts on; empty means the pane background was clicked. */
|
||||
entries: LocalFileEntry[];
|
||||
isVisible: boolean;
|
||||
showHidden: boolean;
|
||||
canUpload: boolean;
|
||||
onClose: () => void;
|
||||
onOpen: (entry: LocalFileEntry) => void;
|
||||
onUploadToRemote: (entries: LocalFileEntry[]) => void;
|
||||
onReveal: (entry?: LocalFileEntry) => void;
|
||||
onRename: (entry: LocalFileEntry) => void;
|
||||
onCopyPath: (entries: LocalFileEntry[]) => void;
|
||||
onNewFolder: () => void;
|
||||
onNewFile: () => void;
|
||||
onRefresh: () => void;
|
||||
onToggleHidden: () => void;
|
||||
onDelete: (entries: LocalFileEntry[]) => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
action?: () => void;
|
||||
shortcut?: string;
|
||||
separator?: boolean;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click menu for the local pane. Same chrome and dismissal rules as
|
||||
* FileManagerContextMenu, with the subset of actions that make sense for the
|
||||
* user's own disk.
|
||||
*/
|
||||
export function LocalFileContextMenu({
|
||||
x,
|
||||
y,
|
||||
entries,
|
||||
isVisible,
|
||||
showHidden,
|
||||
canUpload,
|
||||
onClose,
|
||||
onOpen,
|
||||
onUploadToRemote,
|
||||
onReveal,
|
||||
onRename,
|
||||
onCopyPath,
|
||||
onNewFolder,
|
||||
onNewFile,
|
||||
onRefresh,
|
||||
onToggleHidden,
|
||||
onDelete,
|
||||
}: LocalFileContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setIsMounted(false);
|
||||
return;
|
||||
}
|
||||
setIsMounted(true);
|
||||
|
||||
let cleanupFn: (() => void) | null = null;
|
||||
const timeoutId = setTimeout(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) onClose();
|
||||
};
|
||||
const handleRightClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleBlur = () => onClose();
|
||||
const handleScroll = () => onClose();
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside, true);
|
||||
document.addEventListener("contextmenu", handleRightClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
cleanupFn = () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanupFn?.();
|
||||
};
|
||||
}, [isVisible, x, y, onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !menuRef.current) return;
|
||||
const menuWidth = menuRef.current.offsetWidth;
|
||||
const menuHeight = menuRef.current.offsetHeight;
|
||||
let adjustedX = x;
|
||||
let adjustedY = y;
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
adjustedX = window.innerWidth - menuWidth - 10;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
adjustedY = Math.max(10, window.innerHeight - menuHeight - 10);
|
||||
}
|
||||
setMenuPosition({ x: Math.max(8, adjustedX), y: Math.max(8, adjustedY) });
|
||||
}, [isVisible, x, y, entries.length]);
|
||||
|
||||
const isEntryContext = entries.length > 0;
|
||||
const isSingle = entries.length === 1;
|
||||
const single = isSingle ? entries[0] : null;
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (isEntryContext) {
|
||||
if (single) {
|
||||
menuItems.push({
|
||||
icon: <ExternalLink className="size-3.5" />,
|
||||
label:
|
||||
single.type === "directory"
|
||||
? t("fileManager.localOpenFolder")
|
||||
: t("fileManager.localOpen"),
|
||||
action: () => onOpen(single),
|
||||
shortcut: "Enter",
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
icon: <Upload className="size-3.5" />,
|
||||
label: isSingle
|
||||
? t("fileManager.localUploadToRemote")
|
||||
: t("fileManager.localUploadToRemoteMany", { count: entries.length }),
|
||||
action: () => onUploadToRemote(entries),
|
||||
disabled: !canUpload,
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
icon: <FolderOpen className="size-3.5" />,
|
||||
label: t("fileManager.localRevealInFileManager"),
|
||||
action: () => onReveal(entries[0]),
|
||||
});
|
||||
|
||||
menuItems.push({ separator: true });
|
||||
|
||||
if (single) {
|
||||
menuItems.push({
|
||||
icon: <Edit3 className="size-3.5" />,
|
||||
label: t("fileManager.rename"),
|
||||
action: () => onRename(single),
|
||||
shortcut: "F2",
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="size-3.5" />,
|
||||
label: isSingle ? t("fileManager.copyPath") : t("fileManager.copyPaths"),
|
||||
action: () => onCopyPath(entries),
|
||||
});
|
||||
|
||||
menuItems.push({ separator: true });
|
||||
|
||||
menuItems.push({
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
label: isSingle
|
||||
? t("fileManager.localMoveToTrash")
|
||||
: t("fileManager.localMoveToTrashMany", { count: entries.length }),
|
||||
action: () => onDelete(entries),
|
||||
shortcut: "Del",
|
||||
danger: true,
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
icon: <FolderPlus className="size-3.5" />,
|
||||
label: t("fileManager.newFolder"),
|
||||
action: onNewFolder,
|
||||
});
|
||||
menuItems.push({
|
||||
icon: <FilePlus className="size-3.5" />,
|
||||
label: t("fileManager.newFile"),
|
||||
action: onNewFile,
|
||||
});
|
||||
menuItems.push({ separator: true });
|
||||
menuItems.push({
|
||||
icon: <FolderOpen className="size-3.5" />,
|
||||
label: t("fileManager.localRevealInFileManager"),
|
||||
action: () => onReveal(),
|
||||
});
|
||||
menuItems.push({
|
||||
icon: showHidden ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
),
|
||||
label: showHidden
|
||||
? t("fileManager.localHideHidden")
|
||||
: t("fileManager.localShowHidden"),
|
||||
action: onToggleHidden,
|
||||
});
|
||||
menuItems.push({
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
label: t("fileManager.refresh"),
|
||||
action: onRefresh,
|
||||
shortcut: "F5",
|
||||
});
|
||||
}
|
||||
|
||||
const renderShortcut = (shortcut: string) => {
|
||||
const keys = shortcut.split("+");
|
||||
if (keys.length === 1) return <Kbd>{keys[0]}</Kbd>;
|
||||
return (
|
||||
<Kbd>
|
||||
{keys.map((key, index) => (
|
||||
<React.Fragment key={`${key}-${index}`}>
|
||||
<KbdKey>{key}</KbdKey>
|
||||
{index < keys.length - 1 && <KbdSeparator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Kbd>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isVisible && !isMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||
!isMounted && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
data-testid="local-file-context-menu"
|
||||
className="fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1"
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
className="my-1 border-t border-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${item.label}-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
|
||||
"hover:bg-accent-brand/10 hover:text-accent-brand",
|
||||
item.disabled &&
|
||||
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
|
||||
item.danger &&
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.action?.();
|
||||
onClose();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 text-muted-foreground">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="flex-1 leading-tight">{item.label}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<div className="ml-auto flex-shrink-0 opacity-50">
|
||||
{renderShortcut(item.shortcut)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Button,
|
||||
PasswordInput,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SudoPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (password: string) => void;
|
||||
}
|
||||
|
||||
export function SudoPasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: SudoPasswordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword("");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
onSubmit(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
{t("fileManager.sudoPasswordRequired")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||
{t("fileManager.enterSudoPassword")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("fileManager.sudoPassword")}
|
||||
className="rounded-none bg-muted/50 border-border text-xs focus:ring-1 focus:ring-accent-brand/50"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!password.trim() || loading}
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{loading ? t("common.loading") : t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
|
||||
import type { TFunction } from "i18next";
|
||||
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
|
||||
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
|
||||
import {
|
||||
beginTransferProgressMonitoring,
|
||||
isTransferBeingMonitored,
|
||||
showTransferCompletionToast,
|
||||
} from "./transferProgressMonitor.tsx";
|
||||
import {
|
||||
clearStalePendingTransfer,
|
||||
getPendingTransferIds,
|
||||
isTransferNotified,
|
||||
} from "./transferNotificationStore.ts";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Resumes progress toasts for in-flight host-to-host transfers across app
|
||||
* reloads and tab switches. No UI of its own, so it runs as plain background
|
||||
* polling from activate() rather than a mounted headless component - core
|
||||
* cannot mount a plugin component outside a tab/panel/slot, and this needs
|
||||
* nothing from one.
|
||||
*/
|
||||
export function startTransferMonitor(translate: TranslateFn): () => void {
|
||||
// Every call here is a plain t("key") / t("key", {options}) - the calls
|
||||
// these helpers make everywhere else, so the SDK's simpler translate
|
||||
// function satisfies the same shape they're already typed against.
|
||||
const t = translate as unknown as TFunction;
|
||||
const formatTransferMetrics = createFormatTransferMetrics(t);
|
||||
|
||||
const reconcileTransfers = async () => {
|
||||
let hasWork = false;
|
||||
try {
|
||||
const { transfers } = await listActiveTransfers();
|
||||
hasWork = transfers.length > 0;
|
||||
for (const transfer of transfers) {
|
||||
if (isTransferBeingMonitored(transfer.transferId)) continue;
|
||||
beginTransferProgressMonitoring(transfer.transferId, t, {
|
||||
resumed: true,
|
||||
initialStatus: transfer,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: file-manager service may be unavailable briefly
|
||||
}
|
||||
|
||||
const pendingTransferIds = getPendingTransferIds();
|
||||
for (const transferId of pendingTransferIds) {
|
||||
if (
|
||||
isTransferBeingMonitored(transferId) ||
|
||||
isTransferNotified(transferId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
hasWork = true;
|
||||
|
||||
try {
|
||||
const status = await getTransferStatus(transferId);
|
||||
if (status.status === "running") {
|
||||
if (!isTransferBeingMonitored(transferId)) {
|
||||
beginTransferProgressMonitoring(transferId, t, {
|
||||
resumed: true,
|
||||
initialStatus: status,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
showTransferCompletionToast(
|
||||
status,
|
||||
t,
|
||||
undefined,
|
||||
formatTransferMetrics,
|
||||
);
|
||||
} catch {
|
||||
clearStalePendingTransfer(transferId);
|
||||
}
|
||||
}
|
||||
return hasWork;
|
||||
};
|
||||
|
||||
return runAdaptivePolling(reconcileTransfers, {
|
||||
minIntervalMs: POLL_INTERVAL_MS,
|
||||
maxIntervalMs: 30_000,
|
||||
stablePollsPerStep: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { fileManagerApi, handleApiError } from "@/main-axios";
|
||||
|
||||
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
|
||||
export interface FileManagerEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// FILE MANAGER DATA
|
||||
// ============================================================================
|
||||
|
||||
export async function getRecentFiles(
|
||||
hostId: number,
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/recent", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get recent files");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRecentFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/recent", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add recent file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRecentFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/recent", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove recent file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPinnedFiles(
|
||||
hostId: number,
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/pinned", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get pinned files");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPinnedFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/pinned", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add pinned file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePinnedFile(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/pinned", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove pinned file");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFolderShortcuts(
|
||||
hostId: number,
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/shortcuts", {
|
||||
params: { hostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get folder shortcuts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFolderShortcut(
|
||||
hostId: number,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/shortcuts", {
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add folder shortcut");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFolderShortcut(
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/shortcuts", {
|
||||
data: { hostId, path },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove folder shortcut");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,184 @@
|
||||
// Local disk <-> remote SFTP transfers for the desktop app's dual-pane file
|
||||
// manager. The bytes never pass through the renderer: the Electron main
|
||||
// process streams them between disk and the backend's existing
|
||||
// `uploadFileStream` / `downloadFileStream` routes (see
|
||||
// electron/local-files.cjs), and we only orchestrate + relay progress here.
|
||||
//
|
||||
// The renderer deliberately does not choose the URL or the auth headers. It
|
||||
// names the origin that owns the SSH session ("local" embedded backend or the
|
||||
// configured Remote Sync server) and the main process resolves the rest, so a
|
||||
// compromised renderer cannot turn the transfer bridge into an HTTP client.
|
||||
|
||||
import type {
|
||||
LocalTransferOrigin,
|
||||
LocalTransferProgress,
|
||||
} from "@/types/electron";
|
||||
import { getSessionOrigin } from "@/main-axios";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
export interface LocalTransferProgressEvent {
|
||||
transferred: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
type ProgressListener = (event: LocalTransferProgressEvent) => void;
|
||||
|
||||
/** Error from the main process; `code` is e.g. "EEXIST" or "EBUSY". */
|
||||
export class LocalTransferError extends Error {
|
||||
code?: string;
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.name = "LocalTransferError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const progressListeners = new Map<string, ProgressListener>();
|
||||
let unsubscribeProgress: (() => void) | null = null;
|
||||
|
||||
function ensureProgressSubscription() {
|
||||
if (unsubscribeProgress) return;
|
||||
const api = window.electronAPI?.localTransfer;
|
||||
if (!api) return;
|
||||
unsubscribeProgress = api.onProgress((payload: LocalTransferProgress) => {
|
||||
const listener = progressListeners.get(payload.transferId);
|
||||
listener?.({ transferred: payload.transferred, total: payload.total });
|
||||
});
|
||||
}
|
||||
|
||||
function requireTransferApi() {
|
||||
const api = window.electronAPI?.localTransfer;
|
||||
if (!api) {
|
||||
throw new Error("Local transfers are only available in the desktop app");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function transferOriginFor(sessionId: string): LocalTransferOrigin {
|
||||
return getSessionOrigin(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The token the desktop renderer uses for the embedded backend. The `jwt`
|
||||
* cookie the main process would otherwise rely on lasts only a day after an
|
||||
* interactive login, while this token (and every request the app makes with
|
||||
* it) keeps working, so transfers must carry it too. Remote-origin transfers
|
||||
* are authenticated by the main process from its own Remote Sync state.
|
||||
*/
|
||||
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
|
||||
if (origin !== "local") return undefined;
|
||||
try {
|
||||
return localStorage.getItem("jwt") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalTransferId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a file from the local disk into `remoteDir` on the SSH session,
|
||||
* using the same multipart route the in-browser upload uses.
|
||||
*/
|
||||
export async function uploadLocalFileToSession(options: {
|
||||
sessionId: string;
|
||||
remoteDir: string;
|
||||
localPath: string;
|
||||
fileName: string;
|
||||
hostId?: number;
|
||||
transferId?: string;
|
||||
onProgress?: ProgressListener;
|
||||
}): Promise<void> {
|
||||
const api = requireTransferApi();
|
||||
ensureProgressSubscription();
|
||||
|
||||
const transferId =
|
||||
options.transferId ?? createLocalTransferId("local-upload");
|
||||
|
||||
const fields: Record<string, string> = {
|
||||
sessionId: options.sessionId,
|
||||
path: options.remoteDir,
|
||||
};
|
||||
if (options.hostId !== undefined) fields.hostId = String(options.hostId);
|
||||
|
||||
if (options.onProgress) {
|
||||
progressListeners.set(transferId, options.onProgress);
|
||||
}
|
||||
const origin = transferOriginFor(options.sessionId);
|
||||
try {
|
||||
const result = await api.upload({
|
||||
transferId,
|
||||
origin,
|
||||
deviceId: getDeviceId() ?? undefined,
|
||||
authToken: localAuthTokenFor(origin),
|
||||
fields,
|
||||
localPath: options.localPath,
|
||||
fileName: options.fileName,
|
||||
});
|
||||
if (result.success !== true) {
|
||||
throw new LocalTransferError(
|
||||
result.error || "Upload failed",
|
||||
result.code,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
progressListeners.delete(transferId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a remote file from the SSH session straight to `destPath` on the
|
||||
* local disk. Refuses to replace an existing file (code "EEXIST") unless
|
||||
* `overwrite` is set.
|
||||
*/
|
||||
export async function downloadSessionFileToLocal(options: {
|
||||
sessionId: string;
|
||||
remotePath: string;
|
||||
destPath: string;
|
||||
/** The folder the user picked; the main process refuses any destination outside it. */
|
||||
rootPath: string;
|
||||
expectedSize?: number;
|
||||
overwrite?: boolean;
|
||||
transferId?: string;
|
||||
onProgress?: ProgressListener;
|
||||
}): Promise<void> {
|
||||
const api = requireTransferApi();
|
||||
ensureProgressSubscription();
|
||||
|
||||
const transferId =
|
||||
options.transferId ?? createLocalTransferId("local-download");
|
||||
|
||||
if (options.onProgress) {
|
||||
progressListeners.set(transferId, options.onProgress);
|
||||
}
|
||||
const origin = transferOriginFor(options.sessionId);
|
||||
try {
|
||||
const result = await api.download({
|
||||
transferId,
|
||||
origin,
|
||||
deviceId: getDeviceId() ?? undefined,
|
||||
authToken: localAuthTokenFor(origin),
|
||||
body: { sessionId: options.sessionId, path: options.remotePath },
|
||||
destPath: options.destPath,
|
||||
rootPath: options.rootPath,
|
||||
expectedSize: options.expectedSize,
|
||||
overwrite: options.overwrite === true,
|
||||
});
|
||||
if (result.success !== true) {
|
||||
throw new LocalTransferError(
|
||||
result.error || "Download failed",
|
||||
result.code,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
progressListeners.delete(transferId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelLocalTransfer(transferId: string): Promise<void> {
|
||||
const api = window.electronAPI?.localTransfer;
|
||||
if (!api) return;
|
||||
await api.cancel(transferId);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { Music } from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface AudioPreviewProps {
|
||||
file: FileItem;
|
||||
content: string;
|
||||
color: string;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function getAudioMimeType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
switch (ext) {
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
default:
|
||||
return "audio/mpeg";
|
||||
}
|
||||
}
|
||||
|
||||
export function AudioPreview({
|
||||
file,
|
||||
content,
|
||||
color,
|
||||
onMediaDimensionsChange,
|
||||
}: AudioPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
|
||||
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
<Music className="w-16 h-16 text-pink-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||||
{file.name.replace(/\.[^/.]+$/, "")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ext.toUpperCase()} • {formatFileSize(file.size, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onLoadedMetaData={() => {
|
||||
onMediaDimensionsChange?.({
|
||||
width: 600,
|
||||
height: 400,
|
||||
});
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("Audio playback error:", e);
|
||||
}}
|
||||
showJumpControls={false}
|
||||
showSkipControls={false}
|
||||
showDownloadProgress={true}
|
||||
customAdditionalControls={[]}
|
||||
customVolumeControls={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import {
|
||||
loadLanguage,
|
||||
type LanguageName,
|
||||
} from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
toggleComment,
|
||||
} from "@codemirror/commands";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
|
||||
export interface CodeEditorHandle {
|
||||
openSearchPanel: () => void;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
fileName: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName as LanguageName);
|
||||
}
|
||||
|
||||
const langMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
py: "python",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "sass",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
md: "markdown",
|
||||
conf: "shell",
|
||||
ini: "properties",
|
||||
};
|
||||
|
||||
const language = langMap[ext];
|
||||
return language ? loadLanguage(language as LanguageName) : null;
|
||||
}
|
||||
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
function CodeEditor(
|
||||
{ fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 },
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<{ view?: EditorView } | null>(null);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const languageExtension = getLanguageExtension(fileName);
|
||||
|
||||
return [
|
||||
...(languageExtension ? [languageExtension] : []),
|
||||
history(),
|
||||
search(),
|
||||
autocompletion(),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
{
|
||||
key: "Mod-/",
|
||||
run: toggleComment,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: "Mod-h",
|
||||
run: () => false,
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: `${fontSize}px`,
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [fileName, fontSize]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openSearchPanel: () => {
|
||||
const view = editorRef.current?.view;
|
||||
if (view) {
|
||||
openSearchPanel(view);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
extensions={extensions}
|
||||
theme={oneDark}
|
||||
placeholder={placeholder}
|
||||
className="h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface ColumnResizeHandleProps {
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
onDoubleClick: (event: React.MouseEvent) => void;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
"data-resizing"?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin grab area on the left edge of a list-view header cell. The parent cell
|
||||
* must be `relative`. Shows a hairline on hover and while dragging.
|
||||
*/
|
||||
export function ColumnResizeHandle({
|
||||
title,
|
||||
"data-resizing": resizing,
|
||||
...handlers
|
||||
}: ColumnResizeHandleProps) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={title}
|
||||
data-column-resize-handle
|
||||
data-resizing={resizing}
|
||||
className={cn(
|
||||
"absolute -left-2 top-0 bottom-0 w-3 cursor-col-resize group/handle z-10",
|
||||
)}
|
||||
{...handlers}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-0.5 bottom-0.5 w-px -translate-x-1/2 transition-colors",
|
||||
resizing
|
||||
? "bg-accent-brand"
|
||||
: "bg-transparent group-hover/handle:bg-accent-brand/60",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
|
||||
|
||||
interface ColumnVisibilityMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
isVisible: boolean;
|
||||
columns: ResizableColumns;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click menu for a list-view header: tick/untick which optional
|
||||
* columns are shown. The name column is always on and not listed.
|
||||
*/
|
||||
export function ColumnVisibilityMenu({
|
||||
x,
|
||||
y,
|
||||
isVisible,
|
||||
columns,
|
||||
onClose,
|
||||
}: ColumnVisibilityMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x, y });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleMouseDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", onClose);
|
||||
}, 50);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", onClose);
|
||||
};
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !menuRef.current) return;
|
||||
const { offsetWidth, offsetHeight } = menuRef.current;
|
||||
setPosition({
|
||||
x: Math.max(8, Math.min(x, window.innerWidth - offsetWidth - 10)),
|
||||
y: Math.max(8, Math.min(y, window.innerHeight - offsetHeight - 10)),
|
||||
});
|
||||
}, [isVisible, x, y]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const onlyOneVisible = columns.visibleColumns.length <= 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
data-testid="column-visibility-menu"
|
||||
className="fixed bg-card border border-border rounded-none shadow-md min-w-[200px] z-[99995] py-1"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
<div className="px-3 pt-1.5 pb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("fileManager.columns")}
|
||||
</div>
|
||||
{columns.columns.map((column) => {
|
||||
const visible = columns.isVisible(column.key);
|
||||
// Keep at least one optional column so the header stays meaningful.
|
||||
const disabled = visible && onlyOneVisible;
|
||||
return (
|
||||
<button
|
||||
key={column.key}
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={visible}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center gap-2 rounded-none transition-colors cursor-pointer",
|
||||
"hover:bg-accent-brand/10 hover:text-accent-brand",
|
||||
disabled &&
|
||||
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) columns.toggleColumn(column.key);
|
||||
}}
|
||||
>
|
||||
<span className="size-3.5 flex items-center justify-center text-accent-brand">
|
||||
{visible && <Check className="size-3.5" />}
|
||||
</span>
|
||||
<span className="flex-1 leading-tight">{t(column.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { Package } from "lucide-react";
|
||||
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) {
|
||||
if (fileNames.length === 1) {
|
||||
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
|
||||
setArchiveName(baseName);
|
||||
} else {
|
||||
setArchiveName("archive");
|
||||
}
|
||||
}
|
||||
}, [open, fileNames]);
|
||||
|
||||
const handleCompress = () => {
|
||||
if (!archiveName.trim()) return;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const formats = ["zip", "tar.gz", "tar.bz2", "tar.xz", "tar", "7z"] as const;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Package className="size-4 text-accent-brand" />
|
||||
{t("fileManager.compressFiles")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
className="text-xs font-bold uppercase tracking-widest text-muted-foreground"
|
||||
htmlFor="archiveName"
|
||||
>
|
||||
{t("fileManager.archiveName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="archiveName"
|
||||
value={archiveName}
|
||||
onChange={(e) => setArchiveName(e.target.value)}
|
||||
placeholder={t("fileManager.enterArchiveName")}
|
||||
className="rounded-none bg-muted/50 border-border text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCompress();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("fileManager.compressionFormat")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFormat(f)}
|
||||
className={`py-2 text-xs font-bold uppercase tracking-widest border transition-colors ${
|
||||
format === f
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
.{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-none bg-muted/10 border border-border p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
{t("fileManager.selectedFiles")}:
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{fileNames.slice(0, 5).map((name, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="truncate text-foreground font-medium"
|
||||
>
|
||||
• {name}
|
||||
</li>
|
||||
))}
|
||||
{fileNames.length > 5 && (
|
||||
<li className="text-muted-foreground">
|
||||
{t("fileManager.andMoreFiles", {
|
||||
count: fileNames.length - 5,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCompress}
|
||||
disabled={!archiveName.trim()}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
<Package className="size-3.5 mr-1" />
|
||||
{t("fileManager.compress")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowLeftRight,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
readSSHFile,
|
||||
downloadSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "../api/ssh-file-operations-api";
|
||||
import type { FileItem, SSHHost } from "@/types";
|
||||
|
||||
interface DiffViewerProps {
|
||||
file1: FileItem;
|
||||
file2: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
onDownload1?: () => void;
|
||||
onDownload2?: () => void;
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
file1,
|
||||
file2,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content1, setContent1] = useState<string>("");
|
||||
const [content2, setContent2] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
|
||||
"side-by-side",
|
||||
);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadFileContents = async () => {
|
||||
if (file1.type !== "file" || file2.type !== "file") {
|
||||
setError(t("fileManager.canOnlyCompareFiles"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const [response1, response2] = await Promise.all([
|
||||
readSSHFile(sshSessionId, file1.path),
|
||||
readSSHFile(sshSessionId, file2.path),
|
||||
]);
|
||||
|
||||
setContent1(response1.content || "");
|
||||
setContent2(response2.content || "");
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load files for diff:", error);
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
response?: { data?: { tooLarge?: boolean; error?: string } };
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
|
||||
} else if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
setError(
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: sshHost.name,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t("fileManager.loadFileFailed", {
|
||||
error:
|
||||
err.message || errorData?.error || t("fileManager.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (file: FileItem) => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.downloadFileSuccess", { name: file.name }),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
t("fileManager.downloadFileFailed") +
|
||||
": " +
|
||||
(err.message || t("fileManager.unknownError")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileLanguage = (fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const languageMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
md: "markdown",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
ps1: "powershell",
|
||||
dockerfile: "dockerfile",
|
||||
};
|
||||
return languageMap[ext || ""] || "plaintext";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFileContents();
|
||||
}, [file1, file2, sshSessionId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-card">
|
||||
<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("fileManager.loadingFileComparison")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-card">
|
||||
<div className="text-center max-w-md">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={loadFileContents} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
<div className="flex-shrink-0 border-b border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("fileManager.compare")}:
|
||||
</span>
|
||||
<span className="font-medium text-green-400 mx-2">
|
||||
{file1.name}
|
||||
</span>
|
||||
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
|
||||
<span className="font-medium text-blue-400">{file2.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDiffMode(
|
||||
diffMode === "side-by-side" ? "inline" : "side-by-side",
|
||||
)
|
||||
}
|
||||
>
|
||||
{diffMode === "side-by-side"
|
||||
? t("fileManager.sideBySide")
|
||||
: t("fileManager.inline")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowLineNumbers(!showLineNumbers)}
|
||||
>
|
||||
{showLineNumbers ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file1)}
|
||||
title={t("fileManager.downloadFile", { name: file1.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file1.name}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file2)}
|
||||
title={t("fileManager.downloadFile", { name: file2.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file2.name}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<DiffEditor
|
||||
original={content1}
|
||||
modified={content2}
|
||||
language={getFileLanguage(file1.name)}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
renderSideBySide: diffMode === "side-by-side",
|
||||
lineNumbers: showLineNumbers ? "on" : "off",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
wordWrap: "off",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
scrollbar: {
|
||||
vertical: "visible",
|
||||
horizontal: "visible",
|
||||
},
|
||||
diffWordWrap: "off",
|
||||
ignoreTrimWhitespace: false,
|
||||
}}
|
||||
loading={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.initializingEditor")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { DiffViewer } from "./DiffViewer.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
|
||||
interface DiffWindowProps {
|
||||
windowId: string;
|
||||
file1: FileItem;
|
||||
file2: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
}
|
||||
|
||||
export function DiffWindow({
|
||||
windowId,
|
||||
file1,
|
||||
file2,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 150,
|
||||
initialY = 100,
|
||||
}: DiffWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={t("fileManager.fileComparison", {
|
||||
file1: file1.name,
|
||||
file2: file2.name,
|
||||
})}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={1200}
|
||||
initialHeight={700}
|
||||
minWidth={800}
|
||||
minHeight={500}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<DiffViewer
|
||||
file1={file1}
|
||||
file2={file2}
|
||||
sshSessionId={sshSessionId}
|
||||
sshHost={sshHost}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { formatTransferMbPerSec } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDownToLine } from "lucide-react";
|
||||
import { TransferProgressBar } from "./TransferProgressBar";
|
||||
|
||||
interface DownloadProgressToastProps {
|
||||
fileName: string;
|
||||
loaded: number;
|
||||
total?: number;
|
||||
mbPerSec?: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({
|
||||
fileName,
|
||||
loaded,
|
||||
total,
|
||||
mbPerSec,
|
||||
}: DownloadProgressToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent =
|
||||
total !== undefined && total > 0
|
||||
? Math.min(100, Math.round((loaded / total) * 100))
|
||||
: undefined;
|
||||
|
||||
const speed = formatTransferMbPerSec(mbPerSec);
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
|
||||
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="truncate">
|
||||
{t("fileManager.downloadingFile", { name: fileName })}
|
||||
</span>
|
||||
</p>
|
||||
<TransferProgressBar
|
||||
value={percent}
|
||||
label={t("fileManager.downloadingFile", { name: fileName })}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
{total !== undefined
|
||||
? t("fileManager.downloadProgressBytes", {
|
||||
transferred: formatBytes(loaded),
|
||||
total: formatBytes(total),
|
||||
})
|
||||
: formatBytes(loaded)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 tabular-nums ${speed ? "font-medium text-foreground" : "invisible"}`}
|
||||
aria-hidden={!speed}
|
||||
>
|
||||
{speed || "0 MB/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DraggableWindowProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
onClose: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onResize?: () => void;
|
||||
isMaximized?: boolean;
|
||||
zIndex?: number;
|
||||
onFocus?: () => void;
|
||||
targetSize?: { width: number; height: number };
|
||||
titleActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DraggableWindow({
|
||||
title,
|
||||
children,
|
||||
initialX = 100,
|
||||
initialY = 100,
|
||||
initialWidth = 600,
|
||||
initialHeight = 400,
|
||||
minWidth = 300,
|
||||
minHeight = 200,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onResize,
|
||||
isMaximized = false,
|
||||
zIndex = 1000,
|
||||
onFocus,
|
||||
targetSize,
|
||||
titleActions,
|
||||
}: DraggableWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
const [size, setSize] = useState({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||
const containerBoundsRef = useRef({ width: 0, height: 0 });
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetSize && !isMaximized) {
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
const maxWidth = container
|
||||
? Math.min(container.clientWidth * 0.9, 1200)
|
||||
: Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = container
|
||||
? Math.min(container.clientHeight * 0.8, 800)
|
||||
: Math.min(window.innerHeight * 0.8, 800);
|
||||
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth);
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight);
|
||||
|
||||
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||
const widthRatio = maxWidth / newWidth;
|
||||
const heightRatio = maxHeight / newHeight;
|
||||
const scale = Math.min(widthRatio, heightRatio);
|
||||
|
||||
newWidth = Math.floor(newWidth * scale);
|
||||
newHeight = Math.floor(newHeight * scale);
|
||||
}
|
||||
|
||||
newWidth = Math.max(newWidth, minWidth);
|
||||
newHeight = Math.max(newHeight, minHeight);
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
(container ? container.clientWidth : window.innerWidth) / 2 -
|
||||
newWidth / 2,
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
(container ? container.clientHeight : window.innerHeight) / 2 -
|
||||
newHeight / 2,
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||
|
||||
const handleWindowClick = useCallback(() => {
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isMaximized) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
containerBoundsRef.current = {
|
||||
width: container ? container.clientWidth : window.innerWidth,
|
||||
height: container ? container.clientHeight : window.innerHeight,
|
||||
};
|
||||
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, onFocus],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
const newX = windowStart.x + deltaX;
|
||||
const newY = windowStart.y + deltaY;
|
||||
|
||||
const { width: containerW, height: containerH } =
|
||||
containerBoundsRef.current;
|
||||
const maxX = containerW - size.width;
|
||||
const maxY = containerH - size.height;
|
||||
|
||||
setPosition({
|
||||
x: Math.max(0, Math.min(maxX, newX)),
|
||||
y: Math.max(49, Math.min(maxY, newY)),
|
||||
});
|
||||
}
|
||||
|
||||
if (isResizing && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
let newWidth = sizeStart.width;
|
||||
let newHeight = sizeStart.height;
|
||||
let newX = windowStart.x;
|
||||
let newY = windowStart.y;
|
||||
|
||||
if (resizeDirection.includes("right")) {
|
||||
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes("left")) {
|
||||
const widthChange = -deltaX;
|
||||
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
||||
if (newWidth > minWidth || widthChange > 0) {
|
||||
newX = windowStart.x - (newWidth - sizeStart.width);
|
||||
} else {
|
||||
newX = windowStart.x - (minWidth - sizeStart.width);
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeDirection.includes("bottom")) {
|
||||
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes("top")) {
|
||||
const heightChange = -deltaY;
|
||||
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
||||
if (newHeight > minHeight || heightChange > 0) {
|
||||
newY = windowStart.y - (newHeight - sizeStart.height);
|
||||
} else {
|
||||
newY = windowStart.y - (minHeight - sizeStart.height);
|
||||
}
|
||||
}
|
||||
|
||||
const { width: containerW, height: containerH } =
|
||||
containerBoundsRef.current;
|
||||
newX = Math.max(0, Math.min(containerW - newWidth, newX));
|
||||
newY = Math.max(49, Math.min(containerH - newHeight, newY));
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
|
||||
if (onResize) {
|
||||
onResize();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isDragging,
|
||||
isResizing,
|
||||
isMaximized,
|
||||
dragStart,
|
||||
windowStart,
|
||||
sizeStart,
|
||||
size,
|
||||
minWidth,
|
||||
minHeight,
|
||||
resizeDirection,
|
||||
onResize,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
setResizeDirection("");
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, direction: string) => {
|
||||
if (isMaximized) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
setDragStart({ x: e.clientX, y: e.clientY });
|
||||
setWindowStart({ x: position.x, y: position.y });
|
||||
setSizeStart({ width: size.width, height: size.height });
|
||||
|
||||
const container = windowRef.current?.offsetParent as HTMLElement | null;
|
||||
containerBoundsRef.current = {
|
||||
width: container ? container.clientWidth : window.innerWidth,
|
||||
height: container ? container.clientHeight : window.innerHeight,
|
||||
};
|
||||
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, size, onFocus],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleTitleDoubleClick = useCallback(() => {
|
||||
onMaximize?.();
|
||||
}, [onMaximize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={windowRef}
|
||||
className={cn(
|
||||
"absolute bg-[#0d0e0c] border border-border rounded-none shadow-2xl",
|
||||
"select-none overflow-hidden flex flex-col",
|
||||
isMaximized ? "inset-0" : "",
|
||||
)}
|
||||
style={{
|
||||
left: isMaximized ? 0 : position.x,
|
||||
top: isMaximized ? 0 : position.y,
|
||||
width: isMaximized ? "100%" : size.width,
|
||||
height: isMaximized ? "100%" : size.height,
|
||||
zIndex,
|
||||
}}
|
||||
onClick={handleWindowClick}
|
||||
>
|
||||
<div
|
||||
ref={titleBarRef}
|
||||
className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border shrink-0 cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleTitleDoubleClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 overflow-hidden">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-accent-brand shrink-0">
|
||||
{t("fileManager.editor")}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-border/50 shrink-0" />
|
||||
<span className="text-[10px] font-bold tracking-tight text-muted-foreground truncate">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{titleActions}
|
||||
|
||||
{onMinimize && (
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMinimize();
|
||||
}}
|
||||
title={t("common.minimize")}
|
||||
>
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onMaximize && (
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMaximize();
|
||||
}}
|
||||
title={isMaximized ? t("common.restore") : t("common.maximize")}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="size-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">{children}</div>
|
||||
|
||||
{!isMaximized && (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "right")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top-right")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Film,
|
||||
Music,
|
||||
File as FileIcon,
|
||||
Code,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Eye,
|
||||
Edit,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Keyboard,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SiJavascript,
|
||||
SiTypescript,
|
||||
SiPython,
|
||||
SiCplusplus,
|
||||
SiC,
|
||||
SiDotnet,
|
||||
SiPhp,
|
||||
SiRuby,
|
||||
SiGo,
|
||||
SiRust,
|
||||
SiHtml5,
|
||||
SiSass,
|
||||
SiVuedotjs,
|
||||
SiSvelte,
|
||||
SiMarkdown,
|
||||
SiGnubash,
|
||||
SiMysql,
|
||||
SiDocker,
|
||||
} from "react-icons/si";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Kbd, KbdKey } from "@/components/kbd.tsx";
|
||||
import type { CodeEditorHandle } from "./CodeEditor.tsx";
|
||||
import {
|
||||
loadAudioPreview,
|
||||
loadCodeEditor,
|
||||
loadImagePreview,
|
||||
loadMarkdownRenderer,
|
||||
loadPdfPreview,
|
||||
resolveFilePreviewKind,
|
||||
} from "../file-preview-preload";
|
||||
|
||||
const CodeEditor = lazy(loadCodeEditor);
|
||||
const ImagePreview = lazy(loadImagePreview);
|
||||
const MarkdownRenderer = lazy(loadMarkdownRenderer);
|
||||
const PdfPreview = lazy(loadPdfPreview);
|
||||
const AudioPreview = lazy(loadAudioPreview);
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface FileViewerProps {
|
||||
file: FileItem;
|
||||
content?: string;
|
||||
savedContent?: string;
|
||||
isLoading?: boolean;
|
||||
isSaving?: boolean;
|
||||
isEditable?: boolean;
|
||||
resetKey?: number;
|
||||
onContentChange?: (content: string) => void;
|
||||
onSave?: (content: string) => void;
|
||||
onRevert?: () => void;
|
||||
onDownload?: () => void;
|
||||
onOpenExternal?: () => void;
|
||||
onChooseExternalEditor?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function getLanguageIcon(filename: string): React.ReactNode {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
if (["dockerfile"].includes(baseName)) {
|
||||
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||||
}
|
||||
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return <SiRuby className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||||
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||||
py: <SiPython className="w-6 h-6 text-blue-400" />,
|
||||
java: <Code className="w-6 h-6 text-red-500" />,
|
||||
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
|
||||
c: <SiC className="w-6 h-6 text-blue-700" />,
|
||||
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
|
||||
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
|
||||
rb: <SiRuby className="w-6 h-6 text-red-500" />,
|
||||
go: <SiGo className="w-6 h-6 text-cyan-500" />,
|
||||
rs: <SiRust className="w-6 h-6 text-orange-600" />,
|
||||
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
|
||||
css: <Code className="w-6 h-6 text-blue-500" />,
|
||||
scss: <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
sass: <SiSass className="w-6 h-6 text-pink-500" />,
|
||||
less: <Code className="w-6 h-6 text-blue-600" />,
|
||||
json: <Code className="w-6 h-6 text-yellow-500" />,
|
||||
xml: <Code className="w-6 h-6 text-orange-500" />,
|
||||
yaml: <Code className="w-6 h-6 text-red-400" />,
|
||||
yml: <Code className="w-6 h-6 text-red-400" />,
|
||||
toml: <Code className="w-6 h-6 text-orange-400" />,
|
||||
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
|
||||
sh: <SiGnubash className="w-6 h-6 text-foreground" />,
|
||||
bash: <SiGnubash className="w-6 h-6 text-foreground" />,
|
||||
zsh: <Code className="w-6 h-6 text-foreground" />,
|
||||
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
|
||||
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
|
||||
md: <SiMarkdown className="w-6 h-6 text-muted-foreground" />,
|
||||
conf: <Code className="w-6 h-6 text-muted-foreground" />,
|
||||
ini: <Code className="w-6 h-6 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
|
||||
function getFileType(filename: string): {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
} {
|
||||
const kind = resolveFilePreviewKind(filename);
|
||||
|
||||
if (kind === "image") {
|
||||
return {
|
||||
type: "image",
|
||||
icon: <ImageIcon className="w-6 h-6" />,
|
||||
color: "text-green-500",
|
||||
};
|
||||
} else if (kind === "video") {
|
||||
return {
|
||||
type: "video",
|
||||
icon: <Film className="w-6 h-6" />,
|
||||
color: "text-purple-500",
|
||||
};
|
||||
} else if (kind === "audio") {
|
||||
return {
|
||||
type: "audio",
|
||||
icon: <Music className="w-6 h-6" />,
|
||||
color: "text-pink-500",
|
||||
};
|
||||
} else if (kind === "markdown") {
|
||||
return {
|
||||
type: "markdown",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-blue-600",
|
||||
};
|
||||
} else if (kind === "pdf") {
|
||||
return {
|
||||
type: "pdf",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-red-600",
|
||||
};
|
||||
} else if (kind === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-blue-500",
|
||||
};
|
||||
} else if (kind === "code") {
|
||||
return {
|
||||
type: "code",
|
||||
icon: getLanguageIcon(filename),
|
||||
color: "text-yellow-500",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "unknown",
|
||||
icon: <FileIcon className="w-6 h-6" />,
|
||||
color: "text-foreground-subtle",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function PreviewFallback({ label }: { label: string }) {
|
||||
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">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer({
|
||||
file,
|
||||
content = "",
|
||||
savedContent = "",
|
||||
isLoading = false,
|
||||
isSaving = false,
|
||||
isEditable = false,
|
||||
resetKey,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onRevert,
|
||||
onDownload,
|
||||
onOpenExternal,
|
||||
onChooseExternalEditor,
|
||||
onMediaDimensionsChange,
|
||||
}: FileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editedContent, setEditedContent] = useState(content);
|
||||
const [, setOriginalContent] = useState(savedContent || content);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
|
||||
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
||||
const [editorFocused, setEditorFocused] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("fileManagerEditorFontSize");
|
||||
return stored ? parseInt(stored, 10) : 14;
|
||||
});
|
||||
const editorRef = useRef<CodeEditorHandle | null>(null);
|
||||
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const MAX_FONT_SIZE = 32;
|
||||
|
||||
const decreaseFontSize = () => {
|
||||
setEditorFontSize((prev) => {
|
||||
const next = Math.max(MIN_FONT_SIZE, prev - 1);
|
||||
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const increaseFontSize = () => {
|
||||
setEditorFontSize((prev) => {
|
||||
const next = Math.min(MAX_FONT_SIZE, prev + 1);
|
||||
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const shouldShowAsText =
|
||||
fileTypeInfo.type === "text" ||
|
||||
fileTypeInfo.type === "code" ||
|
||||
(fileTypeInfo.type === "unknown" &&
|
||||
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||||
|
||||
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||||
const isTooLarge = file.size && file.size > MAX_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setEditedContent(content);
|
||||
if (savedContent) {
|
||||
setOriginalContent(savedContent);
|
||||
}
|
||||
}, [file.name, file.path, resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== savedContent);
|
||||
|
||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||
setShowLargeFileWarning(true);
|
||||
} else {
|
||||
setShowLargeFileWarning(false);
|
||||
}
|
||||
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setEditedContent(newContent);
|
||||
setHasChanges(newContent !== savedContent);
|
||||
onContentChange?.(newContent);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isSaving) return;
|
||||
onSave?.(editedContent);
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
if (onRevert) {
|
||||
onRevert();
|
||||
} else {
|
||||
setEditedContent(savedContent);
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorFocused || !isEditable) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
if (isCtrl && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [editorFocused, isEditable, handleSave]);
|
||||
|
||||
const handleConfirmOpenAsText = () => {
|
||||
setForceShowAsText(true);
|
||||
setShowLargeFileWarning(false);
|
||||
};
|
||||
|
||||
const handleCancelOpenAsText = () => {
|
||||
setShowLargeFileWarning(false);
|
||||
};
|
||||
|
||||
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-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading file...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
||||
{fileTypeInfo.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{file.name}</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(file.size, t)}</span>
|
||||
{file.modified && (
|
||||
<span>
|
||||
{t("fileManager.modified")}: {file.modified}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs",
|
||||
fileTypeInfo.color,
|
||||
"bg-muted",
|
||||
)}
|
||||
>
|
||||
{fileTypeInfo.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editorRef.current?.openSearchPanel()}
|
||||
className="flex items-center gap-2"
|
||||
title={t("fileManager.searchInFile")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isEditable && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={decreaseFontSize}
|
||||
disabled={editorFontSize <= MIN_FONT_SIZE}
|
||||
title={t("fileManager.decreaseFontSize")}
|
||||
className="w-7 h-7 p-0"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground w-8 text-center select-none">
|
||||
{editorFontSize}px
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={increaseFontSize}
|
||||
disabled={editorFontSize >= MAX_FONT_SIZE}
|
||||
title={t("fileManager.increaseFontSize")}
|
||||
className="w-7 h-7 p-0"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
|
||||
className="flex items-center gap-2"
|
||||
title={t("fileManager.showKeyboardShortcuts")}
|
||||
>
|
||||
<Keyboard className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRevert}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
{isEditable && onOpenExternal && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenExternal}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("fileManager.openExternalEditor")}
|
||||
</Button>
|
||||
{onChooseExternalEditor && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onChooseExternalEditor}
|
||||
className="h-8 w-8 p-0"
|
||||
title={t("fileManager.chooseExternalEditor")}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showKeyboardShortcuts && isEditable && (
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("fileManager.keyboardShortcuts")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowKeyboardShortcuts(false)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">
|
||||
{t("fileManager.searchAndReplace")}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.search")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+F
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.replace")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+H
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findNext")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
F3
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findPrevious")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Shift+F3
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">
|
||||
{t("fileManager.editing")}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.save")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+S
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.selectAll")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+A
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.undo")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Z
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.redo")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Y
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.toggleComment")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+/
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.autoComplete")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Space
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineUp")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↑
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineDown")}</span>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↓
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{showLargeFileWarning && (
|
||||
<div className="h-full flex items-center justify-center bg-background">
|
||||
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">
|
||||
{t("fileManager.largeFileWarning")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("fileManager.largeFileWarningDesc", {
|
||||
size: formatFileSize(file.size, t),
|
||||
})}
|
||||
</p>
|
||||
{isTooLarge ? (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
File is too large (> 10MB) and cannot be opened as
|
||||
text for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Do you want to continue opening this file as text? This
|
||||
may slow down your browser.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isTooLarge && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConfirmOpenAsText}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Open as Text
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.downloadInstead")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelOpenAsText}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||||
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
|
||||
<ImagePreview
|
||||
content={content}
|
||||
fileName={file.name}
|
||||
onDownload={onDownload}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldShowAsText && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
{isEditable ? (
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading editor..." />}
|
||||
>
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
fileName={file.name}
|
||||
value={editedContent}
|
||||
onChange={handleContentChange}
|
||||
onFocus={() => setEditorFocused(true)}
|
||||
onBlur={() => setEditorFocused(false)}
|
||||
placeholder={t("fileManager.startTyping")}
|
||||
fontSize={editorFontSize}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
|
||||
{editedContent || content || t("fileManager.fileIsEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-4xl">
|
||||
{(() => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const mimeType = (() => {
|
||||
switch (ext) {
|
||||
case "mp4":
|
||||
return "video/mp4";
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "mkv":
|
||||
return "video/x-matroska";
|
||||
case "avi":
|
||||
return "video/x-msvideo";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv";
|
||||
case "flv":
|
||||
return "video/x-flv";
|
||||
default:
|
||||
return "video/mp4";
|
||||
}
|
||||
})();
|
||||
|
||||
const videoUrl = `data:${mimeType};base64,${content}`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
className="w-full rounded-lg shadow-sm"
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 200px)",
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
preload="metadata"
|
||||
onError={(e) => {
|
||||
console.error(
|
||||
"Video playback error:",
|
||||
e.currentTarget.error,
|
||||
);
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.currentTarget;
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
video.videoWidth &&
|
||||
video.videoHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source src={videoUrl} type={mimeType} />
|
||||
<div className="text-center text-muted-foreground p-4">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>
|
||||
Your browser does not support video playback for this
|
||||
format.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="mt-2 flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download to play externally
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={markdownEditMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMarkdownEditMode(true)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
{t("fileManager.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={!markdownEditMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMarkdownEditMode(false)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{t("fileManager.preview")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{markdownEditMode ? (
|
||||
<>
|
||||
<div className="flex-1 border-r border-border">
|
||||
<div className="h-full p-4 bg-background">
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => {
|
||||
setEditedContent(e.target.value);
|
||||
onContentChange?.(e.target.value);
|
||||
}}
|
||||
className="w-full h-full resize-none border-0 bg-transparent text-foreground font-mono text-sm leading-relaxed focus:outline-none focus:ring-0"
|
||||
placeholder={t("fileManager.startWritingMarkdown")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
|
||||
<div className="p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<PreviewFallback label="Loading preview..." />
|
||||
}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
compact
|
||||
content={editedContent || "Nothing to preview yet..."}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto thin-scrollbar p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading preview..." />}
|
||||
>
|
||||
<MarkdownRenderer content={editedContent} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading PDF viewer..." />}
|
||||
>
|
||||
<PdfPreview
|
||||
content={content}
|
||||
onDownload={onDownload}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading audio player..." />}
|
||||
>
|
||||
<AudioPreview
|
||||
file={file}
|
||||
content={content}
|
||||
color={fileTypeInfo.color}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "unknown" &&
|
||||
!shouldShowAsText &&
|
||||
!showLargeFileWarning && (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
Cannot preview this file type
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
This file type is not supported for preview. You can download
|
||||
it to view in an external application.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.downloadFile")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{file.path}</span>
|
||||
{hasChanges && (
|
||||
<span className="text-orange-600 font-medium">
|
||||
● Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { FileViewer } from "./FileViewer.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import {
|
||||
downloadSSHFile,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "../api/ssh-file-operations-api";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface FileWindowProps {
|
||||
windowId: string;
|
||||
file: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
onFileNotFound?: (file: FileItem) => void;
|
||||
}
|
||||
|
||||
function isDisplayableText(str: string): boolean {
|
||||
let printable = 0;
|
||||
for (let i = 0; i < Math.min(str.length, 1000); i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (
|
||||
(code >= 32 && code <= 126) ||
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13 ||
|
||||
code >= 128
|
||||
) {
|
||||
printable++;
|
||||
}
|
||||
}
|
||||
return printable / Math.min(str.length, 1000) > 0.85;
|
||||
}
|
||||
|
||||
export function FileWindow({
|
||||
windowId,
|
||||
file,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 100,
|
||||
initialY = 100,
|
||||
onFileNotFound,
|
||||
}: FileWindowProps) {
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [mediaDimensions, setMediaDimensions] = useState<
|
||||
{ width: number; height: number } | undefined
|
||||
>();
|
||||
const [externalEditorPath, setExternalEditorPath] = useState<string>("");
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const externalEditorRef = useRef<{
|
||||
editId: string;
|
||||
unsubscribe?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.isElectron) return;
|
||||
|
||||
window.electronAPI
|
||||
.getSetting?.("fileManager.externalEditorPath")
|
||||
.then((value) => {
|
||||
if (typeof value === "string") setExternalEditorPath(value);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
|
||||
if (!status.connected) {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSH connection check/reconnect failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
let fileContent = response.content || "";
|
||||
|
||||
if (response.encoding === "base64") {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(fileContent), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
const decoded = new TextDecoder("utf-8").decode(bytes);
|
||||
if (isDisplayableText(decoded)) {
|
||||
fileContent = decoded;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to decode base64 content:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent);
|
||||
setResetKey((k) => k + 1);
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
|
||||
const mediaExtensions = [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"webp",
|
||||
"tiff",
|
||||
"ico",
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
"aac",
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
"dylib",
|
||||
"bin",
|
||||
"iso",
|
||||
];
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
isFileNotFound?: boolean;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
tooLarge?: boolean;
|
||||
error?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
duration: 10000,
|
||||
});
|
||||
} else if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
errorData?.error || err.message || "Unknown error";
|
||||
const isFileNotFound =
|
||||
err.isFileNotFound ||
|
||||
errorData?.fileNotFound ||
|
||||
err.response?.status === 404 ||
|
||||
errorMessage.includes("File not found") ||
|
||||
errorMessage.includes("No such file or directory") ||
|
||||
errorMessage.includes("cannot access") ||
|
||||
errorMessage.includes("not found") ||
|
||||
errorMessage.includes("Resource not found");
|
||||
|
||||
if (isFileNotFound && onFileNotFound) {
|
||||
onFileNotFound(file);
|
||||
toast.error(
|
||||
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
|
||||
);
|
||||
|
||||
closeWindow(windowId);
|
||||
return;
|
||||
} else {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadFile", {
|
||||
error: errorMessage.includes("Server error occurred")
|
||||
? t("fileManager.serverErrorOccurred")
|
||||
: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [file, sshSessionId, sshHost]);
|
||||
|
||||
const handleRevert = async () => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path, {
|
||||
force: true,
|
||||
});
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent("");
|
||||
setResetKey((k) => k + 1);
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file content:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
};
|
||||
|
||||
const handleSave = async (newContent: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||
setContent(newContent);
|
||||
setPendingContent("");
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeExternalEditor = async () => {
|
||||
const session = externalEditorRef.current;
|
||||
if (!session) return;
|
||||
|
||||
session.unsubscribe?.();
|
||||
externalEditorRef.current = null;
|
||||
|
||||
try {
|
||||
await window.electronAPI?.closeExternalEditor?.(session.editId);
|
||||
} catch (error) {
|
||||
console.error("Failed to close external editor session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenExternalEditor = async () => {
|
||||
if (!window.electronAPI?.isElectron) {
|
||||
toast.error(t("fileManager.externalEditorDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await closeExternalEditor();
|
||||
const result = await window.electronAPI.openExternalEditor({
|
||||
fileName: file.name,
|
||||
content: pendingContent || content,
|
||||
encoding: "utf8",
|
||||
editorPath: externalEditorPath || null,
|
||||
});
|
||||
|
||||
if (!result.success || !result.editId) {
|
||||
throw new Error(result.error || "Failed to open external editor");
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onExternalEditorSaved?.(
|
||||
(payload) => {
|
||||
if (payload.editId !== result.editId) return;
|
||||
void handleSave(payload.content);
|
||||
},
|
||||
);
|
||||
|
||||
externalEditorRef.current = {
|
||||
editId: result.editId,
|
||||
unsubscribe,
|
||||
};
|
||||
toast.success(t("fileManager.externalEditorOpened"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to open external editor:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToOpenExternalEditor")}: ${
|
||||
err.message || t("fileManager.unknownError")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChooseExternalEditor = async () => {
|
||||
if (!window.electronAPI?.isElectron) {
|
||||
toast.error(t("fileManager.externalEditorDesktopOnly"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.showOpenDialog({
|
||||
title: t("fileManager.chooseExternalEditor"),
|
||||
properties: ["openFile"],
|
||||
});
|
||||
const editorPath = result.filePaths?.[0];
|
||||
if (result.canceled || !editorPath) return;
|
||||
|
||||
await window.electronAPI.setSetting?.(
|
||||
"fileManager.externalEditorPath",
|
||||
editorPath,
|
||||
);
|
||||
setExternalEditorPath(editorPath);
|
||||
toast.success(t("fileManager.externalEditorSelected"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to select external editor:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSelectExternalEditor")}: ${
|
||||
err.message || t("fileManager.unknownError")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setPendingContent(newContent);
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (newContent !== content) {
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await handleSave(newContent);
|
||||
toast.success(t("fileManager.fileAutoSaved"));
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
toast.error(t("fileManager.autoSaveFailed"));
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
void closeExternalEditor();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.fileDownloadedSuccessfully", {
|
||||
name: response.fileName || file.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`Failed to download file: ${err.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
void closeExternalEditor();
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMediaDimensionsChange = (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
setMediaDimensions(dimensions);
|
||||
};
|
||||
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={file.name}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={600}
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
targetSize={mediaDimensions}
|
||||
>
|
||||
<FileViewer
|
||||
file={file}
|
||||
content={pendingContent || content}
|
||||
savedContent={content}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
resetKey={resetKey}
|
||||
onRevert={handleRevert}
|
||||
isEditable={isEditable}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={(newContent) => handleSave(newContent)}
|
||||
onDownload={handleDownload}
|
||||
onOpenExternal={
|
||||
window.electronAPI?.isElectron && isEditable
|
||||
? handleOpenExternalEditor
|
||||
: undefined
|
||||
}
|
||||
onChooseExternalEditor={
|
||||
window.electronAPI?.isElectron && isEditable
|
||||
? handleChooseExternalEditor
|
||||
: undefined
|
||||
}
|
||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from "react";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onDownload?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function getImageDataUrl(content: string, fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || "image/png";
|
||||
return `data:${mimeType};base64,${content}`;
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
content,
|
||||
fileName,
|
||||
onDownload,
|
||||
onMediaDimensionsChange,
|
||||
}: ImagePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [imageLoadError, setImageLoadError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const imageUrl = getImageDataUrl(content, fileName);
|
||||
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-full relative">
|
||||
{imageLoadError ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.imageLoadError")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">{fileName}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PhotoProvider maskOpacity={0.7}>
|
||||
<PhotoView src={imageUrl}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
onLoad={(e) => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
|
||||
const img = e.currentTarget;
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
img.naturalWidth &&
|
||||
img.naturalHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{imageLoading && !imageLoadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<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">Loading image...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatFileSize } from "../file-manager-utils.ts";
|
||||
|
||||
export interface LocalTransferBatchStatus {
|
||||
direction: "upload" | "download";
|
||||
totalFiles: number;
|
||||
completedFiles: number;
|
||||
currentFileName?: string;
|
||||
/** Files being transferred right now (parallel transfers). */
|
||||
activeFiles?: number;
|
||||
bytesDone: number;
|
||||
totalBytes: number;
|
||||
mbPerSec?: number;
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
interface LocalTransferProgressToastProps {
|
||||
status: LocalTransferBatchStatus;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function LocalTransferProgressToast({
|
||||
status,
|
||||
onCancel,
|
||||
}: LocalTransferProgressToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent =
|
||||
status.totalBytes > 0
|
||||
? Math.min(100, Math.round((status.bytesDone / status.totalBytes) * 100))
|
||||
: undefined;
|
||||
|
||||
const title =
|
||||
status.direction === "upload"
|
||||
? t("fileManager.localUploadingProgress", {
|
||||
current: Math.min(
|
||||
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
|
||||
status.totalFiles,
|
||||
),
|
||||
total: status.totalFiles,
|
||||
})
|
||||
: t("fileManager.localDownloadingProgress", {
|
||||
current: Math.min(
|
||||
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
|
||||
status.totalFiles,
|
||||
),
|
||||
total: status.totalFiles,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">{title}</p>
|
||||
{status.currentFileName && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={status.currentFileName}
|
||||
>
|
||||
{(status.activeFiles ?? 0) > 1 && (
|
||||
<span className="mr-1 text-foreground/70">
|
||||
{t("fileManager.localTransferInFlight", {
|
||||
count: status.activeFiles,
|
||||
})}
|
||||
{" · "}
|
||||
</span>
|
||||
)}
|
||||
{status.currentFileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={status.cancelling}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{status.cancelling
|
||||
? t("transfer.progressCancelling")
|
||||
: t("transfer.progressCancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
{percent === undefined ? (
|
||||
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
|
||||
) : (
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width]"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
{formatFileSize(status.bytesDone)} /{" "}
|
||||
{formatFileSize(status.totalBytes)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium text-foreground">
|
||||
{status.mbPerSec !== undefined
|
||||
? `${status.mbPerSec.toFixed(status.mbPerSec >= 10 ? 0 : 1)} MB/s`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
content,
|
||||
compact = false,
|
||||
}: MarkdownRendererProps) {
|
||||
const h1Class = compact
|
||||
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
|
||||
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
|
||||
const h2Class = compact
|
||||
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
|
||||
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
|
||||
const h3Class = compact
|
||||
? "text-lg font-semibold mb-2 mt-4 text-foreground"
|
||||
: "text-xl font-semibold mb-3 mt-4 text-foreground";
|
||||
const h4Class = compact
|
||||
? "text-base font-semibold mb-2 mt-3 text-foreground"
|
||||
: "text-lg font-semibold mb-2 mt-3 text-foreground";
|
||||
const pClass = compact
|
||||
? "mb-3 text-foreground leading-relaxed"
|
||||
: "mb-4 text-foreground leading-relaxed";
|
||||
const listClass = compact
|
||||
? "mb-3 ml-4 text-foreground"
|
||||
: "mb-4 ml-6 text-foreground";
|
||||
const quoteClass = compact
|
||||
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
|
||||
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
|
||||
const tableWrapClass = compact
|
||||
? "mb-3 overflow-x-auto thin-scrollbar"
|
||||
: "mb-4 overflow-x-auto thin-scrollbar";
|
||||
const tableClass = compact
|
||||
? "min-w-full border border-border rounded-lg text-sm"
|
||||
: "min-w-full border border-border rounded-lg";
|
||||
const thClass = compact
|
||||
? "px-3 py-2 text-left font-semibold text-foreground"
|
||||
: "px-4 py-2 text-left font-semibold text-foreground";
|
||||
const tdClass = compact
|
||||
? "px-3 py-2 text-foreground"
|
||||
: "px-4 py-2 text-foreground";
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
// v9 removed the `inline` flag; a fenced block always carries a
|
||||
// language- class, an inline span never does.
|
||||
const inline = !className;
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
|
||||
p: ({ children }) => <p className={pClass}>{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className={`${listClass} list-disc`}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className={`${listClass} list-decimal`}>{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className={quoteClass}>{children}</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className={tableWrapClass}>
|
||||
<table className={tableClass}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => <th className={thClass}>{children}</th>,
|
||||
td: ({ children }) => <td className={tdClass}>{children}</td>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface PaneResizeHandleProps {
|
||||
label: string;
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical drag handle placed between two panes in a `gap-3` flex row. It
|
||||
* overlaps the gap (negative margins) so the panes' spacing is unchanged, and
|
||||
* shows a hairline on hover / while dragging. Double-click resets the width.
|
||||
*/
|
||||
export function PaneResizeHandle({
|
||||
label,
|
||||
onMouseDown,
|
||||
onDoubleClick,
|
||||
active = false,
|
||||
}: PaneResizeHandleProps) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-pane-resize-handle
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className="hidden md:block -mx-3 w-3 shrink-0 cursor-col-resize group relative z-10"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors",
|
||||
active
|
||||
? "bg-accent-brand"
|
||||
: "bg-transparent group-hover:bg-accent-brand/60",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// No workerSrc: the termix-plugin CLI's esbuild step has no asset-copy
|
||||
// pipeline for a worker file the way core's Vite build does with `?url`, so
|
||||
// pdfjs falls back to parsing on the main thread instead. Fine for the
|
||||
// config/log-sized files this preview usually opens; a real worker needs the
|
||||
// CLI to gain static-asset output first (see FINISH-LIST.md).
|
||||
|
||||
interface PdfPreviewProps {
|
||||
content: string;
|
||||
onDownload?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function PdfPreview({
|
||||
content,
|
||||
onDownload,
|
||||
onMediaDimensionsChange,
|
||||
}: PdfPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [pdfScale, setPdfScale] = useState(1.2);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
|
||||
disabled={pageNumber <= 1}
|
||||
>
|
||||
{t("fileManager.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border">
|
||||
{t("fileManager.pageXOfY", {
|
||||
current: pageNumber,
|
||||
total: numPages || 0,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||||
}
|
||||
disabled={!numPages || pageNumber >= numPages}
|
||||
>
|
||||
{t("fileManager.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomOut")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border min-w-[80px] text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
|
||||
<div className="flex justify-center">
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.cannotLoadPdf")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Document
|
||||
file={`data:application/pdf;base64,${content}`}
|
||||
onLoadSuccess={({ numPages }) => {
|
||||
setNumPages(numPages);
|
||||
setPdfError(false);
|
||||
|
||||
onMediaDimensionsChange?.({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
}}
|
||||
onLoadError={(error) => {
|
||||
console.error("PDF load error:", error);
|
||||
setPdfError(true);
|
||||
}}
|
||||
loading={
|
||||
<div className="text-center p-8">
|
||||
<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("fileManager.loadingPdf")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={pdfScale}
|
||||
className="shadow-lg"
|
||||
loading={
|
||||
<div className="text-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fileManager.loadingPage")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Document>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Button,
|
||||
Input,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } 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>;
|
||||
}
|
||||
|
||||
const parsePermissions = (
|
||||
perms: string,
|
||||
): { owner: number; group: number; other: number } => {
|
||||
if (!perms) {
|
||||
return { owner: 0, group: 0, other: 0 };
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
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)),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
const rows = [
|
||||
{
|
||||
label: `${t("fileManager.owner")}${file.owner ? ` (${file.owner})` : ""}`,
|
||||
read: ownerRead,
|
||||
setRead: setOwnerRead,
|
||||
write: ownerWrite,
|
||||
setWrite: setOwnerWrite,
|
||||
execute: ownerExecute,
|
||||
setExecute: setOwnerExecute,
|
||||
},
|
||||
{
|
||||
label: `${t("fileManager.group")}${file.group ? ` (${file.group})` : ""}`,
|
||||
read: groupRead,
|
||||
setRead: setGroupRead,
|
||||
write: groupWrite,
|
||||
setWrite: setGroupWrite,
|
||||
execute: groupExecute,
|
||||
setExecute: setGroupExecute,
|
||||
},
|
||||
{
|
||||
label: t("fileManager.others"),
|
||||
read: otherRead,
|
||||
setRead: setOtherRead,
|
||||
write: otherWrite,
|
||||
setWrite: setOtherWrite,
|
||||
execute: otherExecute,
|
||||
setExecute: setOtherExecute,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Lock className="size-4 text-accent-brand" />
|
||||
{t("fileManager.changePermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground font-mono break-all">
|
||||
{file.path}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-3 flex flex-col gap-4">
|
||||
<div className="border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
|
||||
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
|
||||
{[
|
||||
t("fileManager.read"),
|
||||
t("fileManager.write"),
|
||||
t("fileManager.execute"),
|
||||
].map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
|
||||
>
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
|
||||
>
|
||||
<div className="px-3 py-3 text-xs font-semibold">
|
||||
{row.label}
|
||||
</div>
|
||||
{[
|
||||
{ val: row.read, set: row.setRead },
|
||||
{ val: row.write, set: row.setWrite },
|
||||
{ val: row.execute, set: row.setExecute },
|
||||
].map((perm, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex items-center justify-center border-l border-border py-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={perm.val}
|
||||
onChange={(e) => perm.set(e.target.checked)}
|
||||
className="accent-[var(--accent-brand)] size-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
|
||||
{t("fileManager.octal")}
|
||||
</span>
|
||||
<Input
|
||||
value={octal}
|
||||
readOnly
|
||||
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{loading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import {
|
||||
Terminal,
|
||||
type TerminalHandle,
|
||||
type TerminalHostConfig,
|
||||
} from "@/features/terminal/Terminal.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import type { SSHHost } from "@/types/index.ts";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface TerminalWindowProps {
|
||||
windowId: string;
|
||||
hostConfig: SSHHost;
|
||||
initialPath?: string;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
executeCommand?: string;
|
||||
onPromoteToTab?: (path?: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalWindow({
|
||||
windowId,
|
||||
hostConfig,
|
||||
initialPath,
|
||||
initialX = 200,
|
||||
initialY = 150,
|
||||
executeCommand,
|
||||
onPromoteToTab,
|
||||
}: TerminalWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
const terminalRef = React.useRef<TerminalHandle | null>(null);
|
||||
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (terminalRef.current?.fit) {
|
||||
terminalRef.current.fit();
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (terminalRef.current?.fit) {
|
||||
terminalRef.current.fit();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handlePromoteToTab = () => {
|
||||
onPromoteToTab?.(initialPath);
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const terminalTitle = executeCommand
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
? t("terminal.terminalWithPath", {
|
||||
host: hostConfig.name,
|
||||
path: initialPath,
|
||||
})
|
||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||
|
||||
return (
|
||||
<CommandHistoryProvider>
|
||||
<DraggableWindow
|
||||
title={terminalTitle}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={500}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
onResize={handleResize}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
titleActions={
|
||||
onPromoteToTab ? (
|
||||
<button
|
||||
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePromoteToTab();
|
||||
}}
|
||||
title={t("common.openInNewTab")}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
hostConfig={hostConfig as TerminalHostConfig}
|
||||
isVisible={!currentWindow.isMinimized}
|
||||
initialPath={initialPath}
|
||||
executeCommand={executeCommand}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
</CommandHistoryProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TransferProgressBar({
|
||||
value,
|
||||
label,
|
||||
stalled = false,
|
||||
}: {
|
||||
value?: number;
|
||||
label: string;
|
||||
stalled?: boolean;
|
||||
}) {
|
||||
const clamped = Math.min(100, Math.max(0, value ?? 0));
|
||||
const indeterminate = value === undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : clamped}
|
||||
aria-busy={indeterminate}
|
||||
data-stalled={stalled || undefined}
|
||||
className="transfer-progress-track"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"transfer-progress-fill",
|
||||
indeterminate && "transfer-progress-indeterminate",
|
||||
stalled && "transfer-progress-stalled",
|
||||
)}
|
||||
style={
|
||||
indeterminate
|
||||
? undefined
|
||||
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
formatTransferMbPerSec,
|
||||
getTransferProgressPercent,
|
||||
type TransferProgressResponse,
|
||||
} from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowRight,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { TransferProgressBar } from "./TransferProgressBar";
|
||||
|
||||
interface TransferProgressToastProps {
|
||||
status: TransferProgressResponse;
|
||||
liveMbPerSec?: number;
|
||||
stalled?: boolean;
|
||||
formatSize: (bytes?: number) => string;
|
||||
onCancel?: () => void;
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
export function TransferProgressToast({
|
||||
status,
|
||||
liveMbPerSec,
|
||||
stalled = false,
|
||||
formatSize,
|
||||
onCancel,
|
||||
cancelling = false,
|
||||
}: TransferProgressToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = getTransferProgressPercent(status);
|
||||
|
||||
let title = t("transfer.progressTransferring");
|
||||
if (status.phase === "reconnecting") {
|
||||
title = t("transfer.progressReconnecting");
|
||||
} else if (status.phase === "benchmarking") {
|
||||
title = t("transfer.progressBenchmarking");
|
||||
} else if (status.phase === "verifying") {
|
||||
title = t("transfer.progressVerifying");
|
||||
} else if (status.phase === "compressing") {
|
||||
title = t("transfer.progressCompressing");
|
||||
} else if (status.phase === "extracting") {
|
||||
title = t("transfer.progressExtracting");
|
||||
} else if (
|
||||
status.method === "item_sftp" &&
|
||||
status.totalItems !== undefined &&
|
||||
status.itemsCompleted !== undefined
|
||||
) {
|
||||
title = t("transfer.progressTransferringItems", {
|
||||
current: status.itemsCompleted,
|
||||
total: status.totalItems,
|
||||
});
|
||||
}
|
||||
|
||||
const hasByteProgress =
|
||||
status.bytesTransferred !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0;
|
||||
|
||||
const detailLeft = hasByteProgress
|
||||
? t("transfer.progressBytes", {
|
||||
transferred: formatSize(status.bytesTransferred),
|
||||
total: formatSize(status.totalBytes),
|
||||
})
|
||||
: status.method === "item_sftp" &&
|
||||
status.totalItems !== undefined &&
|
||||
status.itemsCompleted !== undefined
|
||||
? t("transfer.progressItems", {
|
||||
current: status.itemsCompleted,
|
||||
total: status.totalItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
const liveRate =
|
||||
status.phase === "reconnecting"
|
||||
? undefined
|
||||
: stalled
|
||||
? t("transfer.progressStalled")
|
||||
: liveMbPerSec !== undefined
|
||||
? status.parallelSegmentCount && status.parallelSegmentCount > 1
|
||||
? t("transfer.progressTotalSpeed", {
|
||||
speed: formatTransferMbPerSec(liveMbPerSec),
|
||||
lanes: status.parallelSegmentCount,
|
||||
})
|
||||
: formatTransferMbPerSec(liveMbPerSec)
|
||||
: undefined;
|
||||
|
||||
const showIndeterminate =
|
||||
status.phase === "reconnecting" || percent === undefined;
|
||||
const PhaseIcon =
|
||||
status.phase === "compressing"
|
||||
? Archive
|
||||
: status.phase === "benchmarking"
|
||||
? Gauge
|
||||
: status.phase === "verifying"
|
||||
? ShieldCheck
|
||||
: status.phase === "extracting"
|
||||
? ArchiveRestore
|
||||
: status.phase === "reconnecting"
|
||||
? RefreshCw
|
||||
: ArrowRight;
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
key={status.phase}
|
||||
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
|
||||
>
|
||||
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="truncate">{title}</span>
|
||||
</p>
|
||||
{onCancel && status.status === "running" && status.transferId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={cancelling}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelling
|
||||
? t("transfer.progressCancelling")
|
||||
: t("transfer.progressCancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TransferProgressBar
|
||||
value={showIndeterminate ? undefined : percent}
|
||||
label={title}
|
||||
stalled={stalled}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
|
||||
<span
|
||||
className={`shrink-0 tabular-nums ${stalled ? "text-amber-500" : liveRate ? "font-medium text-foreground" : "invisible"}`}
|
||||
aria-hidden={!liveRate}
|
||||
>
|
||||
{liveRate ?? "0 MB/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
title: string;
|
||||
component: React.ReactNode | ((windowId: string) => React.ReactNode);
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
isMinimized: boolean;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
interface WindowManagerProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface WindowManagerContextType {
|
||||
windows: WindowInstance[];
|
||||
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
|
||||
closeWindow: (id: string) => void;
|
||||
minimizeWindow: (id: string) => void;
|
||||
maximizeWindow: (id: string) => void;
|
||||
focusWindow: (id: string) => void;
|
||||
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
|
||||
}
|
||||
|
||||
const WindowManagerContext =
|
||||
React.createContext<WindowManagerContextType | null>(null);
|
||||
|
||||
export function WindowManager({ children }: WindowManagerProps) {
|
||||
const [windows, setWindows] = useState<WindowInstance[]>([]);
|
||||
const nextZIndex = useRef(1000);
|
||||
const windowCounter = useRef(0);
|
||||
|
||||
const openWindow = useCallback(
|
||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
|
||||
const offset = (windows.length % 5) * 20;
|
||||
let adjustedX = windowData.x + offset;
|
||||
let adjustedY = windowData.y + offset;
|
||||
|
||||
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
|
||||
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
|
||||
|
||||
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
|
||||
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
|
||||
|
||||
const newWindow: WindowInstance = {
|
||||
...windowData,
|
||||
id,
|
||||
zIndex,
|
||||
x: adjustedX,
|
||||
y: adjustedY,
|
||||
};
|
||||
|
||||
setWindows((prev) => [...prev, newWindow]);
|
||||
return id;
|
||||
},
|
||||
[windows.length],
|
||||
);
|
||||
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
const minimizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const maximizeWindow = useCallback((id: string) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const focusWindow = useCallback((id: string) => {
|
||||
setWindows((prev) => {
|
||||
const targetWindow = prev.find((w) => w.id === id);
|
||||
if (!targetWindow) return prev;
|
||||
|
||||
const newZIndex = ++nextZIndex.current;
|
||||
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateWindow = useCallback(
|
||||
(id: string, updates: Partial<WindowInstance>) => {
|
||||
setWindows((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue: WindowManagerContextType = {
|
||||
windows,
|
||||
openWindow,
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
focusWindow,
|
||||
updateWindow,
|
||||
};
|
||||
|
||||
return (
|
||||
<WindowManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div
|
||||
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
<div className="relative w-full h-full pointer-events-none">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id} className="pointer-events-auto">
|
||||
{typeof window.component === "function"
|
||||
? window.component(window.id)
|
||||
: window.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</WindowManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWindowManager() {
|
||||
const context = React.useContext(WindowManagerContext);
|
||||
if (!context) {
|
||||
throw new Error("useWindowManager must be used within a WindowManager");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
export interface FileManagerProps {
|
||||
initialHost?: SSHHost | null;
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
onClose?: () => void;
|
||||
onOpenTerminalTab?: (path?: string) => void;
|
||||
/** When false, pause keepalive while the tab stays mounted in the background. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
export type SSHConnectionError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: "no_keyboard" | "auth_failed" | "timeout";
|
||||
};
|
||||
|
||||
export interface CreateIntent {
|
||||
id: string;
|
||||
type: "file" | "directory";
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export type PendingSudoOperation =
|
||||
{ type: "delete"; files: FileItem[] } | { type: "navigate"; path: string };
|
||||
@@ -0,0 +1,35 @@
|
||||
export function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
task: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(Math.max(1, limit), items.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const item = items[nextIndex++];
|
||||
await task(item);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
markAdaptiveResourceUsed,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "@/lib/adaptive-resource-budget";
|
||||
|
||||
export type FilePreviewKind =
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "markdown"
|
||||
| "pdf"
|
||||
| "text"
|
||||
| "code"
|
||||
| "unknown";
|
||||
|
||||
const extensions = {
|
||||
image: new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]),
|
||||
video: new Set(["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]),
|
||||
audio: new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a"]),
|
||||
markdown: new Set(["md", "markdown", "mdown", "mkdn", "mdx"]),
|
||||
pdf: new Set(["pdf"]),
|
||||
text: new Set(["txt", "readme"]),
|
||||
code: new Set([
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"py",
|
||||
"java",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"conf",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"vue",
|
||||
"svelte",
|
||||
]),
|
||||
} satisfies Record<Exclude<FilePreviewKind, "unknown">, Set<string>>;
|
||||
|
||||
const extensionlessCodeFiles = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
"rakefile",
|
||||
"gemfile",
|
||||
]);
|
||||
|
||||
export function resolveFilePreviewKind(filename: string): FilePreviewKind {
|
||||
const lowerName = filename.toLowerCase();
|
||||
if (extensionlessCodeFiles.has(lowerName)) return "code";
|
||||
|
||||
const extension = lowerName.split(".").pop() || "";
|
||||
for (const [kind, knownExtensions] of Object.entries(extensions)) {
|
||||
if (knownExtensions.has(extension)) return kind as FilePreviewKind;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export const loadCodeEditor = () =>
|
||||
import("./components/CodeEditor.tsx").then((module) => ({
|
||||
default: module.CodeEditor,
|
||||
}));
|
||||
export const loadImagePreview = () =>
|
||||
import("./components/ImagePreview.tsx").then((module) => ({
|
||||
default: module.ImagePreview,
|
||||
}));
|
||||
export const loadMarkdownRenderer = () =>
|
||||
import("./components/MarkdownRenderer.tsx").then((module) => ({
|
||||
default: module.MarkdownRenderer,
|
||||
}));
|
||||
export const loadPdfPreview = () =>
|
||||
import("./components/PdfPreview.tsx").then((module) => ({
|
||||
default: module.PdfPreview,
|
||||
}));
|
||||
export const loadAudioPreview = () =>
|
||||
import("./components/AudioPreview.tsx").then((module) => ({
|
||||
default: module.AudioPreview,
|
||||
}));
|
||||
|
||||
const previewLoaders: Partial<Record<FilePreviewKind, () => Promise<unknown>>> =
|
||||
{
|
||||
image: loadImagePreview,
|
||||
audio: loadAudioPreview,
|
||||
markdown: loadMarkdownRenderer,
|
||||
pdf: loadPdfPreview,
|
||||
text: loadCodeEditor,
|
||||
code: loadCodeEditor,
|
||||
unknown: loadCodeEditor,
|
||||
};
|
||||
|
||||
export function preloadFilePreview(filename: string): void {
|
||||
const kind = resolveFilePreviewKind(filename);
|
||||
const loader = previewLoaders[kind];
|
||||
if (loader) runAdaptiveBackgroundTask("module", `preview:${kind}`, loader);
|
||||
}
|
||||
|
||||
export function markFilePreviewUsed(filename: string): void {
|
||||
markAdaptiveResourceUsed(
|
||||
"module",
|
||||
`preview:${resolveFilePreviewKind(filename)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderSearch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
FileManagerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
|
||||
import { FileManager } from "../FileManager";
|
||||
|
||||
export function FileManagerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<FileManagerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find((h) => h.id === config.hostId);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<FolderSearch size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<FolderSearch size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FileManager initialHost={host as unknown as SSHHost} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
FileManagerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
|
||||
|
||||
export function FileManagerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<FileManagerWidgetConfig>) {
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableFileManager)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface DragAndDropState {
|
||||
isDragging: boolean;
|
||||
dragCounter: number;
|
||||
draggedFiles: File[];
|
||||
}
|
||||
|
||||
interface UseDragAndDropProps {
|
||||
onFilesDropped: (files: FileList) => void;
|
||||
onItemsDropped?: (entries: FileSystemEntry[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
}
|
||||
|
||||
export function useDragAndDrop({
|
||||
onFilesDropped,
|
||||
onItemsDropped,
|
||||
onError,
|
||||
maxFileSize = 5120,
|
||||
allowedTypes = [],
|
||||
}: UseDragAndDropProps) {
|
||||
const [state, setState] = useState<DragAndDropState>({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
const validateFiles = useCallback(
|
||||
(files: FileList): string | null => {
|
||||
const maxSizeBytes = maxFileSize * 1024 * 1024;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
|
||||
}
|
||||
|
||||
if (allowedTypes.length > 0) {
|
||||
const fileExt = file.name.split(".").pop()?.toLowerCase();
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
const isAllowed = allowedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return fileExt === type.slice(1);
|
||||
}
|
||||
if (type.includes("/")) {
|
||||
return (
|
||||
mimeType === type || mimeType.startsWith(type.replace("*", ""))
|
||||
);
|
||||
}
|
||||
switch (type) {
|
||||
case "image":
|
||||
return mimeType.startsWith("image/");
|
||||
case "video":
|
||||
return mimeType.startsWith("video/");
|
||||
case "audio":
|
||||
return mimeType.startsWith("audio/");
|
||||
case "text":
|
||||
return mimeType.startsWith("text/");
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return `File type "${file.type || "unknown"}" is not allowed.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[maxFileSize, allowedTypes],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
dragCounter: prev.dragCounter + 1,
|
||||
}));
|
||||
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: true,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setState((prev) => {
|
||||
const newCounter = prev.dragCounter - 1;
|
||||
return {
|
||||
...prev,
|
||||
dragCounter: newCounter,
|
||||
isDragging: newCounter > 0,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Read the entries before touching state. Updating state flushes a render
|
||||
// and the browser clears dataTransfer once the drop handler unwinds, so
|
||||
// anything read later comes back empty (Firefox is strictest here).
|
||||
const entries: FileSystemEntry[] = [];
|
||||
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
||||
for (const item of Array.from(e.dataTransfer.items)) {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
const files = e.dataTransfer.files;
|
||||
|
||||
setState({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
|
||||
onItemsDropped(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateFiles(files);
|
||||
if (validationError) {
|
||||
onError?.(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
onFilesDropped(files);
|
||||
},
|
||||
[validateFiles, onFilesDropped, onItemsDropped, onError],
|
||||
);
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
setState({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDragging: state.isDragging,
|
||||
dragHandlers: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDragOver: handleDragOver,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
resetDragState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "../api/ssh-file-operations-api";
|
||||
import type { FileItem, SSHHost } from "@/types/index.js";
|
||||
|
||||
interface DragToDesktopState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToDesktopProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToDesktopOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
|
||||
const [state, setState] = useState<DragToDesktopState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const isElectron = () => {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.electronAPI &&
|
||||
window.electronAPI.isElectron
|
||||
);
|
||||
};
|
||||
|
||||
const dragFileToDesktop = useCallback(
|
||||
async (file: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.type !== "file") {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (!response?.content) {
|
||||
throw new Error("Unable to get file content");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 50 }));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFile({
|
||||
fileName: file.name,
|
||||
content: response.content,
|
||||
encoding: "base64",
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary file",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${file.name} to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const dragFilesToDesktop = useCallback(
|
||||
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileList.length === 1) {
|
||||
return dragFileToDesktop(fileList[0], options);
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const downloadPromises = fileList.map((file) =>
|
||||
downloadSSHFile(sshSessionId, file.path),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(downloadPromises);
|
||||
setState((prev) => ({ ...prev, progress: 40 }));
|
||||
|
||||
const folderName = `Files_${Date.now()}`;
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
content: responses[index]?.content || "",
|
||||
encoding: "base64" as const,
|
||||
}));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFolder({
|
||||
folderName,
|
||||
files: filesData,
|
||||
});
|
||||
|
||||
if (!tempResult.success) {
|
||||
throw new Error(
|
||||
tempResult.error || "Failed to create temporary folder",
|
||||
);
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
|
||||
|
||||
const dragResult = await window.electronAPI.startDragToDesktop({
|
||||
tempId: tempResult.tempId,
|
||||
fileName: folderName,
|
||||
});
|
||||
|
||||
if (!dragResult.success) {
|
||||
throw new Error(dragResult.error || "Failed to start dragging");
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(`Dragging ${fileList.length} files to desktop`);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
await window.electronAPI.cleanupTempFile(tempResult.tempId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
}, 15000);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to batch drag to desktop:", error);
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Batch drag failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
isDragging: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Batch drag failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId, dragFileToDesktop],
|
||||
);
|
||||
|
||||
const dragFolderToDesktop = useCallback(
|
||||
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
|
||||
const { enableToast = true, onError } = options;
|
||||
|
||||
if (!isElectron()) {
|
||||
const error =
|
||||
"Drag to desktop feature is only available in desktop application";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (folder.type !== "directory") {
|
||||
const error = "Only folder types can be dragged";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableToast) {
|
||||
toast.info("Folder drag functionality is under development...");
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isElectron: isElectron(),
|
||||
dragFileToDesktop,
|
||||
dragFilesToDesktop,
|
||||
dragFolderToDesktop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "../api/ssh-file-operations-api";
|
||||
import type { FileItem, SSHHost } from "@/types/index.js";
|
||||
|
||||
interface DragToSystemState {
|
||||
isDragging: boolean;
|
||||
isDownloading: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseDragToSystemProps {
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
}
|
||||
|
||||
interface DragToSystemOptions {
|
||||
enableToast?: boolean;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
|
||||
const [state, setState] = useState<DragToSystemState>({
|
||||
isDragging: false,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dragDataRef = useRef<{
|
||||
files: FileItem[];
|
||||
options: DragToSystemOptions;
|
||||
} | null>(null);
|
||||
|
||||
const saveLastDirectory = async (fileHandle: {
|
||||
getParent?: () => Promise<unknown>;
|
||||
}) => {
|
||||
try {
|
||||
if ("indexedDB" in window && fileHandle.getParent) {
|
||||
const dirHandle = await fileHandle.getParent();
|
||||
const request = indexedDB.open("termix-dirs", 1);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction(["directories"], "readwrite");
|
||||
const store = transaction.objectStore("directories");
|
||||
store.put({ handle: dirHandle }, "lastSaveDir");
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Drag operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isFileSystemAPISupported = () => {
|
||||
return "showSaveFilePicker" in window;
|
||||
};
|
||||
|
||||
const isDraggedOutsideWindow = (e: DragEvent) => {
|
||||
const margin = 50;
|
||||
return (
|
||||
e.clientX < margin ||
|
||||
e.clientX > window.innerWidth - margin ||
|
||||
e.clientY < margin ||
|
||||
e.clientY > window.innerHeight - margin
|
||||
);
|
||||
};
|
||||
|
||||
const createFileBlob = async (file: FileItem): Promise<Blob> => {
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
if (!response?.content) {
|
||||
throw new Error(`Unable to get content for file ${file.name}`);
|
||||
}
|
||||
|
||||
const binaryString = atob(response.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes]);
|
||||
};
|
||||
|
||||
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of files) {
|
||||
const blob = await createFileBlob(file);
|
||||
zip.file(file.name, blob);
|
||||
}
|
||||
|
||||
return await zip.generateAsync({ type: "blob" });
|
||||
};
|
||||
|
||||
const fallbackDownload = (blob: Blob, fileName: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDragToSystem = useCallback(
|
||||
async (files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
const { enableToast = true, onSuccess, onError } = options;
|
||||
|
||||
if (files.length === 0) {
|
||||
const error = "No files available for dragging";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileList = files.filter((f) => f.type === "file");
|
||||
if (fileList.length === 0) {
|
||||
const error = "Only files can be dragged to desktop";
|
||||
if (enableToast) toast.error(error);
|
||||
onError?.(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const fileName =
|
||||
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
|
||||
|
||||
let fileHandle: {
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
} | null = null;
|
||||
if (isFileSystemAPISupported()) {
|
||||
try {
|
||||
fileHandle = await (
|
||||
window as Window & {
|
||||
showSaveFilePicker?: (options: {
|
||||
suggestedName: string;
|
||||
startIn: string;
|
||||
types: Array<{
|
||||
description: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
}) => Promise<{
|
||||
createWritable?: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
getParent?: () => Promise<unknown>;
|
||||
}>;
|
||||
}
|
||||
).showSaveFilePicker!({
|
||||
suggestedName: fileName,
|
||||
startIn: "desktop",
|
||||
types: [
|
||||
{
|
||||
description: "Files",
|
||||
accept: {
|
||||
"*/*": [
|
||||
".txt",
|
||||
".jpg",
|
||||
".png",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as { name?: string };
|
||||
if (err.name === "AbortError") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let blob: Blob;
|
||||
if (fileList.length === 1) {
|
||||
blob = await createFileBlob(fileList[0]);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
} else {
|
||||
blob = await createZipBlob(fileList);
|
||||
setState((prev) => ({ ...prev, progress: 70 }));
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 90 }));
|
||||
|
||||
if (fileHandle) {
|
||||
await saveLastDirectory(fileHandle);
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} else {
|
||||
fallbackDownload(blob, fileName);
|
||||
if (enableToast) {
|
||||
toast.info(
|
||||
"Due to browser limitations, file will be downloaded to default download directory",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, progress: 100 }));
|
||||
|
||||
if (enableToast) {
|
||||
toast.success(
|
||||
fileList.length === 1
|
||||
? `${fileName} saved to specified location`
|
||||
: `${fileList.length} files packaged and saved`,
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
setTimeout(() => {
|
||||
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
const errorMessage = err.message || "Save failed";
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
if (enableToast) {
|
||||
toast.error(`Save failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
const startDragToSystem = useCallback(
|
||||
(files: FileItem[], options: DragToSystemOptions = {}) => {
|
||||
dragDataRef.current = { files, options };
|
||||
setState((prev) => ({ ...prev, isDragging: true, error: null }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!dragDataRef.current) return;
|
||||
|
||||
const { files, options } = dragDataRef.current;
|
||||
|
||||
if (isDraggedOutsideWindow(e)) {
|
||||
handleDragToSystem(files, options);
|
||||
}
|
||||
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false }));
|
||||
},
|
||||
[handleDragToSystem],
|
||||
);
|
||||
|
||||
const cancelDragToSystem = useCallback(() => {
|
||||
dragDataRef.current = null;
|
||||
setState((prev) => ({ ...prev, isDragging: false, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isFileSystemAPISupported: isFileSystemAPISupported(),
|
||||
startDragToSystem,
|
||||
handleDragEnd,
|
||||
cancelDragToSystem,
|
||||
handleDragToSystem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export function useFileSelection() {
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
|
||||
|
||||
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
|
||||
if (multiSelect) {
|
||||
setSelectedFiles((prev) => {
|
||||
const isSelected = prev.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
return prev.filter((f) => f.path !== file.path);
|
||||
} else {
|
||||
return [...prev, file];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setSelectedFiles([file]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectRange = useCallback(
|
||||
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
|
||||
const startIndex = files.findIndex((f) => f.path === startFile.path);
|
||||
const endIndex = files.findIndex((f) => f.path === endFile.path);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const rangeFiles = files.slice(start, end + 1);
|
||||
setSelectedFiles(rangeFiles);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectAll = useCallback((files: FileItem[]) => {
|
||||
setSelectedFiles([...files]);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
||||
const toggleSelection = useCallback((file: FileItem) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const isSelected = prev.some((f) => f.path === file.path);
|
||||
if (isSelected) {
|
||||
return prev.filter((f) => f.path !== file.path);
|
||||
} else {
|
||||
return [...prev, file];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(file: FileItem) => {
|
||||
return selectedFiles.some((f) => f.path === file.path);
|
||||
},
|
||||
[selectedFiles],
|
||||
);
|
||||
|
||||
const getSelectedCount = useCallback(() => {
|
||||
return selectedFiles.length;
|
||||
}, [selectedFiles]);
|
||||
|
||||
const setSelection = useCallback((files: FileItem[]) => {
|
||||
setSelectedFiles(files);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
selectRange,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
toggleSelection,
|
||||
isSelected,
|
||||
getSelectedCount,
|
||||
setSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { createElement } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "@/types/index";
|
||||
import type { LocalWalkResult } from "@/types/electron";
|
||||
import {
|
||||
cancelLocalTransfer,
|
||||
createLocalTransferId,
|
||||
downloadSessionFileToLocal,
|
||||
uploadLocalFileToSession,
|
||||
} from "../api/local-transfer-api";
|
||||
import { createSSHFolder, listSSHFiles } from "../api/ssh-file-operations-api";
|
||||
import {
|
||||
ensureLocalDirectory,
|
||||
getLocalHome,
|
||||
localPathsExist,
|
||||
walkLocalPaths,
|
||||
} from "@/lib/local-files.ts";
|
||||
import {
|
||||
UnsafeLocalNameError,
|
||||
buildLocalDestination,
|
||||
getTransferConcurrency,
|
||||
runWithConcurrency,
|
||||
joinRemotePath,
|
||||
planRemoteDirectories,
|
||||
remoteBaseName,
|
||||
remoteDirForRelativePath,
|
||||
} from "../local-transfer-utils.ts";
|
||||
import {
|
||||
LocalTransferProgressToast,
|
||||
type LocalTransferBatchStatus,
|
||||
} from "../components/LocalTransferProgressToast.tsx";
|
||||
|
||||
interface UseLocalTransfersOptions {
|
||||
sshSessionId: string | null;
|
||||
hostId?: number;
|
||||
ensureSSHConnection: () => Promise<unknown>;
|
||||
/** Called after uploads so the remote listing can refresh. */
|
||||
onRemoteChanged: (remoteDir: string) => void;
|
||||
/** Called after downloads so the local pane can refresh. */
|
||||
onLocalChanged: (localDir: string) => void;
|
||||
}
|
||||
|
||||
interface RemoteDownloadPlanEntry {
|
||||
remotePath: string;
|
||||
/** "/"-separated, includes the dragged root's own name. */
|
||||
relativePath: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks whether existing local files may be replaced. Resolves `true` only on
|
||||
* an explicit "Replace"; dismissing, auto-close and "Skip" all mean skip, so
|
||||
* the batch can never overwrite silently.
|
||||
*/
|
||||
function askReplaceExisting(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
count: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
toast.warning(t("fileManager.localReplaceExistingPrompt", { count }), {
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: t("fileManager.localReplace"),
|
||||
onClick: () => settle(true),
|
||||
},
|
||||
cancel: {
|
||||
label: t("fileManager.localSkipExisting"),
|
||||
onClick: () => settle(false),
|
||||
},
|
||||
onDismiss: () => settle(false),
|
||||
onAutoClose: () => settle(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One line a user can act on, taken from whatever the bridge or backend said. */
|
||||
function describeTransferError(error: unknown): string | undefined {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? "").trim();
|
||||
return message ? message.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
function createSpeedometer() {
|
||||
let lastBytes = 0;
|
||||
let lastTime = Date.now();
|
||||
let mbPerSec: number | undefined;
|
||||
return (bytesDone: number) => {
|
||||
const now = Date.now();
|
||||
const deltaMs = now - lastTime;
|
||||
if (deltaMs >= 300) {
|
||||
const deltaBytes = bytesDone - lastBytes;
|
||||
if (deltaBytes >= 0) {
|
||||
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
|
||||
}
|
||||
lastBytes = bytesDone;
|
||||
lastTime = now;
|
||||
}
|
||||
return mbPerSec;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates batched local<->remote transfers for the dual-pane file
|
||||
* manager: expands folders, creates directory skeletons, streams files one by
|
||||
* one through the Electron main process, and reports aggregate progress in a
|
||||
* single toast with a cancel button.
|
||||
*/
|
||||
export function useLocalTransfers({
|
||||
sshSessionId,
|
||||
hostId,
|
||||
ensureSSHConnection,
|
||||
onRemoteChanged,
|
||||
onLocalChanged,
|
||||
}: UseLocalTransfersOptions) {
|
||||
const { t } = useTranslation();
|
||||
const batchCounter = useRef(0);
|
||||
|
||||
const runBatch = useCallback(
|
||||
async (
|
||||
direction: "upload" | "download",
|
||||
totalFiles: number,
|
||||
totalBytes: number,
|
||||
work: (ctx: {
|
||||
isCancelled: () => boolean;
|
||||
/** Marks a transfer as in flight so Cancel can reach it. */
|
||||
trackTransfer: (id: string, fileName: string) => void;
|
||||
/** Progress of one in-flight transfer; bytes are summed across all. */
|
||||
progress: (id: string, transferred: number) => void;
|
||||
/** Transfer finished (success or failure); its size is now settled. */
|
||||
settleTransfer: (id: string, size: number) => void;
|
||||
}) => Promise<{ failed: string[]; reason?: string }>,
|
||||
) => {
|
||||
batchCounter.current += 1;
|
||||
const toastId = `local-transfer-${batchCounter.current}`;
|
||||
let cancelled = false;
|
||||
let cancelling = false;
|
||||
const inFlight = new Map<
|
||||
string,
|
||||
{ fileName: string; transferred: number }
|
||||
>();
|
||||
let settledBytes = 0;
|
||||
let completedFiles = 0;
|
||||
let lastFileName: string | undefined;
|
||||
const speed = createSpeedometer();
|
||||
|
||||
const status: LocalTransferBatchStatus = {
|
||||
direction,
|
||||
totalFiles,
|
||||
completedFiles: 0,
|
||||
bytesDone: 0,
|
||||
totalBytes,
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
toast.loading(
|
||||
createElement(LocalTransferProgressToast, {
|
||||
status: { ...status, cancelling },
|
||||
onCancel: () => {
|
||||
cancelled = true;
|
||||
cancelling = true;
|
||||
render();
|
||||
for (const id of inFlight.keys()) void cancelLocalTransfer(id);
|
||||
},
|
||||
}),
|
||||
{ id: toastId, duration: Infinity },
|
||||
);
|
||||
};
|
||||
render();
|
||||
|
||||
try {
|
||||
const refresh = () => {
|
||||
let bytesDone = settledBytes;
|
||||
for (const entry of inFlight.values()) bytesDone += entry.transferred;
|
||||
status.completedFiles = completedFiles;
|
||||
status.bytesDone = bytesDone;
|
||||
status.activeFiles = inFlight.size;
|
||||
status.currentFileName =
|
||||
lastFileName ?? inFlight.values().next().value?.fileName;
|
||||
status.mbPerSec = speed(bytesDone);
|
||||
render();
|
||||
};
|
||||
const { failed, reason } = await work({
|
||||
isCancelled: () => cancelled,
|
||||
trackTransfer: (id, fileName) => {
|
||||
inFlight.set(id, { fileName, transferred: 0 });
|
||||
lastFileName = fileName;
|
||||
refresh();
|
||||
},
|
||||
progress: (id, transferred) => {
|
||||
const entry = inFlight.get(id);
|
||||
if (!entry) return;
|
||||
entry.transferred = transferred;
|
||||
lastFileName = entry.fileName;
|
||||
refresh();
|
||||
},
|
||||
settleTransfer: (id, size) => {
|
||||
inFlight.delete(id);
|
||||
settledBytes += size;
|
||||
completedFiles += 1;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
toast.dismiss(toastId);
|
||||
if (cancelled) {
|
||||
toast.info(t("fileManager.localTransferCancelled"));
|
||||
return;
|
||||
}
|
||||
const key = direction === "upload" ? "Upload" : "Download";
|
||||
if (failed.length === 0) {
|
||||
toast.success(
|
||||
t(`fileManager.local${key}Complete`, { count: totalFiles }),
|
||||
);
|
||||
} else if (failed.length === totalFiles) {
|
||||
// Every file failed the same way more often than not (auth,
|
||||
// permissions, a dead session); say why instead of just "failed".
|
||||
toast.error(
|
||||
t(`fileManager.local${key}Failed`),
|
||||
reason ? { description: reason } : undefined,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
t(`fileManager.local${key}Partial`, {
|
||||
done: totalFiles - failed.length,
|
||||
failed: failed.length,
|
||||
}),
|
||||
reason ? { description: reason } : undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(toastId);
|
||||
if (error instanceof TransferCancelledError || cancelled) {
|
||||
toast.info(t("fileManager.localTransferCancelled"));
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? "");
|
||||
toast.error(
|
||||
direction === "upload"
|
||||
? t("fileManager.localUploadFailed")
|
||||
: t("fileManager.localDownloadFailed"),
|
||||
message ? { description: message } : undefined,
|
||||
);
|
||||
console.error(`Local ${direction} batch failed:`, error);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
/** Uploads local files/folders (by absolute path) into a remote directory. */
|
||||
const uploadLocalPaths = useCallback(
|
||||
async (localPaths: string[], remoteDir: string) => {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
if (localPaths.length === 0) return;
|
||||
|
||||
let plan: LocalWalkResult;
|
||||
try {
|
||||
plan = await walkLocalPaths(localPaths);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("fileManager.localUploadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (plan.files.length === 0 && plan.emptyDirs.length === 0) {
|
||||
toast.info(t("fileManager.localNothingToTransfer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = sshSessionId;
|
||||
await runBatch(
|
||||
"upload",
|
||||
plan.files.length,
|
||||
plan.totalBytes,
|
||||
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const dirs = planRemoteDirectories(
|
||||
plan.files.map((f) => f.relativePath),
|
||||
plan.emptyDirs,
|
||||
);
|
||||
for (const dir of dirs) {
|
||||
if (isCancelled()) throw new TransferCancelledError();
|
||||
const parent = dir.includes("/")
|
||||
? joinRemotePath(remoteDir, dir.slice(0, dir.lastIndexOf("/")))
|
||||
: remoteDir;
|
||||
const name = dir.split("/").pop()!;
|
||||
// The backend tolerates existing directories (#1442), so a
|
||||
// failure here is real and should stop the batch up front.
|
||||
await createSSHFolder(sessionId, parent, name, hostId);
|
||||
}
|
||||
|
||||
// Files go up several at a time (see "Simultaneous File Transfers"
|
||||
// in the profile settings); directories were created above, so
|
||||
// order between files no longer matters.
|
||||
const failed: string[] = [];
|
||||
let reason: string | undefined;
|
||||
await runWithConcurrency(
|
||||
plan.files,
|
||||
getTransferConcurrency(),
|
||||
async (file) => {
|
||||
const fileName = file.relativePath.split("/").pop()!;
|
||||
const targetDir = remoteDirForRelativePath(
|
||||
remoteDir,
|
||||
file.relativePath,
|
||||
);
|
||||
const transferId = createLocalTransferId("local-upload");
|
||||
trackTransfer(transferId, fileName);
|
||||
try {
|
||||
await uploadLocalFileToSession({
|
||||
sessionId,
|
||||
remoteDir: targetDir,
|
||||
localPath: file.localPath,
|
||||
fileName,
|
||||
hostId,
|
||||
transferId,
|
||||
onProgress: ({ transferred }) =>
|
||||
progress(transferId, transferred),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCancelled()) {
|
||||
failed.push(file.relativePath);
|
||||
reason ??= describeTransferError(error);
|
||||
console.error(`Failed to upload ${file.localPath}:`, error);
|
||||
}
|
||||
} finally {
|
||||
settleTransfer(transferId, file.size);
|
||||
}
|
||||
},
|
||||
isCancelled,
|
||||
);
|
||||
return { failed, reason };
|
||||
},
|
||||
);
|
||||
|
||||
onRemoteChanged(remoteDir);
|
||||
},
|
||||
[sshSessionId, hostId, ensureSSHConnection, onRemoteChanged, runBatch, t],
|
||||
);
|
||||
|
||||
/** Downloads remote files/folders into a local directory. */
|
||||
const downloadRemoteItems = useCallback(
|
||||
async (items: FileItem[], localDir: string) => {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) return;
|
||||
const sessionId = sshSessionId;
|
||||
|
||||
const { separator } = await getLocalHome();
|
||||
// Every remote name is validated for the local platform and the result
|
||||
// must stay inside `localDir`; anything else is reported and skipped.
|
||||
const toLocalPath = (relativePath: string) =>
|
||||
buildLocalDestination(localDir, relativePath, separator);
|
||||
|
||||
// Expand directories into a flat file plan first so the toast can show
|
||||
// a real total. Listing is the only part that goes through the
|
||||
// renderer's normal API path.
|
||||
const plan: RemoteDownloadPlanEntry[] = [];
|
||||
const emptyDirs: string[] = [];
|
||||
|
||||
const expandTaskId = `local-download-expand-${Date.now()}`;
|
||||
toast.loading(t("fileManager.localPreparingDownload"), {
|
||||
id: expandTaskId,
|
||||
duration: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const walkRemote = async (remotePath: string, relDir: string) => {
|
||||
const { files } = await listSSHFiles(sessionId, remotePath, {
|
||||
force: true,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
emptyDirs.push(relDir);
|
||||
return;
|
||||
}
|
||||
for (const child of files) {
|
||||
const rel = `${relDir}/${child.name}`;
|
||||
if (child.type === "directory") {
|
||||
await walkRemote(child.path, rel);
|
||||
} else if (child.type === "file" || child.type === "link") {
|
||||
plan.push({
|
||||
remotePath: child.path,
|
||||
relativePath: rel,
|
||||
size: child.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const name = item.name || remoteBaseName(item.path);
|
||||
if (item.type === "directory") {
|
||||
await walkRemote(item.path, name);
|
||||
} else {
|
||||
plan.push({
|
||||
remotePath: item.path,
|
||||
relativePath: name,
|
||||
size: item.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(expandTaskId);
|
||||
toast.error(
|
||||
t("fileManager.localDownloadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.dismiss(expandTaskId);
|
||||
|
||||
if (plan.length === 0 && emptyDirs.length === 0) {
|
||||
toast.info(t("fileManager.localNothingToTransfer"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop anything whose remote name cannot become a safe local path
|
||||
// (separators, traversal, Windows-invalid characters). These never
|
||||
// reach the filesystem; the user is told how many were skipped.
|
||||
const unsafe: string[] = [];
|
||||
const safeDest = (relativePath: string): string | null => {
|
||||
try {
|
||||
return toLocalPath(relativePath);
|
||||
} catch (error) {
|
||||
if (error instanceof UnsafeLocalNameError) {
|
||||
unsafe.push(relativePath);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const plannedDirs = emptyDirs
|
||||
.map((dir) => ({ dir, dest: safeDest(dir) }))
|
||||
.filter((d): d is { dir: string; dest: string } => d.dest !== null);
|
||||
const plannedFiles = plan
|
||||
.map((entry) => ({ entry, dest: safeDest(entry.relativePath) }))
|
||||
.filter(
|
||||
(p): p is { entry: RemoteDownloadPlanEntry; dest: string } =>
|
||||
p.dest !== null,
|
||||
);
|
||||
if (unsafe.length > 0) {
|
||||
toast.error(
|
||||
t("fileManager.localUnsafeNamesSkipped", { count: unsafe.length }),
|
||||
{ description: unsafe.slice(0, 3).join(", ") },
|
||||
);
|
||||
console.warn("Skipped remote items with unsafe local names:", unsafe);
|
||||
}
|
||||
if (plannedFiles.length === 0 && plannedDirs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collision policy: never replace silently. Find destinations that
|
||||
// already exist and let the user choose Replace or Skip for the batch.
|
||||
const destinations = plannedFiles.map((p) => p.dest);
|
||||
let existing: Set<string>;
|
||||
try {
|
||||
existing = new Set(await localPathsExist(destinations));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("fileManager.localDownloadFailed"),
|
||||
error instanceof Error ? { description: error.message } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let overwriteExisting = false;
|
||||
if (existing.size > 0) {
|
||||
overwriteExisting = await askReplaceExisting(t, existing.size);
|
||||
}
|
||||
const skipped = overwriteExisting
|
||||
? []
|
||||
: plannedFiles.filter(({ dest }) => existing.has(dest));
|
||||
const work = overwriteExisting
|
||||
? plannedFiles
|
||||
: plannedFiles.filter(({ dest }) => !existing.has(dest));
|
||||
if (skipped.length > 0) {
|
||||
toast.info(
|
||||
t("fileManager.localSkippedExisting", { count: skipped.length }),
|
||||
);
|
||||
}
|
||||
if (work.length === 0 && plannedDirs.length === 0) {
|
||||
onLocalChanged(localDir);
|
||||
return;
|
||||
}
|
||||
const workBytes = work.reduce((sum, w) => sum + (w.entry.size ?? 0), 0);
|
||||
|
||||
await runBatch(
|
||||
"download",
|
||||
work.length,
|
||||
workBytes,
|
||||
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
|
||||
for (const { dest } of plannedDirs) {
|
||||
if (isCancelled()) throw new TransferCancelledError();
|
||||
await ensureLocalDirectory(dest, localDir);
|
||||
}
|
||||
|
||||
const failed: string[] = [];
|
||||
let reason: string | undefined;
|
||||
await runWithConcurrency(
|
||||
work,
|
||||
getTransferConcurrency(),
|
||||
async ({ entry, dest }) => {
|
||||
const fileName = entry.relativePath.split("/").pop()!;
|
||||
const transferId = createLocalTransferId("local-download");
|
||||
trackTransfer(transferId, fileName);
|
||||
try {
|
||||
await downloadSessionFileToLocal({
|
||||
sessionId,
|
||||
remotePath: entry.remotePath,
|
||||
destPath: dest,
|
||||
rootPath: localDir,
|
||||
expectedSize: entry.size,
|
||||
overwrite: overwriteExisting && existing.has(dest),
|
||||
transferId,
|
||||
onProgress: ({ transferred }) =>
|
||||
progress(transferId, transferred),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCancelled()) {
|
||||
failed.push(entry.relativePath);
|
||||
reason ??= describeTransferError(error);
|
||||
console.error(
|
||||
`Failed to download ${entry.remotePath}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
settleTransfer(transferId, entry.size ?? 0);
|
||||
}
|
||||
},
|
||||
isCancelled,
|
||||
);
|
||||
return { failed, reason };
|
||||
},
|
||||
);
|
||||
|
||||
onLocalChanged(localDir);
|
||||
},
|
||||
[sshSessionId, ensureSSHConnection, onLocalChanged, runBatch, t],
|
||||
);
|
||||
|
||||
return { uploadLocalPaths, downloadRemoteItems };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
interface UsePaneWidthOptions {
|
||||
/** localStorage key the width is remembered under. */
|
||||
storageKey: string;
|
||||
defaultWidth: number;
|
||||
minWidth: number;
|
||||
/**
|
||||
* Upper bound as a fraction of the containing row's width (measured at
|
||||
* drag start from `containerRef`), so a pane can never swallow the layout.
|
||||
*/
|
||||
maxFraction?: number;
|
||||
containerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function readStoredWidth(
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
minWidth: number,
|
||||
): number {
|
||||
try {
|
||||
const saved = Number(localStorage.getItem(storageKey));
|
||||
return Number.isFinite(saved) && saved >= minWidth ? saved : defaultWidth;
|
||||
} catch {
|
||||
return defaultWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted, drag-resizable pane width. `startResize` goes on the
|
||||
* mousedown of a vertical drag handle sitting on the pane's right edge;
|
||||
* `resetWidth` (e.g. on double-click) restores the default.
|
||||
*/
|
||||
export function usePaneWidth({
|
||||
storageKey,
|
||||
defaultWidth,
|
||||
minWidth,
|
||||
maxFraction = 0.65,
|
||||
containerRef,
|
||||
}: UsePaneWidthOptions) {
|
||||
const [width, setWidth] = useState<number>(() =>
|
||||
readStoredWidth(storageKey, defaultWidth, minWidth),
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
widthRef.current = width;
|
||||
|
||||
const persist = useCallback(
|
||||
(value: number) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = widthRef.current;
|
||||
const rowWidth =
|
||||
containerRef?.current?.getBoundingClientRect().width ?? 0;
|
||||
const maxWidth = Math.max(
|
||||
minWidth,
|
||||
rowWidth ? rowWidth * maxFraction : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
let latest = startWidth;
|
||||
setIsResizing(true);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
latest = Math.round(
|
||||
Math.min(
|
||||
maxWidth,
|
||||
Math.max(minWidth, startWidth + (e.clientX - startX)),
|
||||
),
|
||||
);
|
||||
if (latest !== widthRef.current) setWidth(latest);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setIsResizing(false);
|
||||
persist(latest);
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[containerRef, maxFraction, minWidth, persist],
|
||||
);
|
||||
|
||||
const resetWidth = useCallback(() => {
|
||||
setWidth(defaultWidth);
|
||||
persist(defaultWidth);
|
||||
}, [defaultWidth, persist]);
|
||||
|
||||
return { width, isResizing, startResize, resetWidth };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
export interface ResizableColumnSpec {
|
||||
/** Stable key, also used for persistence. */
|
||||
key: string;
|
||||
/** i18n key for the column's label (used by the show/hide menu). */
|
||||
labelKey: string;
|
||||
defaultWidth: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
interface UseResizableColumnsOptions {
|
||||
/** localStorage key; widths are remembered per pane. */
|
||||
storageKey: string;
|
||||
/**
|
||||
* Fixed-width columns, in visual order, that follow a leading flexible
|
||||
* column (the file name). The flexible column absorbs whatever is left.
|
||||
*/
|
||||
columns: ResizableColumnSpec[];
|
||||
}
|
||||
|
||||
const DEFAULT_MIN = 48;
|
||||
const DEFAULT_MAX = 600;
|
||||
|
||||
function readStoredWidths(
|
||||
storageKey: string,
|
||||
columns: ResizableColumnSpec[],
|
||||
): Record<string, number> {
|
||||
const widths: Record<string, number> = {};
|
||||
for (const column of columns) widths[column.key] = column.defaultWidth;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return widths;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
for (const column of columns) {
|
||||
const value = parsed?.[column.key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
widths[column.key] = clamp(value, column);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable or corrupt: fall back to defaults
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
function readHiddenKeys(
|
||||
storageKey: string,
|
||||
columns: ResizableColumnSpec[],
|
||||
): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${storageKey}:hidden`);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
const known = new Set(columns.map((c) => c.key));
|
||||
return new Set(
|
||||
parsed.filter((k): k is string => typeof k === "string" && known.has(k)),
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, column: ResizableColumnSpec): number {
|
||||
const min = column.minWidth ?? DEFAULT_MIN;
|
||||
const max = column.maxWidth ?? DEFAULT_MAX;
|
||||
return Math.round(Math.min(max, Math.max(min, value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Column widths for a list view whose first column is flexible and whose
|
||||
* remaining columns are fixed and user-resizable by dragging the boundary at
|
||||
* the left edge of each header cell. Double-clicking a handle resets that
|
||||
* column to its default. Widths persist in localStorage.
|
||||
*/
|
||||
export function useResizableColumns({
|
||||
storageKey,
|
||||
columns,
|
||||
}: UseResizableColumnsOptions) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>(() =>
|
||||
readStoredWidths(storageKey, columns),
|
||||
);
|
||||
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(() =>
|
||||
readHiddenKeys(storageKey, columns),
|
||||
);
|
||||
const [resizingKey, setResizingKey] = useState<string | null>(null);
|
||||
const widthsRef = useRef(widths);
|
||||
widthsRef.current = widths;
|
||||
|
||||
// Re-read if the pane is re-keyed (e.g. a different storage key).
|
||||
useEffect(() => {
|
||||
setWidths(readStoredWidths(storageKey, columns));
|
||||
setHiddenKeys(readHiddenKeys(storageKey, columns));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(key: string) => !hiddenKeys.has(key),
|
||||
[hiddenKeys],
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => columns.filter((c) => !hiddenKeys.has(c.key)),
|
||||
[columns, hiddenKeys],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(key: string) => {
|
||||
setHiddenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${storageKey}:hidden`,
|
||||
JSON.stringify(Array.from(next)),
|
||||
);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
(next: Record<string, number>) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const gridTemplateColumns = useMemo(
|
||||
() =>
|
||||
[
|
||||
"minmax(140px, 1fr)",
|
||||
...visibleColumns.map((c) => `${widths[c.key]}px`),
|
||||
].join(" "),
|
||||
[visibleColumns, widths],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
(key: string, event: React.MouseEvent) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
if (!column) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = widthsRef.current[key];
|
||||
setResizingKey(key);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// The handle sits on the column's left edge, so dragging right
|
||||
// narrows the column and dragging left widens it.
|
||||
const next = clamp(startWidth - (e.clientX - startX), column);
|
||||
if (next !== widthsRef.current[key]) {
|
||||
setWidths((prev) => ({ ...prev, [key]: next }));
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setResizingKey(null);
|
||||
persist(widthsRef.current);
|
||||
|
||||
// The browser dispatches a click to the common ancestor of the
|
||||
// mousedown/mouseup targets, which here is the sortable header cell.
|
||||
// Swallow that one click so finishing a drag never toggles the sort.
|
||||
const swallow = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener("click", swallow, { capture: true });
|
||||
setTimeout(
|
||||
() =>
|
||||
document.removeEventListener("click", swallow, { capture: true }),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[columns, persist],
|
||||
);
|
||||
|
||||
const resetColumn = useCallback(
|
||||
(key: string) => {
|
||||
const column = columns.find((c) => c.key === key);
|
||||
if (!column) return;
|
||||
setWidths((prev) => {
|
||||
const next = { ...prev, [key]: column.defaultWidth };
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[columns, persist],
|
||||
);
|
||||
|
||||
const getHandleProps = useCallback(
|
||||
(key: string) => ({
|
||||
onMouseDown: (event: React.MouseEvent) => startResize(key, event),
|
||||
onDoubleClick: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resetColumn(key);
|
||||
},
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
"data-resizing": resizingKey === key ? "true" : undefined,
|
||||
}),
|
||||
[startResize, resetColumn, resizingKey],
|
||||
);
|
||||
|
||||
return {
|
||||
widths,
|
||||
gridTemplateColumns,
|
||||
getHandleProps,
|
||||
resizingKey,
|
||||
columns,
|
||||
visibleColumns,
|
||||
isVisible,
|
||||
toggleColumn,
|
||||
};
|
||||
}
|
||||
|
||||
export type ResizableColumns = ReturnType<typeof useResizableColumns>;
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderSearch, ArrowLeftRight } from "lucide-react";
|
||||
import type {
|
||||
PluginHostRecord,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { FileManager } from "./FileManager.tsx";
|
||||
import FileManagerApp from "./FileManagerApp.tsx";
|
||||
import { SftpTransferTab } from "./SftpTransferTab.tsx";
|
||||
import { HostFilesTab } from "./HostFilesTab.tsx";
|
||||
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
|
||||
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
|
||||
import { startTransferMonitor } from "./TransferMonitor.tsx";
|
||||
|
||||
function FilesTab({ tab, host, sshHost, isVisible, shell }: TabProps) {
|
||||
const data = tab.data as
|
||||
{ initialFilePath?: string; initialPath?: string } | undefined;
|
||||
return (
|
||||
<FileManager
|
||||
initialHost={sshHost as never}
|
||||
initialFilePath={data?.initialFilePath}
|
||||
initialPath={data?.initialPath}
|
||||
isVisible={isVisible}
|
||||
onOpenTerminalTab={
|
||||
host ? (path) => shell.openTerminalTab(host, path) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
return (
|
||||
<FileManagerApp
|
||||
hostId={hostId}
|
||||
initialPath={params.get("path") ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SftpTab() {
|
||||
return <SftpTransferTab />;
|
||||
}
|
||||
|
||||
function openHostAction(
|
||||
app: TermixApp,
|
||||
host: PluginHostRecord | null,
|
||||
path: string | undefined,
|
||||
): void {
|
||||
if (!host) return;
|
||||
app.tabs.openTab(host, "files", { data: { initialPath: path } });
|
||||
}
|
||||
|
||||
function openEditorAction(
|
||||
app: TermixApp,
|
||||
host: PluginHostRecord | null,
|
||||
filePath: string,
|
||||
): void {
|
||||
if (!host) return;
|
||||
app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } });
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
|
||||
icon: FolderSearch,
|
||||
titleKey: "nav.files",
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "fileManager.noHostSelected",
|
||||
persistent: true,
|
||||
standalone: FilesStandalone,
|
||||
// The pre-conversion `?view=file-manager` link keeps resolving.
|
||||
standaloneViews: ["file-manager"],
|
||||
// The activity log still records "file_manager" (matching the backend's
|
||||
// stored string), so recent-activity entries resolve to this tab.
|
||||
activityTypes: ["file_manager"],
|
||||
});
|
||||
|
||||
app.registerTab("sftp", SftpTab as unknown as ComponentType<TabProps>, {
|
||||
icon: ArrowLeftRight,
|
||||
titleKey: "nav.sftp",
|
||||
hostless: true,
|
||||
inLayouts: false,
|
||||
});
|
||||
|
||||
app.registerRailItem({
|
||||
id: "sftp",
|
||||
icon: ArrowLeftRight,
|
||||
titleKey: "nav.sftp",
|
||||
kind: "tab",
|
||||
after: "ssh-tools",
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "files",
|
||||
titleKey: "nav.files",
|
||||
icon: FolderSearch,
|
||||
kind: "open",
|
||||
order: 20,
|
||||
tabType: "files",
|
||||
copyUrlView: "file-manager",
|
||||
when: (host) => !!host.enableSsh && host.enableFileManager !== false,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "files",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabFiles",
|
||||
icon: FolderSearch,
|
||||
order: 60,
|
||||
component: HostFilesTab as never,
|
||||
});
|
||||
|
||||
app.registerHomepageWidget({
|
||||
id: "file_manager_widget",
|
||||
name: "File Manager",
|
||||
description: "Embedded SFTP file manager for a configured host",
|
||||
category: "system",
|
||||
icon: <FolderSearch size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: FileManagerWidget as never,
|
||||
editFormComponent: FileManagerWidgetEditForm as never,
|
||||
});
|
||||
|
||||
app.registerAction("files.openHost", ((
|
||||
host: PluginHostRecord | null,
|
||||
path?: string,
|
||||
) => openHostAction(app, host, path)) as never);
|
||||
app.registerAction("files.openEditor", ((
|
||||
host: PluginHostRecord | null,
|
||||
filePath: string,
|
||||
) => openEditorAction(app, host, filePath)) as never);
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "file-manager.feature",
|
||||
titleKey: "onboarding.feature_files",
|
||||
descriptionKey: "onboarding.feature_files_desc",
|
||||
icon: FolderSearch as ComponentType<{ className?: string }>,
|
||||
});
|
||||
|
||||
const stopTransferMonitor = startTransferMonitor(app.t);
|
||||
app.onDispose(stopTransferMonitor);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
|
||||
|
||||
export interface FileContentResult {
|
||||
content: string;
|
||||
path: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}
|
||||
|
||||
const cache = createKeyedRequestCache<FileContentResult>(30_000, 24);
|
||||
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
|
||||
|
||||
export function getCachedFileContent(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
loader: () => Promise<FileContentResult>,
|
||||
force = false,
|
||||
): Promise<FileContentResult> {
|
||||
return cache.get(keyFor(sessionId, path), loader, { force });
|
||||
}
|
||||
|
||||
export function invalidateCachedFileContent(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): void {
|
||||
cache.invalidate(keyFor(sessionId, path));
|
||||
}
|
||||
|
||||
export function shouldPrefetchFileContent(
|
||||
file: FileItem,
|
||||
maxPrefetchBytes: number,
|
||||
): boolean {
|
||||
return (
|
||||
file.type === "file" &&
|
||||
typeof file.size === "number" &&
|
||||
file.size <= maxPrefetchBytes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
|
||||
|
||||
export interface FileListResult {
|
||||
files: FileItem[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
const cache = createKeyedRequestCache<FileListResult>(5_000, 150);
|
||||
|
||||
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
|
||||
|
||||
export function getCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
loader: () => Promise<FileListResult>,
|
||||
force = false,
|
||||
): Promise<FileListResult> {
|
||||
return cache.get(keyFor(sessionId, path), loader, { force });
|
||||
}
|
||||
|
||||
export function peekCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
maxAgeMs = 30_000,
|
||||
): FileListResult | null {
|
||||
return cache.peek(keyFor(sessionId, path), maxAgeMs);
|
||||
}
|
||||
|
||||
export function invalidateCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): void {
|
||||
cache.invalidate(keyFor(sessionId, path));
|
||||
}
|
||||
|
||||
export function updateCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
files: FileItem[],
|
||||
): void {
|
||||
cache.set(keyFor(sessionId, path), { files, path });
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
|
||||
// Kept free of React/DOM so they can be unit tested directly.
|
||||
|
||||
import type { LocalFileEntry } from "@/types/electron";
|
||||
|
||||
/** Custom MIME type carried by drags that originate in the local pane. */
|
||||
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
|
||||
|
||||
/**
|
||||
* Custom MIME type the remote grid adds to its internal drags so other panes
|
||||
* can recognise them during dragenter/dragover (when payloads are unreadable).
|
||||
*/
|
||||
export const REMOTE_FILES_DRAG_MIME = "application/x-termix-remote-files";
|
||||
|
||||
export interface LocalFilesDragPayload {
|
||||
type: "local_files";
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export interface InternalFilesDragPayload {
|
||||
type: "internal_files";
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export function serializeLocalFilesDragPayload(paths: string[]): string {
|
||||
const payload: LocalFilesDragPayload = { type: "local_files", paths };
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseLocalFilesDragPayload(
|
||||
raw: string | null | undefined,
|
||||
): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<LocalFilesDragPayload>;
|
||||
if (parsed?.type !== "local_files" || !Array.isArray(parsed.paths)) {
|
||||
return null;
|
||||
}
|
||||
const paths = parsed.paths.filter(
|
||||
(p): p is string => typeof p === "string" && p.length > 0,
|
||||
);
|
||||
return paths.length > 0 ? paths : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the payload the remote grid puts on `text/plain` for internal drags. */
|
||||
export function parseInternalFilesDragPayload(
|
||||
raw: string | null | undefined,
|
||||
): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<InternalFilesDragPayload>;
|
||||
if (parsed?.type !== "internal_files" || !Array.isArray(parsed.files)) {
|
||||
return null;
|
||||
}
|
||||
const files = parsed.files.filter(
|
||||
(p): p is string => typeof p === "string" && p.length > 0,
|
||||
);
|
||||
return files.length > 0 ? files : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True while a drag that started in the local pane is over the element. */
|
||||
export function isLocalFilesDrag(
|
||||
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
|
||||
): boolean {
|
||||
if (!dataTransfer) return false;
|
||||
return Array.from(dataTransfer.types ?? []).includes(LOCAL_FILES_DRAG_MIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a drag that starts on remote rows. Within the remote grid a drop is a
|
||||
* move; onto the local pane it is a copy (a download). The source has to
|
||||
* allow both, because Chromium refuses a drop whose dropEffect is not in
|
||||
* effectAllowed without firing any event at all -- the drop just silently
|
||||
* does nothing.
|
||||
*/
|
||||
export function beginRemoteFilesDrag(
|
||||
dataTransfer: Pick<DataTransfer, "setData" | "effectAllowed">,
|
||||
remotePaths: string[],
|
||||
): void {
|
||||
const payload: InternalFilesDragPayload = {
|
||||
type: "internal_files",
|
||||
files: remotePaths,
|
||||
};
|
||||
dataTransfer.setData("text/plain", JSON.stringify(payload));
|
||||
// Lets sibling panes recognise this drag before the payload is readable.
|
||||
dataTransfer.setData(REMOTE_FILES_DRAG_MIME, "1");
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
}
|
||||
|
||||
/** True while a drag that started in the remote grid is over the element. */
|
||||
export function isRemoteFilesDrag(
|
||||
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
|
||||
): boolean {
|
||||
if (!dataTransfer) return false;
|
||||
return Array.from(dataTransfer.types ?? []).includes(REMOTE_FILES_DRAG_MIME);
|
||||
}
|
||||
|
||||
/** Joins a POSIX remote directory with one or more path segments. */
|
||||
export function joinRemotePath(base: string, ...segments: string[]): string {
|
||||
let out = base || "/";
|
||||
for (const segment of segments) {
|
||||
const clean = segment.replace(/^\/+|\/+$/g, "");
|
||||
if (!clean) continue;
|
||||
out = out.endsWith("/") ? `${out}${clean}` : `${out}/${clean}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Joins a local directory and a file name using the platform separator. */
|
||||
export function joinLocalPath(
|
||||
base: string,
|
||||
name: string,
|
||||
separator: string,
|
||||
): string {
|
||||
const sep = separator || "/";
|
||||
const trimmedBase = base.endsWith(sep) ? base.slice(0, -sep.length) : base;
|
||||
// Root on POSIX is "/" which trims to ""; keep the separator in that case.
|
||||
return `${trimmedBase}${sep}${name}`;
|
||||
}
|
||||
|
||||
/** Thrown when a remote name cannot be used as a local path component. */
|
||||
export class UnsafeLocalNameError extends Error {
|
||||
readonly code = "EINVAL";
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly reason: string,
|
||||
) {
|
||||
super(`Unsafe file name "${name}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Windows refuses these device names in any directory, with any extension.
|
||||
const WINDOWS_RESERVED_NAMES =
|
||||
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.[^.]*)?$/i;
|
||||
|
||||
/**
|
||||
* Validates one remote path component for use as a single local path
|
||||
* segment on the destination platform (`separator` "\\" means Windows).
|
||||
*
|
||||
* A POSIX file name may legally contain "\\", ":" or end in a dot, all of
|
||||
* which Windows treats as separators, drive prefixes or strips — so a remote
|
||||
* name like "..\\outside.txt" would otherwise be normalised out of the
|
||||
* selected download folder. Anything that is not a plain name is rejected;
|
||||
* traversal ("..") and separators are rejected on every platform.
|
||||
*/
|
||||
export function assertSafeLocalComponent(
|
||||
name: string,
|
||||
separator: string,
|
||||
): string {
|
||||
if (name === "" || name === "." || name === "..") {
|
||||
throw new UnsafeLocalNameError(name, "empty or traversal component");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\0")) {
|
||||
throw new UnsafeLocalNameError(name, "contains a path separator or NUL");
|
||||
}
|
||||
if (separator === "\\") {
|
||||
if (/[\\:*?"<>|]/.test(name)) {
|
||||
throw new UnsafeLocalNameError(
|
||||
name,
|
||||
"contains a character Windows does not allow in file names",
|
||||
);
|
||||
}
|
||||
if (/[\u0001-\u001f]/.test(name)) {
|
||||
throw new UnsafeLocalNameError(name, "contains control characters");
|
||||
}
|
||||
if (/[. ]$/.test(name)) {
|
||||
throw new UnsafeLocalNameError(
|
||||
name,
|
||||
"Windows strips trailing dots and spaces",
|
||||
);
|
||||
}
|
||||
if (WINDOWS_RESERVED_NAMES.test(name)) {
|
||||
throw new UnsafeLocalNameError(name, "reserved device name on Windows");
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function samePathPrefix(a: string, b: string, separator: string): boolean {
|
||||
return separator === "\\" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the local destination for a "/"-separated relative remote path
|
||||
* under `localDir`, validating every component for the destination platform
|
||||
* and asserting the result stays strictly inside `localDir`.
|
||||
*/
|
||||
export function buildLocalDestination(
|
||||
localDir: string,
|
||||
relativePath: string,
|
||||
separator: string,
|
||||
): string {
|
||||
const sep = separator || "/";
|
||||
const parts = relativePath.split("/").filter((p) => p !== "");
|
||||
if (parts.length === 0) {
|
||||
throw new UnsafeLocalNameError(relativePath, "empty path");
|
||||
}
|
||||
const dest = parts.reduce(
|
||||
(acc, part) => joinLocalPath(acc, assertSafeLocalComponent(part, sep), sep),
|
||||
localDir,
|
||||
);
|
||||
const root = localDir.endsWith(sep) ? localDir : `${localDir}${sep}`;
|
||||
if (
|
||||
!samePathPrefix(dest.slice(0, root.length), root, sep) ||
|
||||
dest.length <= root.length
|
||||
) {
|
||||
throw new UnsafeLocalNameError(
|
||||
relativePath,
|
||||
"destination escapes the selected folder",
|
||||
);
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function remoteBaseName(remotePath: string): string {
|
||||
const trimmed = remotePath.replace(/\/+$/, "");
|
||||
return trimmed.split("/").pop() || trimmed || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the "/"-separated relative paths of files being uploaded (each
|
||||
* including its top-level root name) plus any empty directories, returns the
|
||||
* set of directories that must exist on the remote, shallowest first, so each
|
||||
* parent is created before its children.
|
||||
*/
|
||||
export function planRemoteDirectories(
|
||||
fileRelativePaths: string[],
|
||||
emptyDirs: string[] = [],
|
||||
): string[] {
|
||||
const dirs = new Set<string>();
|
||||
|
||||
for (const relativePath of fileRelativePaths) {
|
||||
const parts = relativePath.split("/").filter(Boolean);
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
for (const dir of emptyDirs) {
|
||||
const parts = dir.split("/").filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dirs).sort(
|
||||
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
/** Remote directory a file with the given relative path should land in. */
|
||||
export function remoteDirForRelativePath(
|
||||
base: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const idx = relativePath.lastIndexOf("/");
|
||||
if (idx <= 0) return base;
|
||||
return joinRemotePath(base, relativePath.slice(0, idx));
|
||||
}
|
||||
|
||||
/** Short "Kind" column label, in the spirit of Finder / Termius. */
|
||||
export function describeLocalKind(
|
||||
entry: Pick<LocalFileEntry, "name" | "type">,
|
||||
): string {
|
||||
if (entry.type === "directory") return "folder";
|
||||
if (entry.type === "link") return "link";
|
||||
const dot = entry.name.lastIndexOf(".");
|
||||
if (dot > 0 && dot < entry.name.length - 1) {
|
||||
return entry.name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
export type LocalSortField = "name" | "modified" | "size" | "kind";
|
||||
|
||||
export function sortLocalEntries(
|
||||
entries: LocalFileEntry[],
|
||||
field: LocalSortField,
|
||||
order: "asc" | "desc",
|
||||
): LocalFileEntry[] {
|
||||
const dir = order === "asc" ? 1 : -1;
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return [...entries].sort((a, b) => {
|
||||
// Folders always group first, matching the remote grid.
|
||||
const aDir = a.type === "directory" ? 0 : 1;
|
||||
const bDir = b.type === "directory" ? 0 : 1;
|
||||
if (aDir !== bDir) return aDir - bDir;
|
||||
|
||||
let cmp = 0;
|
||||
switch (field) {
|
||||
case "modified":
|
||||
cmp = (a.modifiedTimestamp ?? 0) - (b.modifiedTimestamp ?? 0);
|
||||
break;
|
||||
case "size":
|
||||
cmp = a.size - b.size;
|
||||
break;
|
||||
case "kind":
|
||||
cmp = collator.compare(describeLocalKind(a), describeLocalKind(b));
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
}
|
||||
if (cmp === 0) cmp = collator.compare(a.name, b.name);
|
||||
return cmp * dir;
|
||||
});
|
||||
}
|
||||
|
||||
const MONTH_ABBREVIATIONS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a local mtime the same way the backend formats remote entries
|
||||
* (`formatMtime` in src/backend/hosts/file-manager/utils.ts, i.e. `ls -l`
|
||||
* style): `Sep 11 16:25` for the last six months, `Sep 11 2025` before
|
||||
* that — so both panes read alike.
|
||||
*/
|
||||
export function formatLocalModified(
|
||||
timestamp?: number,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
if (!timestamp) return "--";
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
const month = MONTH_ABBREVIATIONS[date.getMonth()];
|
||||
const day = date.getDate().toString().padStart(2, " ");
|
||||
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||
if (date > sixMonthsAgo) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${month} ${day} ${hours}:${minutes}`;
|
||||
}
|
||||
return `${month} ${day} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parallel transfers
|
||||
|
||||
export const TRANSFER_CONCURRENCY_STORAGE_KEY =
|
||||
"termix:file-manager:transfer-concurrency";
|
||||
export const DEFAULT_TRANSFER_CONCURRENCY = 4;
|
||||
export const MAX_TRANSFER_CONCURRENCY = 8;
|
||||
|
||||
export function clampTransferConcurrency(value: unknown): number {
|
||||
const n = Math.floor(Number(value));
|
||||
if (!Number.isFinite(n)) return DEFAULT_TRANSFER_CONCURRENCY;
|
||||
return Math.min(MAX_TRANSFER_CONCURRENCY, Math.max(1, n));
|
||||
}
|
||||
|
||||
/** How many files are transferred at the same time (1 = one after another). */
|
||||
export function getTransferConcurrency(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
|
||||
return raw === null
|
||||
? DEFAULT_TRANSFER_CONCURRENCY
|
||||
: clampTransferConcurrency(raw);
|
||||
} catch {
|
||||
return DEFAULT_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
}
|
||||
|
||||
export function setTransferConcurrency(value: number): number {
|
||||
const clamped = clampTransferConcurrency(value);
|
||||
try {
|
||||
localStorage.setItem(TRANSFER_CONCURRENCY_STORAGE_KEY, String(clamped));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `worker` over `items` with at most `limit` in flight, preserving the
|
||||
* original order of dispatch. Stops dispatching new items once `shouldStop()`
|
||||
* returns true; items already in flight run to completion (the caller
|
||||
* cancels those through their own transfer ids). Never rejects because of a
|
||||
* single item: worker errors are the worker's business.
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<void> {
|
||||
const size = Math.max(1, Math.min(limit, items.length));
|
||||
let next = 0;
|
||||
const lanes = Array.from({ length: size }, async () => {
|
||||
while (next < items.length && !shouldStop()) {
|
||||
const index = next++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(lanes);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
|
||||
export function childPath(parent: string, name: string): string {
|
||||
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
|
||||
}
|
||||
|
||||
export function addOptimisticItem(
|
||||
files: FileItem[],
|
||||
parent: string,
|
||||
name: string,
|
||||
type: "file" | "directory",
|
||||
now = Date.now(),
|
||||
): FileItem[] {
|
||||
const path = childPath(parent, name);
|
||||
if (files.some((file) => file.path === path)) return files;
|
||||
return [
|
||||
...files,
|
||||
{
|
||||
name,
|
||||
path,
|
||||
type,
|
||||
size: type === "file" ? 0 : undefined,
|
||||
modified: new Date(now).toISOString(),
|
||||
modifiedTimestamp: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function removePaths(files: FileItem[], paths: Set<string>): FileItem[] {
|
||||
return files.filter((file) => !paths.has(file.path));
|
||||
}
|
||||
|
||||
export function restoreItems(
|
||||
files: FileItem[],
|
||||
restored: FileItem[],
|
||||
): FileItem[] {
|
||||
const existing = new Set(files.map((file) => file.path));
|
||||
return [...files, ...restored.filter((file) => !existing.has(file.path))];
|
||||
}
|
||||
|
||||
export function renameOptimisticItem(
|
||||
files: FileItem[],
|
||||
originalPath: string,
|
||||
newName: string,
|
||||
): FileItem[] {
|
||||
const slash = originalPath.lastIndexOf("/");
|
||||
const parent = slash <= 0 ? "/" : originalPath.slice(0, slash);
|
||||
return files.map((file) =>
|
||||
file.path === originalPath
|
||||
? { ...file, name: newName, path: childPath(parent, newName) }
|
||||
: file,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function normalizeRemoteDir(remotePath: string): string {
|
||||
const trimmed = remotePath.trim().replace(/\\/g, "/");
|
||||
if (!trimmed) return "/";
|
||||
const collapsed = trimmed.replace(/\/+/g, "/");
|
||||
const absolute = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
|
||||
if (absolute === "/") return "/";
|
||||
return absolute.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function joinRemotePath(basePath: string, childPath: string): string {
|
||||
const base = normalizeRemoteDir(basePath);
|
||||
const child = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!child) return base;
|
||||
return base === "/" ? `/${child}` : `${base}/${child}`;
|
||||
}
|
||||
|
||||
export function hasSameHostTransferConflict(
|
||||
sourcePaths: string[],
|
||||
destinationDir: string,
|
||||
): boolean {
|
||||
const dest = normalizeRemoteDir(destinationDir);
|
||||
return sourcePaths.some((sourcePath) => {
|
||||
const source = normalizeRemoteDir(sourcePath);
|
||||
return dest === source || dest.startsWith(`${source}/`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTransferMbPerSec,
|
||||
type TransferTimings,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
export function createFormatTransferMetrics(t: TFunction) {
|
||||
return (timings?: TransferTimings): string => {
|
||||
if (!timings) return "";
|
||||
const parts: string[] = [];
|
||||
if (
|
||||
timings.directBenchmarkMs !== undefined &&
|
||||
timings.relayBenchmarkMs !== undefined
|
||||
) {
|
||||
parts.push(
|
||||
t("transfer.metricsRouteBenchmark", {
|
||||
direct: formatDurationMs(timings.directBenchmarkMs),
|
||||
relay: formatDurationMs(timings.relayBenchmarkMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.prepareDestMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsPrepare", {
|
||||
duration: formatDurationMs(timings.prepareDestMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.compressMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsCompress", {
|
||||
duration: formatDurationMs(timings.compressMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const hop of timings.hops ?? []) {
|
||||
const hopKey =
|
||||
hop.id === "source_read"
|
||||
? "transfer.metricsHopSourceRead"
|
||||
: hop.id === "dest_local_write"
|
||||
? "transfer.metricsHopDestLocalWrite"
|
||||
: "transfer.metricsHopDestSftpWrite";
|
||||
parts.push(
|
||||
t(hopKey, {
|
||||
throughput: formatTransferMbPerSec(hop.mbPerSec),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.transferMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsTransfer", {
|
||||
duration: formatDurationMs(timings.transferMs),
|
||||
throughput: formatTransferMbPerSec(
|
||||
timings.endToEndMbPerSec,
|
||||
timings.transferBytes,
|
||||
timings.transferMs,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.extractMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsExtract", {
|
||||
duration: formatDurationMs(timings.extractMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.verifyMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsVerify", {
|
||||
duration: formatDurationMs(timings.verifyMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.sourceDeleteMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsSourceDelete", {
|
||||
duration: formatDurationMs(timings.sourceDeleteMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.totalMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsTotal", {
|
||||
duration: formatDurationMs(timings.totalMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const PENDING_KEY = "termix_pending_transfers";
|
||||
const NOTIFIED_KEY = "termix_notified_transfers";
|
||||
|
||||
function readJsonArray(key: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonArray(key: string, values: string[]): void {
|
||||
localStorage.setItem(key, JSON.stringify(values));
|
||||
}
|
||||
|
||||
export function registerPendingTransfer(transferId: string): void {
|
||||
const pending = readJsonArray(PENDING_KEY);
|
||||
if (!pending.includes(transferId)) {
|
||||
writeJsonArray(PENDING_KEY, [...pending, transferId]);
|
||||
}
|
||||
}
|
||||
|
||||
export function markTransferNotified(transferId: string): void {
|
||||
const notified = readJsonArray(NOTIFIED_KEY);
|
||||
if (!notified.includes(transferId)) {
|
||||
writeJsonArray(NOTIFIED_KEY, [...notified, transferId].slice(-200));
|
||||
}
|
||||
writeJsonArray(
|
||||
PENDING_KEY,
|
||||
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
|
||||
);
|
||||
}
|
||||
|
||||
export function isTransferNotified(transferId: string): boolean {
|
||||
return readJsonArray(NOTIFIED_KEY).includes(transferId);
|
||||
}
|
||||
|
||||
export function getPendingTransferIds(): string[] {
|
||||
return readJsonArray(PENDING_KEY);
|
||||
}
|
||||
|
||||
export function clearStalePendingTransfer(transferId: string): void {
|
||||
writeJsonArray(
|
||||
PENDING_KEY,
|
||||
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { toast } from "sonner";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
pollTransferUntilComplete,
|
||||
cancelTransferToHost,
|
||||
cleanupCancelledTransfer,
|
||||
retryTransferToHost,
|
||||
createTransferProgressTracker,
|
||||
type TransferProgressResponse,
|
||||
type TransferTimings,
|
||||
} from "@/main-axios.ts";
|
||||
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
|
||||
import {
|
||||
markTransferNotified,
|
||||
registerPendingTransfer,
|
||||
} from "./transferNotificationStore.ts";
|
||||
|
||||
const monitoredTransferIds = new Set<string>();
|
||||
const TOAST_CLASS = "!pr-10 !pl-4 transfer-progress-toast";
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function renderTransferProgressToast(
|
||||
toastId: string | number,
|
||||
status: TransferProgressResponse,
|
||||
liveMbPerSec?: number,
|
||||
onCancel?: () => void,
|
||||
cancelling?: boolean,
|
||||
stalled?: boolean,
|
||||
): void {
|
||||
toast.loading(
|
||||
<TransferProgressToast
|
||||
status={status}
|
||||
liveMbPerSec={liveMbPerSec}
|
||||
stalled={stalled}
|
||||
formatSize={formatFileSize}
|
||||
onCancel={onCancel}
|
||||
cancelling={cancelling}
|
||||
/>,
|
||||
{ id: toastId, duration: Infinity, className: TOAST_CLASS },
|
||||
);
|
||||
}
|
||||
|
||||
export function showTransferCompletionToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
t: TFunction,
|
||||
toastId?: string | number,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
showDefaultCompletionToast(
|
||||
finalStatus,
|
||||
toastId ?? `transfer-done-${finalStatus.transferId}`,
|
||||
t,
|
||||
formatTransferMetrics,
|
||||
);
|
||||
markTransferNotified(finalStatus.transferId);
|
||||
}
|
||||
|
||||
export function isTransferBeingMonitored(transferId: string): boolean {
|
||||
return monitoredTransferIds.has(transferId);
|
||||
}
|
||||
|
||||
export interface TransferMonitorHandle {
|
||||
toastId: string | number;
|
||||
waitForCompletion: Promise<TransferProgressResponse>;
|
||||
}
|
||||
|
||||
export interface BeginTransferMonitoringOptions {
|
||||
resumed?: boolean;
|
||||
initialStatus?: Partial<TransferProgressResponse>;
|
||||
onComplete?: (
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
) => void;
|
||||
onProgress?: (status: TransferProgressResponse) => void;
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string;
|
||||
}
|
||||
|
||||
function showCancelledTransferToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
): void {
|
||||
const hasPartialDest =
|
||||
(finalStatus.partialDestRemaining ??
|
||||
(finalStatus.bytesTransferred ?? 0) > 0) ||
|
||||
(finalStatus.itemsCompleted ?? 0) > 0;
|
||||
|
||||
let description: string | undefined;
|
||||
if (finalStatus.moveRequested && hasPartialDest) {
|
||||
description = t("transfer.transferCancelledMoveHint");
|
||||
} else if (hasPartialDest) {
|
||||
description = t("transfer.transferCancelledCopyHint");
|
||||
}
|
||||
|
||||
const showCleanupAction = hasPartialDest && !finalStatus.cleanupCompleted;
|
||||
|
||||
toast.info(t("transfer.transferCancelled"), {
|
||||
id: toastId,
|
||||
description,
|
||||
className: TOAST_CLASS,
|
||||
duration: showCleanupAction ? Infinity : undefined,
|
||||
action: showCleanupAction
|
||||
? {
|
||||
label: t("transfer.cleanupDestFiles"),
|
||||
onClick: () => {
|
||||
void cleanupCancelledTransfer(finalStatus.transferId)
|
||||
.then((result) => {
|
||||
toast.dismiss(toastId);
|
||||
if (result.failedPaths.length > 0) {
|
||||
toast.warning(t("transfer.cleanupDestFilesPartial"));
|
||||
} else if (result.removedPaths.length > 0) {
|
||||
toast.success(t("transfer.cleanupDestFilesSuccess"));
|
||||
} else {
|
||||
toast.info(t("transfer.cleanupDestFilesNothing"));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileManager.unknownError");
|
||||
toast.error(
|
||||
`${t("transfer.cleanupDestFilesError")}: ${message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function showFailedTransferToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
const hasPartial =
|
||||
(finalStatus.partialDestRemaining ??
|
||||
(finalStatus.bytesTransferred ?? 0) > 0) ||
|
||||
(finalStatus.itemsCompleted ?? 0) > 0;
|
||||
|
||||
const descriptionParts: string[] = [];
|
||||
if (finalStatus.message) {
|
||||
descriptionParts.push(finalStatus.message);
|
||||
}
|
||||
if (finalStatus.retryable && hasPartial) {
|
||||
descriptionParts.push(t("transfer.transferFailedRetryHint"));
|
||||
}
|
||||
|
||||
const showRetry = finalStatus.retryable === true;
|
||||
|
||||
toast.error(t("transfer.transferError"), {
|
||||
id: toastId,
|
||||
description:
|
||||
descriptionParts.length > 0 ? descriptionParts.join(" ") : undefined,
|
||||
className: TOAST_CLASS,
|
||||
duration: showRetry ? Infinity : undefined,
|
||||
action: showRetry
|
||||
? {
|
||||
label: t("transfer.retryTransfer"),
|
||||
onClick: () => {
|
||||
void retryTransferToHost(finalStatus.transferId)
|
||||
.then(() => {
|
||||
toast.dismiss(toastId);
|
||||
beginTransferProgressMonitoring(finalStatus.transferId, t, {
|
||||
resumed: true,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileManager.unknownError");
|
||||
toast.error(`${t("transfer.retryTransferError")}: ${message}`);
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function showDefaultCompletionToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
if (finalStatus.status === "cancelled") {
|
||||
showCancelledTransferToast(finalStatus, toastId, t);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalStatus.status === "error") {
|
||||
showFailedTransferToast(finalStatus, toastId, t, formatTransferMetrics);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalStatus.status === "partial") {
|
||||
const failed = finalStatus.failedPaths?.join(", ") || "";
|
||||
const metrics = formatTransferMetrics?.(finalStatus.timings);
|
||||
toast.warning(
|
||||
t("transfer.transferPartialHint", {
|
||||
paths: failed,
|
||||
count: finalStatus.failedPaths?.length || 0,
|
||||
}),
|
||||
{
|
||||
id: toastId,
|
||||
description: metrics || undefined,
|
||||
className: TOAST_CLASS,
|
||||
duration: undefined,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = formatTransferMetrics?.(finalStatus.timings);
|
||||
toast.success(t("transfer.transferSuccess"), {
|
||||
id: toastId,
|
||||
description: metrics || undefined,
|
||||
className: TOAST_CLASS,
|
||||
duration: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function beginTransferProgressMonitoring(
|
||||
transferId: string,
|
||||
t: TFunction,
|
||||
options: BeginTransferMonitoringOptions = {},
|
||||
): TransferMonitorHandle | null {
|
||||
if (monitoredTransferIds.has(transferId)) {
|
||||
return null;
|
||||
}
|
||||
monitoredTransferIds.add(transferId);
|
||||
registerPendingTransfer(transferId);
|
||||
|
||||
const progressTracker = createTransferProgressTracker();
|
||||
let cancelling = false;
|
||||
|
||||
const initialStatus: TransferProgressResponse = {
|
||||
transferId,
|
||||
status: "running",
|
||||
phase: "transferring",
|
||||
...options.initialStatus,
|
||||
};
|
||||
|
||||
const progressToast = toast.loading(
|
||||
<TransferProgressToast
|
||||
status={initialStatus}
|
||||
formatSize={formatFileSize}
|
||||
/>,
|
||||
{
|
||||
duration: Infinity,
|
||||
description: options.resumed ? t("transfer.resumedHint") : undefined,
|
||||
className: TOAST_CLASS,
|
||||
},
|
||||
);
|
||||
|
||||
const handleCancelTransfer = () => {
|
||||
if (cancelling) return;
|
||||
cancelling = true;
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
{ ...initialStatus, transferId },
|
||||
undefined,
|
||||
handleCancelTransfer,
|
||||
true,
|
||||
);
|
||||
void cancelTransferToHost(transferId);
|
||||
};
|
||||
|
||||
const waitForCompletion = pollTransferUntilComplete(
|
||||
transferId,
|
||||
(status) => {
|
||||
options.onProgress?.(status);
|
||||
const { rate, stalled } = progressTracker.update(status);
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
status,
|
||||
rate,
|
||||
handleCancelTransfer,
|
||||
cancelling,
|
||||
stalled,
|
||||
);
|
||||
},
|
||||
250,
|
||||
)
|
||||
.then((finalStatus) => {
|
||||
markTransferNotified(transferId);
|
||||
if (options.onComplete) {
|
||||
options.onComplete(finalStatus, progressToast);
|
||||
} else {
|
||||
showDefaultCompletionToast(
|
||||
finalStatus,
|
||||
progressToast,
|
||||
t,
|
||||
options.formatTransferMetrics,
|
||||
);
|
||||
}
|
||||
return finalStatus;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = getErrorMessage(error, t("fileManager.unknownError"));
|
||||
toast.error(`${t("transfer.transferError")}: ${message}`, {
|
||||
id: progressToast,
|
||||
className: TOAST_CLASS,
|
||||
duration: undefined,
|
||||
});
|
||||
markTransferNotified(transferId);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
monitoredTransferIds.delete(transferId);
|
||||
});
|
||||
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
initialStatus,
|
||||
undefined,
|
||||
handleCancelTransfer,
|
||||
cancelling,
|
||||
);
|
||||
|
||||
return { toastId: progressToast, waitForCompletion };
|
||||
}
|
||||
Reference in new issue
Block a user