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 0000000..2a2b357 Binary files /dev/null and b/web/src/lib/dropUpload.ts differ diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index 64a5832..023eb8e 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -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): 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)} />}