feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
import React from "react";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface FileManagerAppProps {
hostId?: string;
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
</div>
</div>
);
}
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
</div>
</div>
);
}
return (
<FileManager
embedded={true}
initialHost={hostConfig}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default FileManagerApp;
@@ -0,0 +1,585 @@
import React, { useEffect, 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,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
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;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
const VIEWPORT_PADDING = 10;
function getClampedMenuPosition(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
) {
const maxX = Math.max(
VIEWPORT_PADDING,
window.innerWidth - menuWidth - VIEWPORT_PADDING,
);
const maxY = Math.max(
VIEWPORT_PADDING,
window.innerHeight - menuHeight - VIEWPORT_PADDING,
);
return {
x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX),
y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY),
};
}
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,
}: 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);
const adjustPosition = () => {
const menuWidth = menuRef.current?.offsetWidth ?? 260;
const menuHeight = menuRef.current?.offsetHeight ?? 400;
setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight));
};
adjustPosition();
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]);
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 (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 (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
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",
});
}
}
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
<<<<<<< HEAD:src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx
className={cn(
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto",
)}
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="border-t border-border"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center justify-between",
"hover:bg-accent-brand/10 hover:text-accent-brand transition-colors cursor-pointer rounded-none",
item.disabled &&
"opacity-50 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.5 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1 truncate">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-2 flex-shrink-0">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import { Star, Clock, Bookmark, File, Folder } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
} from "@/main-axios.ts";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
// ─── 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;
sshSessionId?: string;
refreshTrigger?: number;
diskInfo?: { usedHuman: string; totalHuman: string; percent: number };
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function FileManagerSidebar({
currentHost,
currentPath,
onPathChange,
onFileOpen,
sshSessionId,
refreshTrigger,
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[]>([]);
/**
* 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 ──────────────────────────────────────────────────────────────────
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
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)) {
loadedFoldersRef.current.add(parent.path);
loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, sshSessionId]);
// ─── API: Quick access ────────────────────────────────────────────────────────
const loadQuickAccessData = async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData 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 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 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([]);
}
};
// ─── API: Directory tree ──────────────────────────────────────────────────────
const loadDirectoryTree = 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",
);
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: [],
},
]);
}
};
/**
* 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) => {
if (!sshSessionId) return;
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
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);
});
} 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(() => loadSubdirectory(folderId, folderPath), 600);
return;
}
console.error("Failed to load subdirectory:", error);
}
},
[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)
) {
loadedFoldersRef.current.add(item.path);
await loadSubdirectory(id, item.path);
}
},
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
);
// ─── Context menu ─────────────────────────────────────────────────────────────
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) 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]);
// ─── 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) => handleContextMenu(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 storageUsedPct = diskInfo?.percent ?? null;
return (
<>
<div className="h-full flex flex-col bg-card border-r border-border overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{/* ── 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">
<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>
{/* ── Storage — mobile only (inside scroll) ──────────────── */}
{diskInfo && storageUsedPct !== null && (
<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 flex flex-col gap-2">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<span>{t("fileManager.disk")}</span>
<span className="text-accent-brand">
{storageUsedPct}% {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: `${storageUsedPct}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{diskInfo.usedHuman} {t("fileManager.of")}{" "}
{diskInfo.totalHuman} {t("fileManager.used").toLowerCase()}
</span>
</div>
</div>
)}
</div>
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */}
{diskInfo && storageUsedPct !== null && (
<div className="hidden md:flex flex-col p-3 gap-2 border-t border-border shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<span>{t("fileManager.storage")}</span>
<span className="text-accent-brand">
{storageUsedPct}% {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: `${storageUsedPct}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{diskInfo.usedHuman} {t("fileManager.of")} {diskInfo.totalHuman}{" "}
{t("fileManager.used").toLowerCase()}
</span>
</div>
)}
</div>
{/* ── Context menu ─────────────────────────────────────────────── */}
{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,98 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
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,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,160 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } 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;
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
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) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
function CodeEditor(
{ fileName, value, placeholder, onChange, onFocus, onBlur },
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%",
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
];
}, [fileName]);
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,
scrollPastEnd: false,
}}
/>
);
},
);
@@ -0,0 +1,170 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
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,347 @@
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/button.tsx";
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 "@/main-axios.ts";
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,384 @@
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 };
}
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,
}: 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 windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = 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, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - 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 });
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 windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
setPosition({
x: constrainedX,
y: constrainedY,
});
}
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);
}
}
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
position,
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 });
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 uppercase tracking-widest text-muted-foreground truncate">
{title}
</span>
</div>
<div className="flex items-center gap-0.5">
{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,917 @@
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,
Search,
} 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 "@/components/button.tsx";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
const CodeEditor = lazy(() =>
import("./CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
})),
);
const ImagePreview = lazy(() =>
import("./ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
})),
);
const MarkdownRenderer = lazy(() =>
import("./MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
})),
);
const PdfPreview = lazy(() =>
import("./PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
})),
);
const AudioPreview = lazy(() =>
import("./AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
})),
);
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;
isEditable?: boolean;
onContentChange?: (content: string) => void;
onSave?: (content: string) => void;
onRevert?: () => void;
onDownload?: () => 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 ext = filename.split(".").pop()?.toLowerCase() || "";
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
const textExts = ["txt", "readme"];
const markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"];
const pdfExts = ["pdf"];
const codeExts = [
"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",
];
if (imageExts.includes(ext)) {
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (videoExts.includes(ext)) {
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (audioExts.includes(ext)) {
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (markdownExts.includes(ext)) {
return {
type: "markdown",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-600",
};
} else if (pdfExts.includes(ext)) {
return {
type: "pdf",
icon: <FileText className="w-6 h-6" />,
color: "text-red-600",
};
} else if (textExts.includes(ext)) {
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (codeExts.includes(ext)) {
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,
isEditable = false,
onContentChange,
onSave,
onRevert,
onDownload,
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 editorRef = useRef<CodeEditorHandle | null>(null);
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);
}
setHasChanges(content !== savedContent);
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
setShowLargeFileWarning(false);
}
}, [
content,
savedContent,
fileTypeInfo.type,
isLargeFile,
forceShowAsText,
file.name,
]);
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
const handleSave = () => {
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 items-center justify-between">
<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="flex items-center 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 && (
<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}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Revert
</Button>
<Button
variant="default"
size="sm"
onClick={handleSave}
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>
)}
</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")}
/>
</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,452 @@
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 "@/main-axios.ts";
import { toast } from "sonner";
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 SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
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
) {
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 [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
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 decoded = atob(fileContent);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
} catch (err) {
console.error("Failed to decode base64 content:", err);
}
}
setContent(fileContent);
setPendingContent(fileContent);
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);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
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 {
setIsLoading(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 {
setIsLoading(false);
}
};
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);
}
};
}, []);
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"));
}
} 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 = () => {
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}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
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 "@/components/button.tsx";
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,119 @@
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({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(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,147 @@
import React, { useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
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 rounded 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 rounded 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">Cannot load PDF</h3>
<p className="text-sm mb-4">
There was an error loading this PDF file.
</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">
Loading PDF...
</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">
Loading page...
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,260 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
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-md 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 uppercase tracking-tight text-muted-foreground font-mono">
{file.path}
</DialogDescription>
</DialogHeader>
<div className="py-3 flex flex-col gap-4">
<div className="grid grid-cols-4 gap-0 border border-border overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-r border-border 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="px-3 py-2 bg-muted/50 border-b border-r border-border last:border-r-0 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center"
>
{h}
</div>
))}
{rows.map((row, i) => (
<React.Fragment key={i}>
<div className="px-3 py-2.5 border-b border-r border-border last:border-b-0 text-xs font-semibold truncate">
{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-b border-r border-border last:border-r-0 py-2.5"
>
<input
type="checkbox"
checked={perm.val}
onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-3.5 cursor-pointer"
/>
</div>
))}
</React.Fragment>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{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"
maxLength={3}
/>
<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,124 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<{ fit?: () => void } | 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 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 (
<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}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,138 @@
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">
{windows.map((window) => (
<div key={window.id}>
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</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,161 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
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();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, 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 "@/main-axios";
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",
}));
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,299 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
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,
};
}