Merge branch 'dev-1.7.0' into main

This commit is contained in:
Karmaa
2025-09-15 21:59:17 -05:00
committed by GitHub
8 changed files with 195 additions and 7 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}` });
}
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}` });
}
});
});
});
app.get("/ssh/file_manager/ssh/readFile", (req, res) => { app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];

View File

@@ -458,6 +458,7 @@
"mustSelectValidSshConfig": "Must select a valid SSH configuration from the list", "mustSelectValidSshConfig": "Must select a valid SSH configuration from the list",
"addHost": "Add Host", "addHost": "Add Host",
"editHost": "Edit Host", "editHost": "Edit Host",
"cloneHost": "Clone Host",
"updateHost": "Update Host", "updateHost": "Update Host",
"hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!", "hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!",
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!", "hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",

View File

@@ -444,6 +444,7 @@
"mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置", "mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置",
"addHost": "添加主机", "addHost": "添加主机",
"editHost": "编辑主机", "editHost": "编辑主机",
"cloneHost": "克隆主机",
"deleteHost": "删除主机", "deleteHost": "删除主机",
"authType": "认证类型", "authType": "认证类型",
"passwordAuth": "密码", "passwordAuth": "密码",

View File

@@ -8,6 +8,7 @@ import React, {
import { import {
Folder, Folder,
File, File,
FileSymlink,
ArrowUp, ArrowUp,
Pin, Pin,
MoreVertical, MoreVertical,
@@ -29,6 +30,7 @@ import {
removeFileManagerPinned, removeFileManagerPinned,
getSSHStatus, getSSHStatus,
connectSSH, connectSSH,
identifySSHSymlink,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import type { SSHHost } from "../../../types/index.js"; import type { SSHHost } from "../../../types/index.js";
@@ -339,7 +341,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try { try {
await renameSSHItem(sshSessionId, item.path, newName.trim()); await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success( 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); setRenamingItem(null);
if (onOperationComplete) { if (onOperationComplete) {
@@ -375,6 +377,74 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
onPathChange?.(newPath); 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 ( return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]"> <div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0"> <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"> <div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "directory" ? ( {item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" /> <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" /> <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)} )}
@@ -496,6 +568,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
!isOpen && !isOpen &&
(item.type === "directory" (item.type === "directory"
? handlePathChange(item.path) ? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({ : onOpenFile({
name: item.name, name: item.name,
path: item.path, path: item.path,
@@ -506,6 +580,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
> >
{item.type === "directory" ? ( {item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" /> <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" /> <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)} )}
@@ -514,7 +590,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.type === "file" && ( {(item.type === "file") && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"

View File

@@ -82,7 +82,11 @@ export function HostManager({
{t("hosts.hostViewer")} {t("hosts.hostViewer")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="add_host"> <TabsTrigger value="add_host">
{editingHost ? t("hosts.editHost") : t("hosts.addHost")} {editingHost
? editingHost.id
? t("hosts.editHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</TabsTrigger> </TabsTrigger>
<div className="h-6 w-px bg-dark-border mx-1"></div> <div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="credentials"> <TabsTrigger value="credentials">

View File

@@ -343,7 +343,7 @@ export function HostManagerEditor({
if (defaultAuthType === "password") { if (defaultAuthType === "password") {
formData.password = cleanedHost.password || ""; formData.password = cleanedHost.password || "";
} else if (defaultAuthType === "key") { } else if (defaultAuthType === "key") {
formData.key = "existing_key"; formData.key = editingHost.id ? "existing_key" : editingHost.key;
formData.keyPassword = cleanedHost.keyPassword || ""; formData.keyPassword = cleanedHost.keyPassword || "";
formData.keyType = (cleanedHost.keyType as any) || "auto"; formData.keyType = (cleanedHost.keyType as any) || "auto";
} else if (defaultAuthType === "credential") { } else if (defaultAuthType === "credential") {
@@ -420,7 +420,7 @@ export function HostManagerEditor({
submitData.keyType = null; submitData.keyType = null;
if (data.authType === "credential") { if (data.authType === "credential") {
if (data.credentialId === "existing_credential") { if (data.credentialId === "existing_credential" && editingHost && editingHost.id) {
delete submitData.credentialId; delete submitData.credentialId;
} else { } else {
submitData.credentialId = data.credentialId; submitData.credentialId = data.credentialId;
@@ -440,7 +440,7 @@ export function HostManagerEditor({
submitData.keyType = data.keyType; submitData.keyType = data.keyType;
} }
if (editingHost) { if (editingHost && editingHost.id) {
const updatedHost = await updateSSHHost(editingHost.id, submitData); const updatedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name })); toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
@@ -1497,7 +1497,7 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline"> <Button className="translate-y-2" type="submit" variant="outline">
{editingHost ? t("hosts.updateHost") : t("hosts.addHost")} {editingHost ? editingHost.id ? t("hosts.updateHost") : t("hosts.cloneHost") : t("hosts.addHost")}
</Button> </Button>
</footer> </footer>
</form> </form>

View File

@@ -41,6 +41,7 @@ import {
Check, Check,
Pencil, Pencil,
FolderMinus, FolderMinus,
Copy,
} from "lucide-react"; } from "lucide-react";
import type { import type {
SSHHost, SSHHost,
@@ -206,6 +207,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
} }
}; };
const handleClone = (host: SSHHost) => {
if(onEditHost) {
const clonedHost = {...host};
delete clonedHost.id;
onEditHost(clonedHost);
}
}
const handleRemoveFromFolder = async (host: SSHHost) => { const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast( confirmWithToast(
t("hosts.confirmRemoveFromFolder", { t("hosts.confirmRemoveFromFolder", {
@@ -1009,6 +1018,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<p>Export host</p> <p>Export host</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleClone(host);
}}
className="h-5 w-5 p-0 text-emerald-500 hover:text-emerald-700 hover:bg-emerald-500/10"
>
<Copy className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clone host</p>
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>

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( export async function readSSHFile(
sessionId: string, sessionId: string,
path: string, path: string,