import { useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { ChevronRight, Download, Eye, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react"; import { useFiles } from "@/store/files"; import { client } from "@/jmap/client"; import type { FileNode } from "@/jmap/types"; import { formatSize, formatListDate } from "@/lib/format"; import { canDropFileNode, isShared } from "@/lib/filenode"; import { previewKind } from "@/lib/preview"; import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload"; import { NODE_MIME } from "./FilesTree"; import { ShareDialog } from "../settings/ShareDialog"; import { Empty, Spinner } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview"; import { toast } from "@/ui/toast"; import { t } from "@/lib/i18n"; export function FilesView({ nodeId }: { nodeId?: string }) { const [, navigate] = useLocation(); const files = useFiles(); const parentId = nodeId ?? null; const [dropping, setDropping] = useState(false); const [selected, setSelected] = useState(null); const menu = useMenu(); const [menuNode, setMenuNode] = useState(null); const [moveNode, setMoveNode] = useState(null); const [shareNode, setShareNode] = useState(null); const [preview, setPreview] = useState(null); /* Shared with the sidebar tree, so a row dragged onto a folder there is recognised. See the note on `draggingId` in the store. */ const draggingId = files.draggingId; const setDraggingId = files.setDragging; const inputRef = useRef(null); useEffect(() => { if (files.available) void files.loadChildren(parentId); // `accountId` is in here because opening a share changes which account the // same route means: at /files the parent is null before and after, so // without it the listing would keep showing the previous account's folder. // eslint-disable-next-line react-hooks/exhaustive-deps }, [files.available, files.accountId, parentId]); // The sidebar's primary button asks for an upload here, the way it asks the // calendar for a new event. useEffect(() => { const open = () => inputRef.current?.click(); window.addEventListener("ihm:files-upload", open); return () => window.removeEventListener("ihm:files-upload", open); }, []); // Ensure ancestors are loaded for breadcrumbs useEffect(() => { if (!files.available || !parentId) return; const n = files.nodes[parentId]; if (!n) { void client.call<{ list: FileNode[] }>("FileNode/get", { accountId: files.accountId, ids: [parentId], fetchParents: true }).then((r) => { useFiles.setState((s) => { const nodes = { ...s.nodes }; for (const x of r.list) nodes[x.id] = x; return { nodes }; }); }).catch(() => undefined); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentId, files.available]); if (!files.available) return
} title={t("File storage is not available")}>{t("This account does not have the JMAP file storage capability.")}
; const ids = files.children[parentId ?? "root"] ?? []; const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n)); const path = files.pathTo(parentId); /* A drop lands in `into`, which is the folder under the pointer when there is one and the folder being listed otherwise. Entries have to be read out before the first await -- the list is emptied the moment the handler returns -- so that happens here, synchronously, for every path. */ const dropOnto = (into: string | null, e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDropping(false); if (e.dataTransfer.types.includes(NODE_MIME)) { const id = e.dataTransfer.getData(NODE_MIME); setDraggingId(null); if (id && canDropFileNode(files.nodes, id, into)) { void files.move(id, into).catch((err) => toast.error((err as Error).message)); } return; } if (!e.dataTransfer.types.includes("Files")) return; const entries = entriesFromDrop(e.dataTransfer); const flat = Array.from(e.dataTransfer.files); void (async () => { if (entries.length && hasDirectory(entries)) { const plan = await planUpload(entries); if (plan.length) await files.uploadPlan(into, plan); return; } if (flat.length) await files.upload(into, flat); })(); }; const onDrop = (e: React.DragEvent) => dropOnto(parentId, e); const blobUrl = (n: FileNode, inline: boolean) => client.downloadUrl(files.accountId!, n.blobId!, n.name, n.type ?? "application/octet-stream", inline); const download = (n: FileNode) => { if (!n.blobId) return; const a = document.createElement("a"); a.href = blobUrl(n, false); a.download = n.name; a.click(); }; /* A file with nothing to show still does what it always did. */ const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null; const openPreview = (n: FileNode) => setPreview({ name: n.name, type: n.type ?? "application/octet-stream", size: n.size, url: blobUrl(n, false), inlineUrl: blobUrl(n, true) }); /* Double-clicking a file used to download it, which is a decision made for you: to look at a picture you had to put it on disk first. Now it opens what can be opened and downloads the rest. */ const activate = (n: FileNode) => { if (n.nodeType === "directory") navigate(`/files/${n.id}`); else if (canPreview(n)) openPreview(n); else download(n); }; return (
{ if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
{path.map((n, i) => ( ))}
{ const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
{files.uploads.length > 0 && (
{files.uploads.map((u) =>
{u.name}{u.error ? {u.error} : {u.progress}%}
)}
)} {files.error &&
{files.error}
}
{ // Only the empty space below the rows: a row has its own menu. if ((e.target as HTMLElement).closest("tr")) return; e.preventDefault(); setMenuNode(null); menu.openAt(e.clientX, e.clientY); }} > {files.loading && !nodes.length ? : !nodes.length ? ( } title={t("This folder is empty")}>{t("Drag files here or use Upload.")} ) : ( {nodes.map((n) => ( { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }} onDragEnd={() => setDraggingId(null)} onDragOver={(e) => { if (n.nodeType !== "directory") return; const node = e.dataTransfer.types.includes(NODE_MIME); if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = node ? "move" : "copy"; }} onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }} onClick={() => setSelected(n.id)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}> ))}
{t("Name")}{t("Size")}{t("Modified")}
{n.nodeType === "directory" ? : } { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}{isShared(n) && }
{n.nodeType === "directory" ? "—" : formatSize(n.size)} {formatListDate(n.modified ?? n.created)}
)}
{!menuNode && ( <> } label={t("Upload files…")} onClick={() => inputRef.current?.click()} /> } label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> )} {menuNode && ( <> {menuNode.nodeType === "directory" ? } label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : ( <> {canPreview(menuNode) && } label={t("Preview")} onClick={() => openPreview(menuNode)} />} } label={t("Download")} onClick={() => download(menuNode)} /> )} } label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> } label={t("Move to…")} onClick={() => setMoveNode(menuNode)} /> } label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> } label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), confirmLabel: t("Delete"), danger: true })) { try { await files.destroy([menuNode.id]); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } } }} /> )} {moveNode && setMoveNode(null)} />} setPreview(null)} /> {shareNode && setShareNode(null)} />}
); } function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) { const files = useFiles(); const [cur, setCur] = useState(null); useEffect(() => { void files.loadChildren(cur); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cur]); const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id)); const path = files.pathTo(cur); return ( }>
{path.map((n) => )}
{dirs.map((d) => )} {!dirs.length &&

{t("No subfolders here.")}

}
); }