"use client"; import ConfirmActionDialog from "@/components/ConfirmActionDialog"; import Image from "next/image"; import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowUp, ChevronDown, ChevronRight, Download, FilePlus2, FileText, FolderOpen, FolderPlus, Home, Loader2, Pencil, RefreshCw, Save, Trash2, Upload, X, } from "lucide-react"; import { Container, ContainerDirectoryListing, ContainerFileContent, ContainerFileEntry, containers as containersApi, } from "@/lib/api"; import StatusBadge from "../StatusBadge"; import { decodeBase64ToBlob, decodeBase64ToText, formatBytes, formatTimestamp, joinContainerPath, } from "../../utils/container-utils"; interface ContainerFileManagerModalProps { container: Container; onClose: () => void; } const FILE_MANAGER_UPLOAD_MAX_BYTES = 6 * 1024 * 1024; type PendingConfirmAction = { title: string; description: string; confirmLabel: string; tone?: "danger" | "warning" | "info"; note?: string; onConfirm: () => void; onCancel?: () => void; }; export default function ContainerFileManagerModal({ container, onClose, }: ContainerFileManagerModalProps) { const fileInputRef = useRef(null); const [listing, setListing] = useState( null, ); const [currentPath, setCurrentPath] = useState("/"); const [listingLoading, setListingLoading] = useState(true); const [listingError, setListingError] = useState(""); const [isDragOver, setIsDragOver] = useState(false); const [selectedFile, setSelectedFile] = useState( null, ); const [editorValue, setEditorValue] = useState(""); const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(""); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); const [busyPath, setBusyPath] = useState(null); const [newMenuOpen, setNewMenuOpen] = useState(false); const [createMode, setCreateMode] = useState<"file" | "folder" | null>(null); const [createName, setCreateName] = useState(""); const [renamePath, setRenamePath] = useState(null); const [renameValue, setRenameValue] = useState(""); const [confirmDialog, setConfirmDialog] = useState(null); const [notice, setNotice] = useState<{ tone: "success" | "error" | "info"; message: string; } | null>(null); const dirty = !!selectedFile && !selectedFile.isBinary && !selectedFile.tooLarge && editorValue !== selectedFile.content; const loadDirectory = useCallback( async (path: string) => { setListingLoading(true); setListingError(""); try { const response = await containersApi.listFiles(container.id, path); setListing(response.data ?? null); setCurrentPath(response.data?.path ?? path); } catch (err: unknown) { setListingError( err instanceof Error ? err.message : "Failed to load files", ); } finally { setListingLoading(false); } }, [container.id], ); useEffect(() => { void loadDirectory("/"); }, [loadDirectory]); const resetEditor = () => { setSelectedFile(null); setEditorValue(""); setFileError(""); }; const closeInlineForms = () => { setCreateMode(null); setCreateName(""); setRenamePath(null); setRenameValue(""); }; const requestConfirmation = useCallback( (options: Omit) => new Promise((resolve) => { setConfirmDialog({ ...options, onConfirm: () => resolve(true), onCancel: () => resolve(false), }); }), [], ); const confirmLoseChanges = useCallback(async () => { if (!dirty) return true; return requestConfirmation({ title: "Discard Unsaved Changes", description: "Unsaved changes will be lost. Continue?", confirmLabel: "Discard Changes", tone: "warning", note: "Any edits in the current file editor will be discarded.", }); }, [dirty, requestConfirmation]); const handleNavigate = async (path: string) => { if (!(await confirmLoseChanges())) return; resetEditor(); await loadDirectory(path); }; const handleOpenFile = async (path: string) => { if (!(await confirmLoseChanges())) return; setFileLoading(true); setFileError(""); setNotice(null); try { const response = await containersApi.readFile(container.id, path); const nextFile = response.data ?? null; setSelectedFile(nextFile); setEditorValue(nextFile?.content ?? ""); } catch (err: unknown) { setFileError(err instanceof Error ? err.message : "Failed to open file"); } finally { setFileLoading(false); } }; const handleCloseFile = async () => { if (!(await confirmLoseChanges())) return; setNotice(null); resetEditor(); }; const handleSave = async () => { if (!selectedFile || selectedFile.isBinary || selectedFile.tooLarge) return; setSaving(true); setNotice(null); try { await containersApi.writeFile(container.id, { path: selectedFile.path, content: editorValue, }); const nextSize = new TextEncoder().encode(editorValue).length; setSelectedFile({ ...selectedFile, content: editorValue, size: nextSize, }); setNotice({ tone: "success", message: `Saved ${selectedFile.path}` }); await loadDirectory(currentPath); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to save file", }); } finally { setSaving(false); } }; const handleCreateFile = async () => { if (!(await confirmLoseChanges())) return; const name = createName.trim(); if (!name) return; const path = joinContainerPath(currentPath, name); setBusyPath(path); setNotice(null); try { await containersApi.createFile(container.id, { path, content: "" }); await loadDirectory(currentPath); await handleOpenFile(path); closeInlineForms(); setNotice({ tone: "success", message: `Created ${path}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to create file", }); } finally { setBusyPath(null); } }; const handleCreateFolder = async () => { const name = createName.trim(); if (!name) return; const path = joinContainerPath(currentPath, name); setBusyPath(path); setNotice(null); try { await containersApi.createFolder(container.id, { path }); await loadDirectory(currentPath); closeInlineForms(); setNotice({ tone: "success", message: `Created folder ${path}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to create folder", }); } finally { setBusyPath(null); } }; const handleRename = async (entry: ContainerFileEntry) => { const nextName = renameValue.trim(); if (!nextName || nextName === entry.name) return; if ( dirty && selectedFile?.path === entry.path && !(await confirmLoseChanges()) ) { return; } const newPath = joinContainerPath(currentPath, nextName); setBusyPath(entry.path); setNotice(null); try { await containersApi.renamePath(container.id, { path: entry.path, newPath, }); if (selectedFile?.path === entry.path) { resetEditor(); } await loadDirectory(currentPath); closeInlineForms(); setNotice({ tone: "success", message: `Renamed to ${newPath}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to rename item", }); } finally { setBusyPath(null); } }; const uploadFileObject = useCallback( async (file: File) => { if (file.size > FILE_MANAGER_UPLOAD_MAX_BYTES) { setNotice({ tone: "error", message: `File is too large. Maximum upload size is ${formatBytes( FILE_MANAGER_UPLOAD_MAX_BYTES, )}.`, }); return; } setUploading(true); setNotice(null); try { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result ?? "")); reader.onerror = () => reject(new Error("Failed to read selected file")); reader.readAsDataURL(file); }); const contentBase64 = dataUrl.split(",")[1] ?? ""; if (!contentBase64) { throw new Error("Selected file is empty or could not be encoded"); } await containersApi.uploadFile(container.id, { directoryPath: currentPath, fileName: file.name, contentBase64, }); await loadDirectory(currentPath); setNotice({ tone: "success", message: `Uploaded ${file.name}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to upload file", }); } finally { setUploading(false); } }, [container.id, currentPath, loadDirectory], ); const handleDelete = async (entry: ContainerFileEntry) => { const confirmed = await requestConfirmation({ title: `Delete ${entry.type === "directory" ? "Folder" : "File"}`, description: `Delete ${entry.type} "${entry.name}"?`, confirmLabel: `Delete ${entry.type === "directory" ? "Folder" : "File"}`, tone: "danger", note: "This removes the selected path from the container filesystem.", }); if (!confirmed) return; if ( dirty && selectedFile?.path === entry.path && !(await confirmLoseChanges()) ) { return; } setBusyPath(entry.path); setNotice(null); try { await containersApi.deletePath(container.id, entry.path); if ( selectedFile?.path === entry.path || (entry.type === "directory" && selectedFile?.path.startsWith(`${entry.path}/`)) ) { resetEditor(); } await loadDirectory(currentPath); setNotice({ tone: "success", message: `Deleted ${entry.path}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to delete item", }); } finally { setBusyPath(null); } }; const handleDownload = async (path: string) => { setBusyPath(path); setNotice(null); try { const response = await containersApi.downloadFile(container.id, path); const data = response.data; if (!data) { throw new Error("No file content returned"); } const blob = decodeBase64ToBlob(data.contentBase64); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = data.name; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice({ tone: "success", message: `Downloaded ${data.name}` }); } catch (err: unknown) { setNotice({ tone: "error", message: err instanceof Error ? err.message : "Failed to download file", }); } finally { setBusyPath(null); } }; const handleUploadRequest = () => { setNewMenuOpen(false); fileInputRef.current?.click(); }; const handleUploadSelected = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) { return; } await uploadFileObject(file); }; const handleDrop = async (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); const file = event.dataTransfer.files?.[0]; if (!file) { return; } await uploadFileObject(file); }; const imagePreviewUrl = selectedFile?.previewBase64 && selectedFile.mimeType?.startsWith("image/") ? `data:${selectedFile.mimeType};base64,${selectedFile.previewBase64}` : null; const binaryPreviewText = selectedFile?.previewBase64 && !selectedFile.mimeType?.startsWith("image/") ? decodeBase64ToText(selectedFile.previewBase64) : null; const pathSegments = currentPath.split("/").filter(Boolean); const breadcrumbItems = pathSegments.length > 5 ? [ ...pathSegments.slice(0, 2).map((segment, index) => ({ segment, index, type: "segment" as const, })), { segment: "...", index: -1, type: "ellipsis" as const, title: pathSegments.slice(2, -2).join("/"), }, ...pathSegments.slice(-2).map((segment, offset) => ({ segment, index: pathSegments.length - 2 + offset, type: "segment" as const, })), ] : pathSegments.map((segment, index) => ({ segment, index, type: "segment" as const, })); return (
{ const current = confirmDialog; setConfirmDialog(null); current?.onCancel?.(); }} onConfirm={() => { const current = confirmDialog; setConfirmDialog(null); current?.onConfirm(); }} />

File Manager

{container.name} • {container.image} {container.server ? ` • ${container.server.name} (${container.server.ip})` : ""}

{breadcrumbItems.map((item) => { return (
{item.type === "ellipsis" ? ( {item.segment} ) : ( )}
); })}
{ if (!event.currentTarget.contains(event.relatedTarget)) { setNewMenuOpen(false); } }} > {newMenuOpen ? (
) : null}
{createMode || renamePath ? (
{renamePath ? "Rename item" : createMode === "file" ? "Create new file" : "Create new folder"} renamePath ? setRenameValue(event.target.value) : setCreateName(event.target.value) } placeholder={ createMode === "file" ? "new-file.txt" : "new-folder" } style={{ minWidth: 280, flex: "1 1 280px" }} />
) : null}
{ event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragOver(true); }} onDragEnter={(event) => { event.preventDefault(); setIsDragOver(true); }} onDragLeave={(event) => { if ( event.currentTarget.contains(event.relatedTarget as Node | null) ) { return; } setIsDragOver(false); }} onDrop={(event) => void handleDrop(event)} style={{ flex: 1, overflow: "hidden", padding: 22, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))", gap: 18, border: isDragOver ? "2px dashed rgba(59,130,246,0.55)" : "2px solid transparent", borderRadius: 18, background: isDragOver ? "rgba(59,130,246,0.06)" : undefined, }} >

Directory Browser

{currentPath}

{listing?.entries.length ?? 0} items
{listingLoading ? (

Loading directory contents...

) : listingError ? (
{listingError}
) : !listing || listing.entries.length === 0 ? (

Directory is empty.

) : ( {listing.entries.map((entry) => { const isDirectory = entry.type === "directory"; const isSpecial = entry.type === "special"; const canOpen = !isSpecial; const rowBusy = busyPath === entry.path; return ( ); })}
Name Size Modified Actions
{isDirectory || entry.size == null ? "-" : formatBytes(entry.size)} {formatTimestamp(entry.modified)}
{!isDirectory ? ( ) : null}
)}

{selectedFile ? selectedFile.name : "Editor"}

{selectedFile ? selectedFile.path : "Choose a file from the left panel"}

{selectedFile ? ( {formatBytes(selectedFile.size)} ) : null}
{selectedFile ? ( ) : null} {notice ? (
{notice.message}
) : null} {fileError ? (
{fileError}
) : null} {fileLoading ? (

Opening file...

) : !selectedFile ? (

Select a file to preview or edit it.

) : selectedFile.isBinary ? ( imagePreviewUrl ? (
Image preview is shown read-only. Use Download if you want the original file.
{selectedFile.name}
) : binaryPreviewText ? (
Binary file detected. Showing lightweight read-only preview.
                      {binaryPreviewText}
                    
) : (
This file looks binary and is not shown in the text editor.
) ) : selectedFile.tooLarge ? (
This file is too large to load into the editor. Open a smaller text file or inspect it through a terminal.
) : (
Modified: {formatTimestamp(selectedFile.modified)} {dirty ? "Unsaved changes" : "Saved"}