mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
v2.3.0
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user