feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
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 (&gt; 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>;
+147
View File
@@ -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 };
}