This commit is contained in:
LukeGus
2026-05-28 22:29:20 -05:00
parent 33dcde0827
commit 5777351145
238 changed files with 62301 additions and 98953 deletions
+306 -109
View File
@@ -38,7 +38,6 @@ import {
Search,
Grid3X3,
List,
ArrowUpDown,
ChevronLeft,
ChevronRight,
ArrowUp,
@@ -48,8 +47,6 @@ import {
Copy,
Layout,
} from "lucide-react";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
DropdownMenu,
DropdownMenuContent,
@@ -73,7 +70,6 @@ import {
listSSHFiles,
resolveSSHPath,
uploadSSHFile,
downloadSSHFile,
createSSHFile,
createSSHFolder,
deleteSSHItem,
@@ -260,6 +256,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped,
onItemsDropped: handleItemsDropped,
onError: (error) => toast.error(error),
maxFileSize: 5120,
});
@@ -412,6 +409,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function initializeSSHConnection() {
if (!currentHost || isConnectingRef.current) return;
if (currentHost.enableSsh === false) {
setHasConnectionError(true);
addLog({
type: "error",
message: t("fileManager.sshRequiredForFileManager"),
timestamp: new Date().toISOString(),
});
setIsLoading(false);
return;
}
isConnectingRef.current = true;
try {
@@ -532,7 +540,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
handleCloseWithError(
t("fileManager.failedToConnect") + ": " + (error.message || error),
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
);
} finally {
setIsLoading(false);
@@ -547,10 +557,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false;
}
if (isLoading && currentLoadingPathRef.current !== path) {
return false;
}
let resolvedPath = path;
if (path.includes("$") || path.startsWith("~")) {
resolvedPath = await resolveSSHPath(sshSessionId, path);
@@ -563,8 +569,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
currentLoadingPathRef.current = resolvedPath;
setIsLoading(true);
setCreateIntent(null);
try {
const response = await listSSHFiles(sshSessionId, resolvedPath);
@@ -661,9 +665,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false;
} else if (initialLoadDoneRef.current) {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
const isPermissionDenied =
httpStatus === 403 ||
errorMessage?.toLowerCase().includes("permission denied") ||
errorMessage?.toLowerCase().includes("eacces");
if (isPermissionDenied) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
}
}
}
return false;
@@ -695,6 +707,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const navigateTo = useCallback(
(path: string) => {
if (sshSessionId) setIsLoading(true);
setCurrentPath(path);
setNavHistory((prev) => {
const next = [...prev.slice(0, navIndex + 1), path];
@@ -702,24 +715,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return next;
});
},
[navIndex],
[navIndex, sshSessionId],
);
const goBack = useCallback(() => {
if (navIndex > 0) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex - 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goForward = useCallback(() => {
if (navIndex < navHistory.length - 1) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex + 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goUp = useCallback(() => {
if (currentPath === "/") return;
@@ -783,6 +798,125 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentPath]);
async function handleItemsDropped(items: DataTransferItemList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
const files: { file: File; relativePath: string }[] = [];
async function readEntry(
entry: FileSystemEntry,
path: string,
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
files.push({ file, relativePath: path });
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const dirEntries = await new Promise<FileSystemEntry[]>(
(resolve, reject) => reader.readEntries(resolve, reject),
);
for (const child of dirEntries) {
await readEntry(child, `${path}/${child.name}`);
}
}
}
for (const entry of entries) {
await readEntry(entry, entry.name);
}
if (files.length === 0) return;
const progressToast = toast.loading(
`Uploading ${files.length} file(s)...`,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
const dirs = new Set<string>();
for (const { relativePath } of files) {
const parts = relativePath.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const sortedDirs = Array.from(dirs).sort();
for (const dir of sortedDirs) {
const parentPath = currentPath.endsWith("/")
? currentPath + dir.split("/").slice(0, -1).join("/")
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
const folderName = dir.split("/").pop()!;
const targetPath = parentPath.endsWith("/")
? parentPath
: parentPath + "/";
try {
await createSSHFolder(
sshSessionId,
targetPath,
folderName,
currentHost?.id,
);
} catch {
// directory may already exist
}
}
for (const { file, relativePath } of files) {
const dirPart = relativePath.includes("/")
? relativePath.substring(0, relativePath.lastIndexOf("/"))
: "";
const uploadPath = dirPart
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
dirPart +
"/"
: currentPath;
const fileContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result.split(",")[1] || "");
} else {
reject(new Error("Failed to read file"));
}
};
reader.readAsDataURL(file);
});
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
fileContent,
currentHost?.id,
);
}
toast.dismiss(progressToast);
toast.success(`Uploaded ${files.length} file(s) successfully`);
handleRefreshDirectory();
} catch (error) {
toast.dismiss(progressToast);
toast.error(t("fileManager.failedToUploadFile"));
console.error("Folder upload failed:", error);
}
}
function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
@@ -840,10 +974,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
toast.dismiss(progressToast);
const uploadErr = error instanceof Error ? error : null;
if (
error.message?.includes("connection") ||
error.message?.includes("established")
uploadErr?.message?.includes("connection") ||
uploadErr?.message?.includes("established")
) {
toast.error(
t("fileManager.sshConnectionFailed", {
@@ -865,38 +999,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
const { downloadSSHFileStream } = await import("@/main-axios.ts");
await downloadSSHFileStream(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
}
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
} catch (error: unknown) {
const err = error instanceof Error ? error : null;
if (
error.message?.includes("connection") ||
error.message?.includes("established")
err?.message?.includes("connection") ||
err?.message?.includes("established")
) {
toast.error(
t("fileManager.sshConnectionFailed", {
@@ -980,7 +1093,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
clearSelection();
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { needsSudo?: boolean; error?: string } };
response?: {
data?: { needsSudo?: boolean; error?: string };
status?: number;
};
message?: string;
};
if (axiosError.response?.data?.needsSudo) {
@@ -989,11 +1105,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return;
}
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else if (
axiosError.message?.includes("connection") ||
axiosError.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
@@ -1061,14 +1188,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFolderDefault"),
"directory",
);
const newCreateIntent = {
setCreateIntent({
id: Date.now().toString(),
type: "directory" as const,
defaultName,
currentName: defaultName,
};
setCreateIntent(newCreateIntent);
});
}
function handleCreateNewFile() {
@@ -1076,13 +1201,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFileDefault"),
"file",
);
const newCreateIntent = {
setCreateIntent({
id: Date.now().toString(),
type: "file" as const,
defaultName,
currentName: defaultName,
};
setCreateIntent(newCreateIntent);
});
}
const handleSymlinkClick = async (file: FileItem) => {
@@ -1166,6 +1290,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleFileOpen(file: FileItem) {
if (file.type === "directory") {
if (sshSessionId) setIsLoading(true);
setCurrentPath(file.path);
} else if (file.type === "link") {
await handleSymlinkClick(file);
@@ -1308,16 +1433,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
} catch (error: unknown) {
console.error(`Failed to ${operation} file ${file.name}:`, error);
toast.error(
t("fileManager.operationFailed", {
operation:
operation === "copy"
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
error: error.message,
}),
);
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.operationFailed", {
operation:
operation === "copy"
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
}
@@ -1408,9 +1545,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setClipboard(null);
}
} catch (error: unknown) {
toast.error(
`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
}
}
@@ -1436,10 +1573,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
}
}
@@ -1481,10 +1617,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
}
}
@@ -1524,7 +1659,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.deleteCopiedFileFailed", {
name: copiedFile.targetName,
error: error.message,
error:
error instanceof Error ? error.message : String(error),
}),
);
}
@@ -1566,7 +1702,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.moveBackFileFailed", {
name: movedFile.targetName,
error: error.message,
error:
error instanceof Error ? error.message : String(error),
}),
);
}
@@ -1599,9 +1736,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
toast.error(
`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
console.error("Undo failed:", error);
}
}
@@ -1696,8 +1833,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setCreateIntent(null);
handleRefreshDirectory();
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToCreateItem"));
}
console.error("Create failed:", error);
toast.error(t("fileManager.failedToCreateItem"));
}
}
@@ -1725,8 +1874,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null);
handleRefreshDirectory();
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToRenameItem"));
}
console.error("Rename failed:", error);
toast.error(t("fileManager.failedToRenameItem"));
}
}
@@ -1910,7 +2071,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setAuthDialogReason("auth_failed");
setShowAuthDialog(true);
toast.error(
t("fileManager.failedToConnect") + ": " + (error.message || error),
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
);
} finally {
setIsLoading(false);
@@ -1979,7 +2142,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.moveFileFailed", { name: file.name }) +
": " +
error.message,
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2022,7 +2185,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
} catch (error: unknown) {
console.error("Drag move operation failed:", error);
toast.error(t("fileManager.moveOperationFailed") + ": " + error.message);
toast.error(
t("fileManager.moveOperationFailed") +
": " +
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2096,7 +2263,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.dragFailed") +
": " +
(error.message || t("fileManager.unknownError")),
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2362,15 +2529,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
}
if ((isLoading || isReconnecting) && !sshSessionId) {
return (
<div className="h-full w-full flex flex-col bg-background relative">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
</div>
<ConnectionLog
isConnecting={isLoading || isReconnecting}
isConnected={false}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background relative">
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
<div
className="h-full w-full flex flex-col"
className="h-full w-full flex flex-col min-h-0"
style={{
visibility: isConnectionLogExpanded ? "hidden" : "visible",
}}
>
<Card className="flex flex-col shrink-0 mx-3 mt-3 rounded-none shadow-none border-border">
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
@@ -2413,11 +2599,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
disabled={isLoading}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
@@ -2538,16 +2723,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onClick={handleCreateNewFolder}
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleCreateNewFile}
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
@@ -2640,7 +2830,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
</div>
</div>
</div>
</Card>
</div>
<div
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
@@ -2664,7 +2854,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
: "hidden md:flex",
)}
>
<Card className="flex-1 flex flex-col rounded-none shadow-none p-0 gap-0 overflow-hidden border-border">
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
<FileManagerSidebar
currentHost={currentHost}
currentPath={currentPath}
@@ -2675,10 +2865,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
refreshTrigger={sidebarRefreshTrigger}
diskInfo={diskInfo ?? undefined}
/>
</Card>
</div>
</div>
<Card className="flex-1 relative overflow-hidden rounded-none shadow-none p-0 gap-0 min-h-0 flex flex-col border-border">
<div className="flex-1 relative overflow-hidden min-h-0 flex flex-col border border-border bg-card">
<div className="flex-1 relative min-h-0 h-full">
<FileManagerGrid
files={filteredFiles}
@@ -2687,7 +2877,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onFileOpen={handleFileOpen}
onSelectionChange={setSelection}
currentPath={currentPath}
isLoading={isLoading}
onPathChange={navigateTo}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
@@ -2701,7 +2890,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setSortOrder("asc");
}
}}
onDownload={(files) => files.forEach(handleDownloadFile)}
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onContextMenu={handleContextMenu}
viewMode={viewMode}
onRename={handleRenameConfirm}
@@ -2733,7 +2926,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onClose={() =>
setContextMenu((prev) => ({ ...prev, isVisible: false }))
}
onDownload={(files) => files.forEach(handleDownloadFile)}
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onPreview={handleFileOpen}
onRename={handleRenameFile}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
@@ -2767,7 +2965,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onCopyPath={handleCopyPath}
/>
</div>
</Card>
</div>
</div>
</div>
@@ -2783,6 +2981,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
@@ -2792,6 +2991,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
@@ -2806,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
@@ -2826,10 +3027,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
onSubmit={handleSudoPasswordSubmit}
/>
<SimpleLoader
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
<ConnectionLog
isConnecting={isReconnecting || isLoading}
isConnected={!!sshSessionId}
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
@@ -7,6 +8,7 @@ interface FileManagerAppProps {
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
@@ -15,7 +17,9 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
<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>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
</div>
);
@@ -25,7 +29,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
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>
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Download,
@@ -143,14 +143,6 @@ export function FileManagerContextMenu({
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(() => {
@@ -206,6 +198,21 @@ export function FileManagerContextMenu({
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth)
adjustedX = viewportWidth - menuWidth - 10;
if (y + menuHeight > viewportHeight)
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
setMenuPosition({ x: adjustedX, y: adjustedY });
}, [isVisible, x, y, files.length]);
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
@@ -528,9 +535,8 @@ export function FileManagerContextMenu({
<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",
"fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
)}
style={{
left: menuPosition.x,
@@ -543,7 +549,7 @@ export function FileManagerContextMenu({
return (
<div
key={`separator-${index}`}
className="border-t border-border"
className="my-1 border-t border-border"
/>
);
}
@@ -552,10 +558,10 @@ export function FileManagerContextMenu({
<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",
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled &&
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current",
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
@@ -567,12 +573,14 @@ export function FileManagerContextMenu({
}}
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 className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-2 flex-shrink-0">
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
@@ -13,19 +13,14 @@ import {
Settings,
Download,
Upload,
ChevronLeft,
ChevronRight,
RefreshCw,
ArrowUp,
ArrowDown,
FileSymlink,
Move,
GitCompare,
Edit,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
interface CreateIntent {
id: string;
@@ -70,7 +65,6 @@ interface FileManagerGridProps {
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
isLoading?: boolean;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
@@ -194,7 +188,6 @@ export function FileManagerGrid({
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
onPathChange,
onRefresh,
onUpload,
@@ -529,20 +522,30 @@ export function FileManagerGrid({
e.stopPropagation();
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (createIntent) {
const target = e.target as HTMLElement;
if (target.tagName !== "INPUT") {
e.preventDefault();
}
return;
}
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
setIsSelecting(true);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setIsSelecting(true);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setJustFinishedSelecting(false);
}
}, []);
setJustFinishedSelecting(false);
}
},
[createIntent],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
@@ -699,7 +702,7 @@ export function FileManagerGrid({
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
if (gridRef.current) {
if (gridRef.current && !createIntent) {
gridRef.current.focus();
}
@@ -734,7 +737,7 @@ export function FileManagerGrid({
};
const handleGridClick = (event: React.MouseEvent) => {
if (gridRef.current) {
if (gridRef.current && !createIntent) {
gridRef.current.focus();
}
@@ -978,6 +981,7 @@ export function FileManagerGrid({
onBlur={handleEditConfirm}
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<p
@@ -1100,6 +1104,7 @@ export function FileManagerGrid({
onBlur={handleEditConfirm}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<span
@@ -1230,8 +1235,6 @@ export function FileManagerGrid({
</div>,
document.body,
)}
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
</div>
);
}
@@ -1248,24 +1251,49 @@ function CreateIntentGridItem({
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timer);
}, [intent.id]);
const commit = useCallback(
(name: string) => {
if (doneRef.current) return;
doneRef.current = true;
if (name) {
onConfirm?.(name);
} else {
onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
commit(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.();
}
};
return (
<div className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5">
<div
className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-2">
{intent.type === "directory" ? (
<Folder className="size-10 text-accent-brand" />
@@ -1279,7 +1307,7 @@ function CreateIntentGridItem({
value={inputName}
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
onBlur={() => commit(inputName.trim())}
className="w-full max-w-[120px] border border-accent-brand/60 bg-card px-2 py-1 text-xs text-center rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
placeholder={
intent.type === "directory"
@@ -1303,24 +1331,49 @@ function CreateIntentListItem({
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timer);
}, [intent.id]);
const commit = useCallback(
(name: string) => {
if (doneRef.current) return;
doneRef.current = true;
if (name) {
onConfirm?.(name);
} else {
onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
commit(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.();
}
};
return (
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none">
<div
className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<div className="shrink-0">
{intent.type === "directory" ? (
@@ -1335,7 +1388,7 @@ function CreateIntentListItem({
value={inputName}
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
onBlur={() => commit(inputName.trim())}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
placeholder={
intent.type === "directory"
@@ -551,7 +551,7 @@ export function FileManagerSidebar({
return (
<>
<div className="h-full flex flex-col bg-card border-r border-border overflow-hidden">
<div className="h-full flex flex-col bg-card overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
@@ -53,14 +53,20 @@ export function DraggableWindow({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const containerBoundsRef = useRef({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
const container = windowRef.current?.offsetParent as HTMLElement | null;
const maxWidth = container
? Math.min(container.clientWidth * 0.9, 1200)
: Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = container
? Math.min(container.clientHeight * 0.8, 800)
: Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
@@ -80,8 +86,16 @@ export function DraggableWindow({
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2),
x: Math.max(
0,
(container ? container.clientWidth : window.innerWidth) / 2 -
newWidth / 2,
),
y: Math.max(
0,
(container ? container.clientHeight : window.innerHeight) / 2 -
newHeight / 2,
),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
@@ -98,6 +112,13 @@ export function DraggableWindow({
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, onFocus],
@@ -112,50 +133,14 @@ export function DraggableWindow({
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));
const { width: containerW, height: containerH } =
containerBoundsRef.current;
const maxX = containerW - size.width;
const maxY = containerH - size.height;
setPosition({
x: constrainedX,
y: constrainedY,
x: Math.max(0, Math.min(maxX, newX)),
y: Math.max(49, Math.min(maxY, newY)),
});
}
@@ -194,8 +179,10 @@ export function DraggableWindow({
}
}
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
const { width: containerW, height: containerH } =
containerBoundsRef.current;
newX = Math.max(0, Math.min(containerW - newWidth, newX));
newY = Math.max(49, Math.min(containerH - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
@@ -213,7 +200,6 @@ export function DraggableWindow({
windowStart,
sizeStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
@@ -238,6 +224,13 @@ export function DraggableWindow({
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, size, onFocus],
@@ -3,8 +3,9 @@ import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
@@ -85,10 +86,10 @@ export function PdfPreview({
{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>
<h3 className="text-lg font-medium mb-2">
{t("fileManager.cannotLoadPdf")}
</h3>
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
{onDownload && (
<Button
variant="outline"
@@ -120,7 +121,7 @@ export function PdfPreview({
<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...
{t("fileManager.loadingPdf")}
</p>
</div>
}
@@ -133,7 +134,7 @@ export function PdfPreview({
<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...
{t("fileManager.loadingPage")}
</p>
</div>
}
@@ -168,35 +168,40 @@ export function PermissionsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Lock className="size-4 text-accent-brand" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono">
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono break-all">
{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>
))}
<div className="border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
{[
t("fileManager.read"),
t("fileManager.write"),
t("fileManager.execute"),
].map((h) => (
<div
key={h}
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
>
{h}
</div>
))}
</div>
{rows.map((row, i) => (
<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">
<div
key={i}
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
>
<div className="px-3 py-3 text-xs font-semibold">
{row.label}
</div>
{[
@@ -206,29 +211,28 @@ export function PermissionsDialog({
].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"
className="flex items-center justify-center border-l border-border py-3"
>
<input
type="checkbox"
checked={perm.val}
onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-3.5 cursor-pointer"
className="accent-[var(--accent-brand)] size-4 cursor-pointer"
/>
</div>
))}
</React.Fragment>
</div>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
{t("fileManager.octal")}
</span>
<Input
value={octal}
readOnly
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
maxLength={3}
/>
<span className="text-[10px] text-muted-foreground font-mono">
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
@@ -3,20 +3,8 @@ 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;
}
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import type { SSHHost } from "@/types/index.ts";
interface TerminalWindowProps {
windowId: string;
@@ -96,29 +84,31 @@ export function TerminalWindow({
: 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}
<CommandHistoryProvider>
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
/>
</DraggableWindow>
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef as any}
hostConfig={hostConfig as any}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
</CommandHistoryProvider>
);
}
@@ -116,14 +116,19 @@ export function WindowManager({ children }: WindowManagerProps) {
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
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
style={{ zIndex: 1000 }}
>
<div className="relative w-full h-full pointer-events-none">
{windows.map((window) => (
<div key={window.id} className="pointer-events-auto">
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</div>
</WindowManagerContext.Provider>
);
@@ -8,6 +8,7 @@ interface DragAndDropState {
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onItemsDropped?: (items: DataTransferItemList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
@@ -15,6 +16,7 @@ interface UseDragAndDropProps {
export function useDragAndDrop({
onFilesDropped,
onItemsDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
@@ -123,6 +125,16 @@ export function useDragAndDrop({
draggedFiles: [],
});
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
const hasDirectory = Array.from(e.dataTransfer.items).some(
(item) => item.webkitGetAsEntry?.()?.isDirectory,
);
if (hasDirectory) {
onItemsDropped(e.dataTransfer.items);
return;
}
}
const files = e.dataTransfer.files;
if (files.length === 0) {
@@ -137,7 +149,7 @@ export function useDragAndDrop({
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
[validateFiles, onFilesDropped, onItemsDropped, onError],
);
const resetDragState = useCallback(() => {