import { useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput, X } from "lucide-react"; import { useFiles } from "@/store/files"; import { client } from "@/jmap/client"; import type { FileNode, Id } from "@/jmap/types"; import { formatSize, formatListDate } from "@/lib/format"; import { canDropFileNodes, isShared, NODE_MIME, readDraggedIds } from "@/lib/filenode"; import { previewKind } from "@/lib/preview"; import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/input/dropUpload"; 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 { plural, 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); /* A set, and the row a shift-click measures from. Kept as ids rather than indices: the listing reloads under you -- a push, an upload finishing -- and an index would then point at a different file. */ const [selection, setSelection] = useState>(() => new Set()); const [anchor, setAnchor] = useState(null); const menu = useMenu(); const [menuNode, setMenuNode] = useState(null); const [moveNodes, setMoveNodes] = useState(null); const [shareNode, setShareNode] = useState(null); const [preview, setPreview] = useState(null); /* What the open editor is editing, and the blob its text came from -- the baseline a save is checked against. Kept beside `preview` rather than in it, because the dialog is presentational and knows nothing about nodes. */ const [editTarget, setEditTarget] = useState<{ id: Id; blobId: Id | null } | null>(null); const [startInEdit, setStartInEdit] = useState(false); /* Shared with the sidebar tree, so a row dragged onto a folder there is recognized. See the note on `draggingId` in the store. */ const draggingIds = files.draggingIds; const setDragging = files.setDragging; const inputRef = useRef(null); /* A selection belongs to the folder it was made in. Carrying it across would leave rows selected that are no longer on screen, and the delete two folders later would be a surprise. */ useEffect(() => { setSelection(new Set()); setAnchor(null); }, [parentId, files.accountId]); /* Escape drops it, the way it does everywhere else. */ useEffect(() => { const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setSelection((cur) => (cur.size ? new Set() : cur)); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); 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 ids = readDraggedIds(e.dataTransfer); setDragging([]); if (canDropFileNodes(files.nodes, ids, into)) { setSelection(new Set()); void files.moveMany(ids, 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(); }; /* * Clicking a row, with the conventions a file manager has taught everyone: * plain replaces the selection, ctrl/cmd adds or removes one, shift takes * the run from the last row clicked to this one. The anchor is the row a * shift measures from, and a plain or toggling click moves it. */ const clickRow = (n: FileNode, ev: React.MouseEvent) => { if (ev.shiftKey && anchor) { const from = nodes.findIndex((x) => x.id === anchor); const to = nodes.findIndex((x) => x.id === n.id); if (from >= 0 && to >= 0) { const run = nodes.slice(Math.min(from, to), Math.max(from, to) + 1).map((x) => x.id); setSelection(new Set(ev.ctrlKey || ev.metaKey ? [...selection, ...run] : run)); return; } } if (ev.ctrlKey || ev.metaKey) { const next = new Set(selection); if (next.has(n.id)) next.delete(n.id); else next.add(n.id); setSelection(next); setAnchor(n.id); return; } setSelection(new Set([n.id])); setAnchor(n.id); }; /* Right-clicking inside the selection acts on all of it; right-clicking outside it means you meant that row, so the selection follows the pointer rather than the menu quietly applying to something off-screen. */ const menuFor = (n: FileNode, at: (x: number, y: number) => void, x: number, y: number) => { if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); } setMenuNode(n); at(x, y); }; const selectedNodes = () => nodes.filter((n) => selection.has(n.id)); /* What the menu and the bar act on: the whole selection when the row is part of it, and that row alone otherwise. */ const targets = (n: FileNode | null) => (n && selection.has(n.id) && selection.size > 1 ? selectedNodes() : n ? [n] : selectedNodes()); const removeNodes = async (list: FileNode[]) => { if (!list.length) return; const title = list.length === 1 ? t("Delete “{name}”?", { name: list[0]!.name }) : plural(list.length, { one: "Delete {n} item?", other: "Delete {n} items?" }); if (!(await confirmDialog({ title, confirmLabel: t("Delete"), danger: true }))) return; try { await files.destroy(list.map((n) => n.id)); setSelection(new Set()); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } }; /* 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, edit = false) => { setPreview({ name: n.name, type: n.type ?? "application/octet-stream", size: n.size, url: blobUrl(n, false), inlineUrl: blobUrl(n, true) }); setEditTarget({ id: n.id, blobId: n.blobId }); setStartInEdit(edit); }; /* What the menu can tell from a row: text, and the right to write it. Whether it is *really* editable needs the bytes -- a truncated or non-UTF-8 file opens read-only and says so. */ const canEditFile = (n: FileNode) => canPreview(n) && previewKind(n.type, n.name) === "text" && Boolean(n.myRights?.mayModifyContent); /* * Only offered where the reader may actually write: a folder shared read-only * still opens, and the Edit button is simply not there. `saveText` checks the * blob it started from, so two people editing the same file get told rather * than one of them losing the work. */ const canEditNode = (n: FileNode | undefined) => Boolean(n?.myRights?.mayModifyContent); const saveEdited = async (text: string) => { const target = editTarget; if (!target) return; const next = await files.saveText(target.id, text, target.blobId); // The file has a new blob now; the next save in this same session is // checked against that one, not the one we opened. setEditTarget({ id: target.id, blobId: next }); toast.success(t("Saved")); }; /* 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) && canDropFileNodes(files.nodes, draggingIds, 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}%}
)}
)} {selection.size > 1 && (
{plural(selection.size, { one: "{n} item selected", other: "{n} items selected" })}
)} {files.error &&
{files.error}
}
{ if ((e.target as HTMLElement).closest("tr")) return; setSelection((cur) => (cur.size ? new Set() : cur)); }} onContextMenu={(e) => { // 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) => ( { /* Dragging a row that is part of the selection drags all of it; dragging one outside the selection means that row. */ const ids = selection.has(n.id) ? [...selection] : [n.id]; if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); } e.dataTransfer.setData(NODE_MIME, ids.join(",")); e.dataTransfer.effectAllowed = "move"; setDragging(ids); }} onDragEnd={() => setDragging([])} onDragOver={(e) => { if (n.nodeType !== "directory") return; const node = e.dataTransfer.types.includes(NODE_MIME); if (node ? !canDropFileNodes(files.nodes, draggingIds, 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={(e) => clickRow(n, e)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); menuFor(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 && targets(menuNode).length > 1 && ( <> } label={plural(targets(menuNode).length, { one: "Move {n} item…", other: "Move {n} items…" })} onClick={() => setMoveNodes(targets(menuNode))} /> } label={plural(targets(menuNode).length, { one: "Delete {n} item", other: "Delete {n} items" })} onClick={() => void removeNodes(targets(menuNode))} /> )} {menuNode && targets(menuNode).length <= 1 && ( <> {menuNode.nodeType === "directory" ? } label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : ( <> {canPreview(menuNode) && } label={t("Preview")} onClick={() => openPreview(menuNode)} />} {canEditFile(menuNode) && } label={t("Edit")} onClick={() => openPreview(menuNode, true)} />} } 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={() => setMoveNodes([menuNode])} /> } label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> } label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={() => void removeNodes([menuNode])} /> )} {moveNodes && setMoveNodes(null)} onMoved={() => setSelection(new Set())} />} { setPreview(null); setEditTarget(null); setStartInEdit(false); }} onSave={editTarget && canEditNode(files.nodes[editTarget.id]) ? saveEdited : undefined} startInEdit={startInEdit} /> {shareNode && setShareNode(null)} />}
); } function MoveDialog({ nodes, onClose, onMoved }: { nodes: FileNode[]; onClose: () => void; onMoved: () => void }) { const files = useFiles(); const [cur, setCur] = useState(null); useEffect(() => { void files.loadChildren(cur); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cur]); /* None of the folders being moved can be their own destination, and neither can a folder already holding all of them -- "Move here" would be a no-op. */ const moving = new Set(nodes.map((n) => n.id)); const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && !moving.has(n.id))); const path = files.pathTo(cur); const already = nodes.every((n) => (n.parentId ?? null) === cur); const title = nodes.length === 1 ? t("Move \u201c{name}\u201d", { name: nodes[0]!.name }) : plural(nodes.length, { one: "Move {n} item", other: "Move {n} items" }); return ( }>
{path.map((n) => )}
{dirs.map((d) => )} {!dirs.length &&

{t("No subfolders here.")}

}
); }