diff --git a/web/src/lib/__tests__/filenodeDrag.test.ts b/web/src/lib/__tests__/filenodeDrag.test.ts new file mode 100644 index 0000000..fe70bf7 --- /dev/null +++ b/web/src/lib/__tests__/filenodeDrag.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { canDropFileNodes, NODE_MIME, readDraggedIds } from "@/lib/filenode"; +import type { FileNode, Id } from "@/jmap/types"; + +const rights = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; + +function node(id: string, parentId: Id | null, nodeType: FileNode["nodeType"] = "file"): FileNode { + return { id, parentId, nodeType, blobId: nodeType === "file" ? `b${id}` : null, size: 1, name: id, type: "text/plain", created: "", modified: null, myRights: rights } as FileNode; +} + +/* + * A multi-file drag carries its ids in one payload, because `dataTransfer` + * holds one string per type and the drop has to be one action. These two + * functions are the whole of that contract -- the gesture itself cannot be + * driven synthetically, so this is what pins it. + */ +describe("readDraggedIds", () => { + const dt = (value: string) => ({ getData: (type: string) => (type === NODE_MIME ? value : "") }) as DataTransfer; + + it("reads one id as a list of one", () => { + expect(readDraggedIds(dt("f1"))).toEqual(["f1"]); + }); + + it("reads a whole selection", () => { + expect(readDraggedIds(dt("f1,f2,f3"))).toEqual(["f1", "f2", "f3"]); + }); + + it("is empty for a drag that carries nothing of ours", () => { + // A drag from outside the app: the caller checks `types` first, but an + // empty string here must not read as a file called "". + expect(readDraggedIds(dt(""))).toEqual([]); + expect(readDraggedIds(dt(",,"))).toEqual([]); + }); +}); + +describe("canDropFileNodes", () => { + const nodes: Record = { + root1: node("root1", null), + root2: node("root2", null), + dir: node("dir", null, "directory"), + inside: node("inside", "dir"), + }; + + it("allows a drop only when every file can make it", () => { + expect(canDropFileNodes(nodes, ["root1", "root2"], "dir")).toBe(true); + // `inside` is already in `dir`, so the move is a no-op for it -- and a drop + // that would move one of two files is refused rather than half-done. + expect(canDropFileNodes(nodes, ["root1", "inside"], "dir")).toBe(false); + }); + + it("refuses a folder dropped into itself, whoever it is dragged with", () => { + expect(canDropFileNodes(nodes, ["dir"], "dir")).toBe(false); + expect(canDropFileNodes(nodes, ["root1", "dir"], "dir")).toBe(false); + }); + + it("has nothing to drop when nothing is dragged", () => { + expect(canDropFileNodes(nodes, [], "dir")).toBe(false); + }); + + it("treats the top level like any other target", () => { + expect(canDropFileNodes(nodes, ["inside"], null)).toBe(true); + // Already at the top: nothing to do. + expect(canDropFileNodes(nodes, ["root1"], null)).toBe(false); + expect(canDropFileNodes(nodes, ["inside", "root1"], null)).toBe(false); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index 023eb8e..ff51f07 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -54,6 +54,27 @@ export function isShared(node: Pick): boolean { * 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. */ +/** The MIME a dragged node is offered under, so a target can recognise it. */ +export const NODE_MIME = "application/x-ihasmail-filenode"; + +/** + * The ids in a node drag. A multi-file selection is dragged as one payload, so + * this is a list even when it holds one -- both drop targets read it the same + * way and neither has to care how the drag started. + */ +export function readDraggedIds(dt: DataTransfer): Id[] { + return dt.getData(NODE_MIME).split(",").filter(Boolean); +} + +/** + * The same question for a multi-file drag. Every one of them has to be able to + * land, because the drop is one action: allowing a drag that would move four + * of five files and silently skip the fifth is worse than refusing it. + */ +export function canDropFileNodes(nodes: Record, draggedIds: Id[], targetId: Id | null): boolean { + return draggedIds.length > 0 && draggedIds.every((id) => canDropFileNode(nodes, id, targetId)); +} + export function canDropFileNode(nodes: Record, draggedId: Id, targetId: Id | null): boolean { const dragged = nodes[draggedId]; if (!dragged) return false; diff --git a/web/src/store/__tests__/files-account-switch.test.ts b/web/src/store/__tests__/files-account-switch.test.ts index 0fdca95..ed15e0d 100644 --- a/web/src/store/__tests__/files-account-switch.test.ts +++ b/web/src/store/__tests__/files-account-switch.test.ts @@ -24,7 +24,7 @@ describe("what a switch to another account keeps", () => { children: {}, dirIds: [], treeLoaded: false, - draggingId: null, + draggingIds: [], error: null, }); }); @@ -41,14 +41,14 @@ describe("what a switch to another account keeps", () => { it("drops a drag that was in flight", () => { // Its id belongs to the other account and would name a different node here. - expect(emptyForAccount("b").draggingId).toBeNull(); + expect(emptyForAccount("b").draggingIds).toEqual([]); }); it("names every piece of per-account state", () => { // Add a per-account field to the store and forget it here, and this fails // rather than the field quietly following someone into another account. expect(Object.keys(emptyForAccount(null)).sort()).toEqual( - ["accountId", "children", "dirIds", "draggingId", "error", "nodes", "treeLoaded"], + ["accountId", "children", "dirIds", "draggingIds", "error", "nodes", "treeLoaded"], ); }); }); diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 8f22c29..bb2cf2e 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -48,7 +48,7 @@ interface FilesState { * 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; + draggingIds: Id[]; init(): Promise; /** Browse an account: the reader's own, or one shared with them. */ @@ -64,9 +64,11 @@ interface FilesState { */ saveText(id: Id, text: string, seenBlobId: Id | null): Promise; move(id: Id, parentId: Id | null): Promise; + /** Move several at once, in one round trip -- see the note on the implementation. */ + moveMany(ids: Id[], parentId: Id | null): Promise; destroy(ids: Id[]): Promise; refresh(ids: Id[]): Promise; - setDragging(id: Id | null): void; + setDragging(ids: Id[]): 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. */ @@ -115,7 +117,7 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] { * accounts. */ export function emptyForAccount(accountId: Id | null) { - return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingId: null, error: null }; + return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingIds: [], error: null }; } export const useFiles = create((set, get) => ({ @@ -130,7 +132,7 @@ export const useFiles = create((set, get) => ({ uploads: [], dirIds: [], treeLoaded: false, - draggingId: null, + draggingIds: [], async init() { const session = useSession.getState(); @@ -276,8 +278,8 @@ 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 }); + setDragging(ids) { + set({ draggingIds: ids }); }, async refresh(ids) { @@ -353,12 +355,27 @@ export const useFiles = create((set, get) => ({ }, async move(id, parentId) { + await get().moveMany([id], parentId); + }, + + /* + * One `FileNode/set` for the lot rather than one per file. + * + * Not only for the round trip: a loop would apply half the moves and then + * throw, leaving a selection split across two folders with nothing saying + * which half went. One call is one answer, and `notUpdated` names whichever + * ones the server refused. + */ + async moveMany(ids, parentId) { + if (!ids.length) return; const accountId = get().accountId!; - const from = get().nodes[id]?.parentId ?? null; - const res = await client.call("FileNode/set", { accountId, update: { [id]: { parentId } } }); - const err = res.notUpdated?.[id]; - if (err) throw new Error(setErrorMessage(err)); - await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]); + const from = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null)); + const update = Object.fromEntries(ids.map((id) => [id, { parentId }])); + const res = await client.call("FileNode/set", { accountId, update }); + const failed = Object.values(res.notUpdated ?? {})[0]; + if (failed) throw new Error(setErrorMessage(failed)); + from.add(parentId); + for (const p of from) await get().loadChildren(p); void get().loadTree(); }, diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 95e0358..dc349c6 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1355,6 +1355,10 @@ button.dp-open:disabled { cursor: default; opacity: .5; } /* 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. */ +/* Shown only when more than one row is selected: with a single row the row + menu already says everything this would. */ +.selection-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; margin: 0 12px 4px; border-radius: var(--radius); background: var(--accent-soft); color: var(--accent-soft-fg); } +.selection-bar .grow { font-weight: 600; } .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; } diff --git a/web/src/views/files/FilesTree.tsx b/web/src/views/files/FilesTree.tsx index f8e721f..b96fda6 100644 --- a/web/src/views/files/FilesTree.tsx +++ b/web/src/views/files/FilesTree.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, P import { useFiles } from "@/store/files"; import { useSession } from "@/store/session"; import type { FileNode, Id } from "@/jmap/types"; -import { canDropFileNode, isShared } from "@/lib/filenode"; +import { canDropFileNodes, NODE_MIME, readDraggedIds, 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"; @@ -34,8 +34,6 @@ async function refreshShares(force = false): Promise { await useFiles.getState().init(); } -/** 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. @@ -66,8 +64,8 @@ export function FilesTree() { /* 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); + const draggingIds = useFiles((s) => s.draggingIds); + const setDragging = useFiles((s) => s.setDragging); useEffect(() => { if (available && !treeLoaded) void loadTree(); @@ -103,12 +101,12 @@ export function FilesTree() { 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 canDropOn = (targetId: Id | null) => canDropFileNodes(nodes, draggingIds, targetId); - const moveTo = async (id: Id, parentId: Id | null) => { - setDraggingId(null); + const moveTo = async (ids: Id[], parentId: Id | null) => { + setDragging([]); try { - await useFiles.getState().move(id, parentId); + await useFiles.getState().moveMany(ids, parentId); if (parentId) setExpanded((x) => ({ ...x, [parentId]: true })); } catch (err) { toast.error((err as Error).message); @@ -132,8 +130,8 @@ export function FilesTree() { 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); + const ids = readDraggedIds(e.dataTransfer); + if (canDropFileNodes(nodes, ids, targetId)) void moveTo(ids, targetId); return; } if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer); @@ -153,13 +151,13 @@ export function FilesTree() { 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)} + onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDragging([d.id]); }} + onDragEnd={() => setDragging([])} onDragOver={onDragOver(d.id)} onDrop={onDrop(d.id)} > diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx index dabc796..7a20953 100644 --- a/web/src/views/files/FilesView.tsx +++ b/web/src/views/files/FilesView.tsx @@ -1,31 +1,34 @@ import { useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; -import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react"; +import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput, X } from "lucide-react"; import { useFiles } from "@/store/files"; import { client } from "@/jmap/client"; import type { FileNode, Id } from "@/jmap/types"; import { formatSize, formatListDate } from "@/lib/format"; -import { canDropFileNode, isShared } from "@/lib/filenode"; +import { canDropFileNodes, isShared, NODE_MIME, readDraggedIds } from "@/lib/filenode"; import { previewKind } from "@/lib/preview"; 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"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview"; import { toast } from "@/ui/toast"; -import { t } from "@/lib/i18n"; +import { plural, t } from "@/lib/i18n"; export function FilesView({ nodeId }: { nodeId?: string }) { const [, navigate] = useLocation(); const files = useFiles(); const parentId = nodeId ?? null; const [dropping, setDropping] = useState(false); - const [selected, setSelected] = useState(null); + /* A set, and the row a shift-click measures from. Kept as ids rather than + indices: the listing reloads under you -- a push, an upload finishing -- + and an index would then point at a different file. */ + const [selection, setSelection] = useState>(() => new Set()); + const [anchor, setAnchor] = useState(null); const menu = useMenu(); const [menuNode, setMenuNode] = useState(null); - const [moveNode, setMoveNode] = useState(null); + const [moveNodes, setMoveNodes] = useState(null); const [shareNode, setShareNode] = useState(null); const [preview, setPreview] = useState(null); /* What the open editor is editing, and the blob its text came from -- the @@ -35,10 +38,27 @@ export function FilesView({ nodeId }: { nodeId?: string }) { const [startInEdit, setStartInEdit] = useState(false); /* 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 draggingIds = files.draggingIds; + const setDragging = files.setDragging; const inputRef = useRef(null); + /* A selection belongs to the folder it was made in. Carrying it across would + leave rows selected that are no longer on screen, and the delete two + folders later would be a surprise. */ + useEffect(() => { + setSelection(new Set()); + setAnchor(null); + }, [parentId, files.accountId]); + + /* Escape drops it, the way it does everywhere else. */ + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") setSelection((cur) => (cur.size ? new Set() : cur)); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + useEffect(() => { if (files.available) void files.loadChildren(parentId); // `accountId` is in here because opening a share changes which account the @@ -86,10 +106,11 @@ export function FilesView({ nodeId }: { nodeId?: string }) { e.stopPropagation(); setDropping(false); 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)); + const ids = readDraggedIds(e.dataTransfer); + setDragging([]); + if (canDropFileNodes(files.nodes, ids, into)) { + setSelection(new Set()); + void files.moveMany(ids, into).catch((err) => toast.error((err as Error).message)); } return; } @@ -119,6 +140,66 @@ export function FilesView({ nodeId }: { nodeId?: string }) { a.click(); }; + /* + * Clicking a row, with the conventions a file manager has taught everyone: + * plain replaces the selection, ctrl/cmd adds or removes one, shift takes + * the run from the last row clicked to this one. The anchor is the row a + * shift measures from, and a plain or toggling click moves it. + */ + const clickRow = (n: FileNode, ev: React.MouseEvent) => { + if (ev.shiftKey && anchor) { + const from = nodes.findIndex((x) => x.id === anchor); + const to = nodes.findIndex((x) => x.id === n.id); + if (from >= 0 && to >= 0) { + const run = nodes.slice(Math.min(from, to), Math.max(from, to) + 1).map((x) => x.id); + setSelection(new Set(ev.ctrlKey || ev.metaKey ? [...selection, ...run] : run)); + return; + } + } + if (ev.ctrlKey || ev.metaKey) { + const next = new Set(selection); + if (next.has(n.id)) next.delete(n.id); + else next.add(n.id); + setSelection(next); + setAnchor(n.id); + return; + } + setSelection(new Set([n.id])); + setAnchor(n.id); + }; + + /* Right-clicking inside the selection acts on all of it; right-clicking + outside it means you meant that row, so the selection follows the pointer + rather than the menu quietly applying to something off-screen. */ + const menuFor = (n: FileNode, at: (x: number, y: number) => void, x: number, y: number) => { + if (!selection.has(n.id)) { + setSelection(new Set([n.id])); + setAnchor(n.id); + } + setMenuNode(n); + at(x, y); + }; + + const selectedNodes = () => nodes.filter((n) => selection.has(n.id)); + /* What the menu and the bar act on: the whole selection when the row is part + of it, and that row alone otherwise. */ + const targets = (n: FileNode | null) => (n && selection.has(n.id) && selection.size > 1 ? selectedNodes() : n ? [n] : selectedNodes()); + + const removeNodes = async (list: FileNode[]) => { + if (!list.length) return; + const title = list.length === 1 + ? t("Delete “{name}”?", { name: list[0]!.name }) + : plural(list.length, { one: "Delete {n} item?", other: "Delete {n} items?" }); + if (!(await confirmDialog({ title, confirmLabel: t("Delete"), danger: true }))) return; + try { + await files.destroy(list.map((n) => n.id)); + setSelection(new Set()); + toast.success(t("Deleted")); + } catch (err) { + toast.error((err as Error).message); + } + }; + /* A file with nothing to show still does what it always did. */ const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null; @@ -160,7 +241,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) { }; return ( -
{ 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}> +
{ if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNodes(files.nodes, draggingIds, parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
@@ -180,9 +261,24 @@ export function FilesView({ nodeId }: { nodeId?: string }) { {files.uploads.map((u) =>
{u.name}{u.error ? {u.error} : {u.progress}%}
)}
)} + {selection.size > 1 && ( +
+ {plural(selection.size, { one: "{n} item selected", other: "{n} items selected" })} + + + +
+ )} {files.error &&
{files.error}
}
{ + if ((e.target as HTMLElement).closest("tr")) return; + setSelection((cur) => (cur.size ? new Set() : cur)); + }} onContextMenu={(e) => { // Only the empty space below the rows: a row has its own menu. if ((e.target as HTMLElement).closest("tr")) return; @@ -200,24 +296,32 @@ export function FilesView({ nodeId }: { nodeId?: string }) { {nodes.map((n) => ( { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }} - onDragEnd={() => setDraggingId(null)} + onDragStart={(e) => { + /* Dragging a row that is part of the selection drags all of + it; dragging one outside the selection means that row. */ + const ids = selection.has(n.id) ? [...selection] : [n.id]; + if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); } + e.dataTransfer.setData(NODE_MIME, ids.join(",")); + e.dataTransfer.effectAllowed = "move"; + setDragging(ids); + }} + onDragEnd={() => setDragging([])} onDragOver={(e) => { if (n.nodeType !== "directory") return; const node = e.dataTransfer.types.includes(NODE_MIME); - if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return; + if (node ? !canDropFileNodes(files.nodes, draggingIds, n.id) : !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = node ? "move" : "copy"; }} onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }} - onClick={() => setSelected(n.id)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}> + onClick={(e) => clickRow(n, e)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); menuFor(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)} - + ))} @@ -231,7 +335,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) { } label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> )} - {menuNode && ( + {menuNode && targets(menuNode).length > 1 && ( + <> + } label={plural(targets(menuNode).length, { one: "Move {n} item…", other: "Move {n} items…" })} onClick={() => setMoveNodes(targets(menuNode))} /> + + } label={plural(targets(menuNode).length, { one: "Delete {n} item", other: "Delete {n} items" })} onClick={() => void removeNodes(targets(menuNode))} /> + + )} + {menuNode && targets(menuNode).length <= 1 && ( <> {menuNode.nodeType === "directory" ? } label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : ( <> @@ -241,14 +352,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) { )} } label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> - } label={t("Move to…")} onClick={() => setMoveNode(menuNode)} /> + } label={t("Move to…")} onClick={() => setMoveNodes([menuNode])} /> } label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> - } label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), confirmLabel: t("Delete"), danger: true })) { try { await files.destroy([menuNode.id]); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } } }} /> + } label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={() => void removeNodes([menuNode])} /> )} - {moveNode && setMoveNode(null)} />} + {moveNodes && setMoveNodes(null)} onMoved={() => setSelection(new Set())} />} { setPreview(null); setEditTarget(null); setStartInEdit(false); }} @@ -260,17 +371,22 @@ export function FilesView({ nodeId }: { nodeId?: string }) { ); } -function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) { +function MoveDialog({ nodes, onClose, onMoved }: { nodes: FileNode[]; onClose: () => void; onMoved: () => void }) { const files = useFiles(); const [cur, setCur] = useState(null); useEffect(() => { void files.loadChildren(cur); // eslint-disable-next-line react-hooks/exhaustive-deps }, [cur]); - const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id)); + /* None of the folders being moved can be their own destination, and neither + can a folder already holding all of them -- "Move here" would be a no-op. */ + const moving = new Set(nodes.map((n) => n.id)); + const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && !moving.has(n.id))); const path = files.pathTo(cur); + const already = nodes.every((n) => (n.parentId ?? null) === cur); + const title = nodes.length === 1 ? t("Move \u201c{name}\u201d", { name: nodes[0]!.name }) : plural(nodes.length, { one: "Move {n} item", other: "Move {n} items" }); return ( - }> + }>
{path.map((n) => )}