From f70eb184c24eb4044a750f5db48a096d6c9a2b98 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 09:19:54 -0700 Subject: [PATCH] A folder tree, and dragging things into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/mock/index.ts | 10 +- web/src/lib/__tests__/dropUpload.test.ts | 102 ++++++++++ web/src/lib/__tests__/filenodeMove.test.ts | 70 +++++++ web/src/lib/dropUpload.ts | Bin 0 -> 5678 bytes web/src/lib/filenode.ts | 29 +++ web/src/lib/folderMove.ts | 15 +- web/src/store/files.ts | 94 +++++++++ web/src/styles/app.css | 10 + web/src/views/AppShell.tsx | 3 +- web/src/views/files/FilesTree.tsx | 213 +++++++++++++++++++++ web/src/views/files/FilesView.tsx | 74 ++++++- 11 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/__tests__/dropUpload.test.ts create mode 100644 web/src/lib/__tests__/filenodeMove.test.ts create mode 100644 web/src/lib/dropUpload.ts create mode 100644 web/src/views/files/FilesTree.tsx diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index acbe0f1..125c55a 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -721,7 +721,15 @@ const handlers: Record = { "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), diff --git a/web/src/lib/__tests__/dropUpload.test.ts b/web/src/lib/__tests__/dropUpload.test.ts new file mode 100644 index 0000000..f818b3e --- /dev/null +++ b/web/src/lib/__tests__/dropUpload.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/web/src/lib/__tests__/filenodeMove.test.ts b/web/src/lib/__tests__/filenodeMove.test.ts new file mode 100644 index 0000000..4733e00 --- /dev/null +++ b/web/src/lib/__tests__/filenodeMove.test.ts @@ -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 = {}) => ({ + 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 => { + const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial = {}) => + ({ 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); + }); +}); diff --git a/web/src/lib/dropUpload.ts b/web/src/lib/dropUpload.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a2b3572c70b438f1ccb2f173b436ada63377920 GIT binary patch literal 5678 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: 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, 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); +} diff --git a/web/src/lib/folderMove.ts b/web/src/lib/folderMove.ts index 9b42605..a40a289 100644 --- a/web/src/lib/folderMove.ts +++ b/web/src/lib/folderMove.ts @@ -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: Id): Set { +/** + * 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(tree: Record, id: Id): Set { const out = new Set(); - const all = Object.values(mailboxes); + const all = Object.values(tree); let frontier = new Set([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(); for (const m of all) { diff --git a/web/src/store/files.ts b/web/src/store/files.ts index a8fd773..37b2b2c 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -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; loadChildren(parentId: Id | null): Promise; @@ -22,6 +39,11 @@ interface FilesState { move(id: Id, parentId: Id | null): Promise; destroy(ids: Id[]): Promise; refresh(ids: Id[]): Promise; + setDragging(id: Id | null): void; + /** Every directory in the account, for the tree in the sidebar. */ + loadTree(): Promise; + /** Upload a planned drop, creating the folders it needs as it goes. */ + uploadPlan(parentId: Id | null, plan: PlannedUpload[]): Promise; pathTo(id: Id | null): FileNode[]; applyChanges(types: Set): void; } @@ -61,6 +83,9 @@ export const useFiles = create((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((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; + // 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((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((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((set, get) => ({ }); }, + async uploadPlan(parentId, plan) { + // Folders first, parents before children, so every file has somewhere to go. + const dirIds = new Map([["", 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(); + 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("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((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((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) { diff --git a/web/src/styles/app.css b/web/src/styles/app.css index e34607c..1a6d4bf 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 01d4d25..4cbaab2 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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") && } {section === "calendar" && } {section === "contacts" &&
Contacts
} - {section === "files" &&
Files
} + {section === "files" && } {section === "settings" &&
Settings
} {(section === "mail" || section === "search") && } diff --git a/web/src/views/files/FilesTree.tsx b/web/src/views/files/FilesTree.tsx new file mode 100644 index 0000000..25300be --- /dev/null +++ b/web/src/views/files/FilesTree.tsx @@ -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>(() => loadRaw("files-expanded", {})); + const setExpanded = (fn: (x: Record) => Record) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; }); + const [menuNode, setMenuNode] = useState(null); + const [shareNode, setShareNode] = useState(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 = {}; + 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 ( +
+
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)} + > + + {open && kids.length ? : } + {d.name} + {isShared(d) && } +
+ {open && kids.map((k) => row(k, depth + 1))} +
+ ); + }; + + return ( + <> +
Files
+
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)} + > +
+ {childrenOf(null).map((d) => row(d, 1))} + {treeLoaded && !dirs.length &&

No folders yet.

} + + + } + 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 && ( + <> + } + 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); + } + }} + /> + } label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> + + } + 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); + } + }} + /> + + )} + + {shareNode && setShareNode(null)} />} + + ); +} diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx index 49c9ce8..586e58a 100644 --- a/web/src/views/files/FilesView.tsx +++ b/web/src/views/files/FilesView.tsx @@ -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(null); const [moveNode, setMoveNode] = useState(null); const [shareNode, setShareNode] = 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(() => { @@ -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 ( -
{ if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}> +
{ 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}>
@@ -88,7 +118,16 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
)} {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="This folder is empty">Drag files here or use Upload. ) : ( @@ -96,7 +135,22 @@ export function FilesView({ nodeId }: { nodeId?: string }) { NameSizeModified {nodes.map((n) => ( - 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); }}> + { 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); }}>
{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)} @@ -108,6 +162,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) { )}
+ {!menuNode && ( + <> + } label="Upload files…" onClick={() => inputRef.current?.click()} /> + } 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" ? } label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : } label="Download" onClick={() => download(menuNode)} />}