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
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