Handle Symlink in the File manager. #227

Merged
jedi04 merged 1 commits from feat/symlink into dev-1.7.0 2025-09-15 02:30:17 +00:00
3 changed files with 157 additions and 2 deletions

View File

@@ -329,6 +329,71 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
});
});
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const linkPath = decodeURIComponent(req.query.path as string);
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 (!linkPath) {
return res.status(400).json({ error: "Link path is required" });
}
sshConn.lastActive = Date.now();
const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH identifySymlink 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) {
fileLogger.error(
`SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
coderabbitai[bot] commented 2025-09-12 20:30:37 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

Return a specific status for broken links.

If stderr contains “No such file or directory”, respond with 404 to let the UI show a clearer toast for broken symlinks instead of a generic 500.

Apply:

-      if (code !== 0) {
+      if (code !== 0) {
         fileLogger.error(
           `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
         );
-        return res.status(500).json({ error: `Command failed: ${errorData}` });
+        const notFound = /No such file or directory/i.test(errorData);
+        return res.status(notFound ? 404 : 500).json({
+          error: notFound ? "Target not found or broken symlink" : `Command failed: ${errorData}`,
+        });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    stream.on("close", (code) => {
      if (code !== 0) {
        fileLogger.error(
          `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
        );
        const notFound = /No such file or directory/i.test(errorData);
        return res.status(notFound ? 404 : 500).json({
          error: notFound ? "Target not found or broken symlink" : `Command failed: ${errorData}`,
        });
      }
🤖 Prompt for AI Agents
In src/backend/ssh/file-manager.ts around lines 371-377, the handler currently
maps any non-zero SSH command exit to a 500; change it to detect broken symlink
errors by inspecting errorData (stderr) for "No such file or directory"
(case-insensitive) and return res.status(404). Keep the fileLogger.error call
but include the detected error text and indicate it's a missing target;
otherwise preserve the existing 500 behavior and message for other errors.
_🛠️ Refactor suggestion_ **Return a specific status for broken links.** If stderr contains “No such file or directory”, respond with 404 to let the UI show a clearer toast for broken symlinks instead of a generic 500. Apply: ```diff - if (code !== 0) { + if (code !== 0) { fileLogger.error( `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); - return res.status(500).json({ error: `Command failed: ${errorData}` }); + const notFound = /No such file or directory/i.test(errorData); + return res.status(notFound ? 404 : 500).json({ + error: notFound ? "Target not found or broken symlink" : `Command failed: ${errorData}`, + }); } ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion stream.on("close", (code) => { if (code !== 0) { fileLogger.error( `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); const notFound = /No such file or directory/i.test(errorData); return res.status(notFound ? 404 : 500).json({ error: notFound ? "Target not found or broken symlink" : `Command failed: ${errorData}`, }); } ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/ssh/file-manager.ts around lines 371-377, the handler currently maps any non-zero SSH command exit to a 500; change it to detect broken symlink errors by inspecting errorData (stderr) for "No such file or directory" (case-insensitive) and return res.status(404). Keep the fileLogger.error call but include the detected error text and indicate it's a missing target; otherwise preserve the existing 500 behavior and message for other errors. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
const [fileType, target] = data.trim().split("\n");
res.json({
path: linkPath,
target: target,
type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH identifySymlink stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
coderabbitai[bot] commented 2025-09-12 20:30:37 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

Guard against missing/invalid path and harden output parsing.

decodeURIComponent is called before validating presence; undefined becomes the string "undefined" and the command runs against that path. Also, parsing assumes exactly two non-empty lines. Tighten both.

Apply:

-app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
-  const sessionId = req.query.sessionId as string;
-  const sshConn = sshSessions[sessionId];
-  const linkPath = decodeURIComponent(req.query.path as string);
+app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
+  const sessionId = req.query.sessionId as string;
+  const sshConn = sshSessions[sessionId];
+  const rawPath = req.query.path;
+  if (typeof rawPath !== "string" || rawPath.trim() === "") {
+    return res.status(400).json({ error: "Link path is required" });
+  }
+  const linkPath = decodeURIComponent(rawPath);
@@
-  sshConn.client.exec(command, (err, stream) => {
+  sshConn.client.exec(command, (err, stream) => {
@@
-    stream.on("close", (code) => {
+    stream.on("close", (code) => {
       if (code !== 0) {
@@
-      const [fileType, target] = data.trim().split("\n");
-      
+      const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
+      if (lines.length < 2) {
+        fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`);
+        return res.status(500).json({ error: "Unexpected command output while resolving link" });
+      }
+      const [fileType, target] = lines;
+
       res.json({
         path: linkPath,
         target: target,
         type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
  const sessionId = req.query.sessionId as string;
  const sshConn = sshSessions[sessionId];
  const rawPath = req.query.path;
  if (typeof rawPath !== "string" || rawPath.trim() === "") {
    return res.status(400).json({ error: "Link path is required" });
  }
  const linkPath = decodeURIComponent(rawPath);

  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 (!linkPath) {
    return res.status(400).json({ error: "Link path is required" });
  }

  sshConn.lastActive = Date.now();

  const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
  const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;

  sshConn.client.exec(command, (err, stream) => {
    if (err) {
      fileLogger.error("SSH identifySymlink 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) {
        fileLogger.error(
          `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
        );
        return res.status(500).json({ error: `Command failed: ${errorData}` });
      }

      const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
      if (lines.length < 2) {
        fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`);
        return res.status(500).json({ error: "Unexpected command output while resolving link" });
      }
      const [fileType, target] = lines;
      
      res.json({
        path: linkPath,
        target: target,
        type: fileType.toLowerCase().includes("directory") ? "directory" : "file"
      });
    });

    stream.on("error", (streamErr) => {
      fileLogger.error("SSH identifySymlink stream error:", streamErr);
      if (!res.headersSent) {
        res.status(500).json({ error: `Stream error: ${streamErr.message}` });
      }
    });
  });
});
🤖 Prompt for AI Agents
In src/backend/ssh/file-manager.ts around lines 332-396, decodeURIComponent is
called before validating the raw query path and the output parsing assumes
exactly two non-empty lines; fix by first reading and validating req.query.path
(return 400 if missing/empty), then safely decode it inside a try/catch (return
400 if decoding fails), continue to escape the decoded path for the shell, and
after command completes robustly parse output by trimming and splitting into
lines, treating missing lines gracefully (e.g. set target = lines[1] || "" and
fileType = lines[0] || "" and if fileType is empty return 500 or an informative
error), and ensure responses are only sent once (check res.headersSent) when
handling stream errors or command failures.
_⚠️ Potential issue_ **Guard against missing/invalid path and harden output parsing.** decodeURIComponent is called before validating presence; undefined becomes the string "undefined" and the command runs against that path. Also, parsing assumes exactly two non-empty lines. Tighten both. Apply: ```diff -app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { - const sessionId = req.query.sessionId as string; - const sshConn = sshSessions[sessionId]; - const linkPath = decodeURIComponent(req.query.path as string); +app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const rawPath = req.query.path; + if (typeof rawPath !== "string" || rawPath.trim() === "") { + return res.status(400).json({ error: "Link path is required" }); + } + const linkPath = decodeURIComponent(rawPath); @@ - sshConn.client.exec(command, (err, stream) => { + sshConn.client.exec(command, (err, stream) => { @@ - stream.on("close", (code) => { + stream.on("close", (code) => { if (code !== 0) { @@ - const [fileType, target] = data.trim().split("\n"); - + const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean); + if (lines.length < 2) { + fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`); + return res.status(500).json({ error: "Unexpected command output while resolving link" }); + } + const [fileType, target] = lines; + res.json({ path: linkPath, target: target, type: fileType.toLowerCase().includes("directory") ? "directory" : "file" }); ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; const rawPath = req.query.path; if (typeof rawPath !== "string" || rawPath.trim() === "") { return res.status(400).json({ error: "Link path is required" }); } const linkPath = decodeURIComponent(rawPath); 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 (!linkPath) { return res.status(400).json({ error: "Link path is required" }); } sshConn.lastActive = Date.now(); const escapedPath = linkPath.replace(/'/g, "'\"'\"'"); const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`; sshConn.client.exec(command, (err, stream) => { if (err) { fileLogger.error("SSH identifySymlink 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) { fileLogger.error( `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res.status(500).json({ error: `Command failed: ${errorData}` }); } const lines = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean); if (lines.length < 2) { fileLogger.error(`SSH identifySymlink command returned unexpected output: ${JSON.stringify(lines)}`); return res.status(500).json({ error: "Unexpected command output while resolving link" }); } const [fileType, target] = lines; res.json({ path: linkPath, target: target, type: fileType.toLowerCase().includes("directory") ? "directory" : "file" }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH identifySymlink stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/ssh/file-manager.ts around lines 332-396, decodeURIComponent is called before validating the raw query path and the output parsing assumes exactly two non-empty lines; fix by first reading and validating req.query.path (return 400 if missing/empty), then safely decode it inside a try/catch (return 400 if decoding fails), continue to escape the decoded path for the shell, and after command completes robustly parse output by trimming and splitting into lines, treating missing lines gracefully (e.g. set target = lines[1] || "" and fileType = lines[0] || "" and if fileType is empty return 500 or an informative error), and ensure responses are only sent once (check res.headersSent) when handling stream errors or command failures. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];

View File

@@ -8,6 +8,7 @@ import React, {
import {
Folder,
File,
FileSymlink,
ArrowUp,
Pin,
MoreVertical,
@@ -29,6 +30,7 @@ import {
removeFileManagerPinned,
getSSHStatus,
connectSSH,
identifySSHSymlink,
} from "@/ui/main-axios.ts";
import type { SSHHost } from "../../../types/index.js";
@@ -339,7 +341,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
);
setRenamingItem(null);
if (onOperationComplete) {
@@ -375,6 +377,74 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
onPathChange?.(newPath);
};
// Handle symlink resolution
const handleSymlinkClick = async (item: any) => {
if (!host) return;
try {
// Extract just the symlink path (before the " -> " if present)
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
: item.path;
let currentSessionId = sshSessionId;
// Check SSH connection status and reconnect if needed
if (currentSessionId) {
try {
const status = await getSSHStatus(currentSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw sessionErr;
}
}
} else {
// No session ID, try to connect
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToConnectSSH"));
}
}
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath);
if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it
handlePathChange(symlinkInfo.target);
} else if (symlinkInfo.type === "file") {
// If symlink points to a file, open it as a file
onOpenFile({
name: item.name,
path: symlinkInfo.target, // Use the target path, not the symlink path
isSSH: item.isSSH,
sshSessionId: currentSessionId,
});
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
);
}
};
return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0">
@@ -456,6 +526,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
@@ -496,6 +568,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
!isOpen &&
(item.type === "directory"
? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
@@ -506,6 +580,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
@@ -514,7 +590,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</span>
</div>
<div className="flex items-center gap-1">
{item.type === "file" && (
{(item.type === "file") && (
<Button
size="icon"
variant="ghost"

View File

@@ -969,6 +969,20 @@ export async function listSSHFiles(
}
}
export async function identifySSHSymlink(
sessionId: string,
path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await fileManagerApi.get("/ssh/identifySymlink", {
params: { sessionId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "identify SSH symlink");
}
}
export async function readSSHFile(
sessionId: string,
path: string,