A folder tree, and dragging things into it
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
This commit is contained in:
@@ -9,6 +9,7 @@ import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { FilesTree } from "./files/FilesTree";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
@@ -128,7 +129,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
{section === "calendar" && <CalendarSidebar />}
|
||||
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
|
||||
{section === "files" && <div className="nav-section"><span>Files</span></div>}
|
||||
{section === "files" && <FilesTree />}
|
||||
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
/**
|
||||
* The folder tree beside the file list.
|
||||
*
|
||||
* Every directory in the account arrives in one query, so this never waits on
|
||||
* an expand and a drag always knows every folder it could land on -- including
|
||||
* ones the reader has never opened.
|
||||
*/
|
||||
export function FilesTree() {
|
||||
const [location, navigate] = useLocation();
|
||||
const nodes = useFiles((s) => s.nodes);
|
||||
const dirIds = useFiles((s) => s.dirIds);
|
||||
const treeLoaded = useFiles((s) => s.treeLoaded);
|
||||
const available = useFiles((s) => s.available);
|
||||
const loadTree = useFiles((s) => s.loadTree);
|
||||
// Kept across sessions, the way the mailbox tree keeps its own.
|
||||
const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {}));
|
||||
const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; });
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||
const [rootDrop, setRootDrop] = useState(false);
|
||||
const menu = useMenu();
|
||||
|
||||
/* Shared with the list pane: a drag starting in one has to be recognised by
|
||||
the other. See the note on `draggingId` in the store. */
|
||||
const draggingId = useFiles((s) => s.draggingId);
|
||||
const setDraggingId = useFiles((s) => s.setDragging);
|
||||
|
||||
useEffect(() => {
|
||||
if (available && !treeLoaded) void loadTree();
|
||||
}, [available, treeLoaded, loadTree]);
|
||||
|
||||
const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null;
|
||||
|
||||
// Open the branch the reader is looking at, so the current folder is visible
|
||||
// without them having to find it.
|
||||
useEffect(() => {
|
||||
if (!currentId) return;
|
||||
const open: Record<Id, boolean> = {};
|
||||
for (let id: Id | null | undefined = nodes[currentId]?.parentId; id; id = nodes[id]?.parentId) open[id] = true;
|
||||
if (Object.keys(open).length) setExpanded((x) => ({ ...x, ...open }));
|
||||
}, [currentId, nodes]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
|
||||
const canDropOn = (targetId: Id | null) => Boolean(draggingId) && canDropFileNode(nodes, draggingId!, targetId);
|
||||
|
||||
const moveTo = async (id: Id, parentId: Id | null) => {
|
||||
setDraggingId(null);
|
||||
try {
|
||||
await useFiles.getState().move(id, parentId);
|
||||
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
/** Files dropped from outside land in the folder they were dropped on. */
|
||||
const dropFiles = async (parentId: Id | null, dt: DataTransfer) => {
|
||||
const entries = entriesFromDrop(dt);
|
||||
const flat = Array.from(dt.files);
|
||||
if (entries.length && hasDirectory(entries)) {
|
||||
const plan = await planUpload(entries);
|
||||
if (plan.length) await useFiles.getState().uploadPlan(parentId, plan);
|
||||
return;
|
||||
}
|
||||
if (flat.length) await useFiles.getState().upload(parentId, flat);
|
||||
};
|
||||
|
||||
const onDrop = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRootDrop(false);
|
||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||
const id = e.dataTransfer.getData(NODE_MIME);
|
||||
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId);
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
|
||||
};
|
||||
|
||||
const onDragOver = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||
if (node ? !canDropOn(targetId) : !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||
};
|
||||
|
||||
const row = (d: FileNode, depth: number) => {
|
||||
const kids = childrenOf(d.id);
|
||||
const open = Boolean(expanded[d.id]);
|
||||
return (
|
||||
<div key={d.id}>
|
||||
<div
|
||||
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && canDropOn(d.id) ? "drop-target" : ""}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
onClick={() => navigate(`/files/${d.id}`)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onDragOver={onDragOver(d.id)}
|
||||
onDrop={onDrop(d.id)}
|
||||
>
|
||||
<button
|
||||
className="nav-twisty"
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
style={{ visibility: kids.length ? "visible" : "hidden" }}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((x) => ({ ...x, [d.id]: !open })); }}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
|
||||
<span className="grow truncate">{d.name}</span>
|
||||
{isShared(d) && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
</div>
|
||||
{open && kids.map((k) => row(k, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>Files</span></div>
|
||||
<div
|
||||
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
|
||||
onClick={() => navigate("/files")}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(null); menu.openAt(e.clientX, e.clientY); }}
|
||||
onDragOver={(e) => { onDragOver(null)(e); if (!e.defaultPrevented) return; setRootDrop(true); }}
|
||||
onDragLeave={() => setRootDrop(false)}
|
||||
onDrop={onDrop(null)}
|
||||
>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<HardDrive size={17} />
|
||||
<span className="grow truncate">All files</span>
|
||||
</div>
|
||||
{childrenOf(null).map((d) => row(d, 1))}
|
||||
{treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>No folders yet.</p>}
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||
<MenuItem
|
||||
icon={<FolderPlus size={16} />}
|
||||
label="New folder"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
|
||||
if (menuNode) setExpanded((x) => ({ ...x, [menuNode.id]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{menuNode && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<Pencil size={16} />}
|
||||
label="Rename"
|
||||
disabled={!menuNode.myRights?.mayRename}
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
|
||||
if (!name?.trim() || name === menuNode.name) return;
|
||||
try {
|
||||
await useFiles.getState().rename(menuNode.id, name.trim());
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
label="Delete"
|
||||
disabled={!menuNode.myRights?.mayDelete}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await useFiles.getState().destroy([menuNode.id]);
|
||||
if (currentId === menuNode.id) navigate("/files");
|
||||
toast.success("Deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { useFiles } from "@/store/files";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { isShared } from "@/lib/filenode";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
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";
|
||||
@@ -22,6 +24,10 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(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<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,13 +57,37 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(parentId);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
/* 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);
|
||||
const list = Array.from(e.dataTransfer.files);
|
||||
if (list.length) void files.upload(parentId, list);
|
||||
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 download = (n: FileNode) => {
|
||||
if (!n.blobId) return;
|
||||
const a = document.createElement("a");
|
||||
@@ -67,7 +97,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { 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}>
|
||||
<div className="files-toolbar">
|
||||
<div className="breadcrumb">
|
||||
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
||||
@@ -88,7 +118,16 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
</div>
|
||||
)}
|
||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||
<div className="files-scroll">
|
||||
<div
|
||||
className="files-scroll"
|
||||
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 ? <Spinner /> : !nodes.length ? (
|
||||
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
|
||||
) : (
|
||||
@@ -96,7 +135,22 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<tr
|
||||
key={n.id}
|
||||
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => { 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={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label="Shared" />}</div></td>
|
||||
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||
@@ -108,6 +162,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||
{!menuNode && (
|
||||
<>
|
||||
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New folder" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
|
||||
|
||||
Reference in New Issue
Block a user