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:
2026-08-27 09:19:54 -07:00
parent 6566f4c2d3
commit f70eb184c2
11 changed files with 606 additions and 14 deletions
+9 -1
View File
@@ -721,7 +721,15 @@ const handlers: Record<string, Handler> = {
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {};
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
// `nodeType` is a filter 0.16.19 really applies -- checked live on
// 2026-08-27, where it returned the two directories out of seven nodes. The
// mock ignoring it was worse than not having it: the sidebar tree asks for
// directories and was handed files, which it then drew as folders.
const list = fileNodes.filter((n) => {
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
if (f.nodeType && n.nodeType !== f.nodeType) return false;
return true;
});
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
},
"FileNode/get": genericGet(fileNodes),
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
/**
* Dropping a folder in, reduced to the two things the DataTransfer entry API
* gets wrong if you take it at face value.
*
* `readEntries` answers with *up to* some number of entries and signals the end
* of a directory with an empty array, so a single call quietly loses everything
* past the first batch — a real folder of a few hundred files would upload the
* first hundred and look like it had finished. And a directory tree that cycles
* has to stop somewhere the tab is still alive.
*/
const file = (name: string) => new File([name], name);
/** A directory whose contents arrive a batch at a time, as a real one does. */
const dir = (name: string, children: unknown[], batch = 2) => {
let at = 0;
return {
isFile: false,
isDirectory: true,
name,
createReader: () => ({
readEntries: (cb: (e: never[]) => void) => {
const slice = children.slice(at, at + batch);
at += slice.length;
cb(slice as never[]);
},
}),
};
};
const leaf = (name: string) => ({
isFile: true,
isDirectory: false,
name,
file: (cb: (f: File) => void) => cb(file(name)),
});
describe("walking a dropped folder", () => {
it("reads a directory across as many batches as it takes", async () => {
// Five children, two per readEntries call: a single read would find two.
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
});
it("keeps the folder each file came from", async () => {
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
["outer", "top"],
["outer/inner", "deep"],
]);
});
it("puts a loose file at the drop itself", async () => {
const plan = await planUpload([leaf("loose")] as never[]);
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
});
it("stops rather than following a cycle for ever", async () => {
const loop: Record<string, unknown> = {};
Object.assign(loop, dir("loop", []));
(loop as { createReader: () => unknown }).createReader = () => ({
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
});
// Terminating at all is the assertion; the caps decide where. Both are set
// low so the test does not have to read twenty thousand phantom entries.
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
expect(plan).toEqual([]);
});
});
describe("the folders a plan needs", () => {
it("lists parents before their children", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a", "b", "c"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
});
it("names each folder once, however many files are in it", () => {
const needed = foldersNeeded([
{ file: file("x"), path: ["a"] },
{ file: file("y"), path: ["a"] },
]);
expect(needed).toEqual([["a"]]);
});
it("asks for nothing when everything lands at the drop", () => {
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
});
});
describe("spotting a folder in the drop", () => {
it("is true when any entry is a directory", () => {
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { canDropFileNode } from "@/lib/filenode";
import type { FileNode, Id } from "@/jmap/types";
/**
* Dragging a folder into its own subtree is the move that has to be refused
* rather than reported: the server would orphan the branch, and the folder the
* reader was dragging would leave the tree with everything under it.
*/
const rights = (over: Partial<FileNode["myRights"]> = {}) => ({
mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true, ...over,
});
/** a > b > c, plus a file in a and a second top-level folder. */
const tree = (): Record<Id, FileNode> => {
const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial<FileNode> = {}) =>
({ id, parentId, nodeType, name: id, myRights: rights(), ...over }) as FileNode;
return {
a: mk("a", null, "directory"),
b: mk("b", "a", "directory"),
c: mk("c", "b", "directory"),
other: mk("other", null, "directory"),
doc: mk("doc", "a", "file"),
};
};
describe("what a folder may be dropped on", () => {
it("allows a move to an unrelated folder", () => {
expect(canDropFileNode(tree(), "a", "other")).toBe(true);
});
it("refuses a drop on itself", () => {
expect(canDropFileNode(tree(), "a", "a")).toBe(false);
});
it("refuses a drop into its own subtree, however deep", () => {
expect(canDropFileNode(tree(), "a", "b")).toBe(false);
expect(canDropFileNode(tree(), "a", "c")).toBe(false);
});
it("refuses the parent it already has, which is a no-op dressed as a move", () => {
expect(canDropFileNode(tree(), "b", "a")).toBe(false);
});
it("allows a child up to the top level, but not one already there", () => {
expect(canDropFileNode(tree(), "b", null)).toBe(true);
expect(canDropFileNode(tree(), "a", null)).toBe(false);
});
});
describe("targets that cannot take it", () => {
it("refuses a file as a target", () => {
expect(canDropFileNode(tree(), "b", "doc")).toBe(false);
});
it("refuses a folder that will not take children", () => {
const t = tree();
t.other = { ...t.other!, myRights: rights({ mayAddChildren: false }) };
expect(canDropFileNode(t, "a", "other")).toBe(false);
});
it("refuses a target that is not there at all", () => {
expect(canDropFileNode(tree(), "a", "ghost")).toBe(false);
});
it("allows a file to be moved like anything else", () => {
expect(canDropFileNode(tree(), "doc", "other")).toBe(true);
});
});
Binary file not shown.
+29
View File
@@ -9,6 +9,7 @@
* so a node has one shape and there is nothing left to detect.
*/
import type { FileNode, Id } from "@/jmap/types";
import { descendantIds } from "./folderMove";
/** Properties to request for a node. */
export function fileNodeProps(): string[] {
@@ -36,3 +37,31 @@ export function fileCreate(parentId: Id | null, name: string, blobId: Id, type:
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
return Object.keys(node.shareWith ?? {}).length > 0;
}
/**
* Whether the node being dragged may be dropped on `targetId`, null being the
* top level.
*
* The same four refusals as folders: onto itself, into its own subtree, onto
* the parent it already has, or -- for the top level -- when it is already
* there. `descendantIds` is shared with the mailbox tree, since both are the
* same shape of tree asking the same question.
*
* Rights are deliberately only half-checked. A target that will not take
* children is refused here, because that is unambiguous. Whether the node may
* leave the parent it is in is not: JMAP models a move as an update of
* `parentId` and does not say which right covers it, and guessing would hide
* legal moves behind a disabled drop. The server refuses those with a message
* of its own, which is a better answer than a silent one.
*/
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
const dragged = nodes[draggedId];
if (!dragged) return false;
if (targetId === null) return dragged.parentId != null;
if (targetId === draggedId) return false;
if (dragged.parentId === targetId) return false;
const target = nodes[targetId];
if (!target || target.nodeType !== "directory") return false;
if (target.myRights && !target.myRights.mayAddChildren) return false;
return !descendantIds(nodes, draggedId).has(targetId);
}
+10 -5
View File
@@ -9,13 +9,18 @@ export function movable(m: Mailbox): boolean {
return !m.role || m.role === "subscribed";
}
/** Every folder beneath this one, so a folder cannot be dropped inside itself. */
export function descendantIds(mailboxes: Record<Id, Mailbox>, id: Id): Set<Id> {
/**
* Every node beneath this one, so a node cannot be dropped inside itself.
*
* Written against `{ id, parentId }` rather than `Mailbox` because file nodes
* form the same shape of tree and need the same answer -- see `canDropFileNode`.
*/
export function descendantIds<T extends { id: Id; parentId: Id | null }>(tree: Record<Id, T>, id: Id): Set<Id> {
const out = new Set<Id>();
const all = Object.values(mailboxes);
const all = Object.values(tree);
let frontier = new Set<Id>([id]);
// Depth is bounded by the server's own mailbox depth limit; the guard is only
// here so a cycle in the data cannot spin forever.
// Depth is bounded by the server's own depth limit; the guard is only here so
// a cycle in the data cannot spin forever.
for (let depth = 0; depth < 20 && frontier.size; depth++) {
const next = new Set<Id>();
for (const m of all) {
+94
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
import { isAppFolder } from "@/lib/appFolder";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
@@ -13,6 +14,22 @@ interface FilesState {
loading: boolean;
error: string | null;
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
dirIds: Id[];
treeLoaded: boolean;
/*
* The node being dragged, if any.
*
* Kept here rather than in whichever pane started the drag, because a drag
* crosses between them -- a row dragged onto the sidebar tree, a folder in
* the tree dragged onto a row -- and every possible target has to know what
* is in flight to say whether it will take it. Two panes each holding their
* own copy meant the one that did not start the drag never lit up and never
* accepted the drop.
*
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
* during dragover, which is exactly when the answer is needed.
*/
draggingId: Id | null;
init(): Promise<void>;
loadChildren(parentId: Id | null): Promise<void>;
@@ -22,6 +39,11 @@ interface FilesState {
move(id: Id, parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>;
refresh(ids: Id[]): Promise<void>;
setDragging(id: Id | null): void;
/** Every directory in the account, for the tree in the sidebar. */
loadTree(): Promise<void>;
/** Upload a planned drop, creating the folders it needs as it goes. */
uploadPlan(parentId: Id | null, plan: PlannedUpload[]): Promise<void>;
pathTo(id: Id | null): FileNode[];
applyChanges(types: Set<string>): void;
}
@@ -61,6 +83,9 @@ export const useFiles = create<FilesState>((set, get) => ({
loading: false,
error: null,
uploads: [],
dirIds: [],
treeLoaded: false,
draggingId: null,
async init() {
const accountId = useSession.getState().accountFor(CAP.filenode);
@@ -69,6 +94,44 @@ export const useFiles = create<FilesState>((set, get) => ({
set({ available });
},
/*
* The whole directory tree in one query.
*
* `filter: { nodeType: "directory" }` returns every folder in the account,
* confirmed against 0.16.19 on 2026-08-27, so the sidebar tree is complete
* from the first paint: expanding costs nothing, and a drag knows every
* folder it could be dropped on without having opened it first.
*
* It is deliberately its own request rather than a call appended to another.
* A filter Stalwart refuses fails with a request-level 400 that takes every
* method call in the request with it -- `{ parentId: null }` does exactly
* that -- so a tree query batched alongside the folder listing would blank
* the whole view instead of just the sidebar.
*/
async loadTree() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
// Filtered again here rather than trusted: a server that ignores the
// nodeType filter answers with files as well, and the tree would draw
// them as folders you could open into nothing.
const dirs = withoutAppFolder(g.list).filter((n) => n.nodeType === "directory");
set((s) => {
const nodes = { ...s.nodes };
for (const n of dirs) nodes[n.id] = n;
return { nodes, dirIds: dirs.map((n) => n.id), treeLoaded: true };
});
} catch (err) {
// The listing still works without a tree, so this must not blank the view.
set({ error: (err as Error).message, treeLoaded: true });
}
},
async loadChildren(parentId) {
const accountId = get().accountId;
if (!accountId) return;
@@ -103,6 +166,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const err = res.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(parentId);
void get().loadTree();
return res.created!.d!.id;
},
@@ -133,6 +197,10 @@ export const useFiles = create<FilesState>((set, get) => ({
/* Re-read named nodes in place. Sharing changes one property of one node and
nothing about which folder it sits in, so reloading the level around it
would be a bigger round trip to land in the same place. */
setDragging(id) {
set({ draggingId: id });
},
async refresh(ids) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
@@ -144,12 +212,36 @@ export const useFiles = create<FilesState>((set, get) => ({
});
},
async uploadPlan(parentId, plan) {
// Folders first, parents before children, so every file has somewhere to go.
const dirIds = new Map<string, Id | null>([["", parentId]]);
for (const path of foldersNeeded(plan)) {
const parent = dirIds.get(path.slice(0, -1).join(" ")) ?? parentId;
const name = path[path.length - 1]!;
try {
dirIds.set(path.join(" "), await get().mkdir(parent, name));
} catch (err) {
// Leave it unmapped: its files land in the nearest folder that exists
// rather than vanishing, and the error is shown against the upload.
set({ error: (err as Error).message });
}
}
const byFolder = new Map<string, File[]>();
for (const item of plan) {
const key = item.path.join(" ");
byFolder.set(key, [...(byFolder.get(key) ?? []), item.file]);
}
for (const [key, files] of byFolder) await get().upload(dirIds.get(key) ?? parentId, files);
void get().loadTree();
},
async rename(id, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(get().nodes[id]?.parentId ?? null);
void get().loadTree();
},
async move(id, parentId) {
@@ -159,6 +251,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
void get().loadTree();
},
async destroy(ids) {
@@ -168,6 +261,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(setErrorMessage(failed));
for (const p of parents) await get().loadChildren(p);
void get().loadTree();
},
pathTo(id) {
+10
View File
@@ -1014,3 +1014,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.dp-split { flex-direction: column; }
.dp-times { flex-direction: row; overflow-x: auto; max-height: none; border-left: 0; border-top: 1px solid var(--border); padding: 6px 0 0; }
}
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
no equivalent for are here. A row in the list is a drop target the same way a
folder in the tree is, and says so the same way. */
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
.files-table tbody tr[draggable="true"] { cursor: grab; }
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
.sidebar .nav-item[draggable="true"] { cursor: pointer; }
.f-name .faint { flex: none; }
+2 -1
View File
@@ -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 />}
+213
View File
@@ -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)} />}
</>
);
}
+67 -7
View File
@@ -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)} />}