Merge pull request #194 from Coffey-Labs/feat/files-multiselect

Select more than one file at a time
This commit is contained in:
Coffey Labs
2026-09-01 21:14:20 -07:00
committed by GitHub
7 changed files with 276 additions and 54 deletions
@@ -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<Id, FileNode> = {
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);
});
});
+21
View File
@@ -54,6 +54,27 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
* legal moves behind a disabled drop. The server refuses those with a message * 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. * 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<Id, FileNode>, draggedIds: Id[], targetId: Id | null): boolean {
return draggedIds.length > 0 && draggedIds.every((id) => canDropFileNode(nodes, id, targetId));
}
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean { export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
const dragged = nodes[draggedId]; const dragged = nodes[draggedId];
if (!dragged) return false; if (!dragged) return false;
@@ -24,7 +24,7 @@ describe("what a switch to another account keeps", () => {
children: {}, children: {},
dirIds: [], dirIds: [],
treeLoaded: false, treeLoaded: false,
draggingId: null, draggingIds: [],
error: null, error: null,
}); });
}); });
@@ -41,14 +41,14 @@ describe("what a switch to another account keeps", () => {
it("drops a drag that was in flight", () => { it("drops a drag that was in flight", () => {
// Its id belongs to the other account and would name a different node here. // 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", () => { it("names every piece of per-account state", () => {
// Add a per-account field to the store and forget it here, and this fails // 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. // rather than the field quietly following someone into another account.
expect(Object.keys(emptyForAccount(null)).sort()).toEqual( expect(Object.keys(emptyForAccount(null)).sort()).toEqual(
["accountId", "children", "dirIds", "draggingId", "error", "nodes", "treeLoaded"], ["accountId", "children", "dirIds", "draggingIds", "error", "nodes", "treeLoaded"],
); );
}); });
}); });
+28 -11
View File
@@ -48,7 +48,7 @@ interface FilesState {
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked * It cannot be read from the drag itself: `dataTransfer.getData` is blocked
* during dragover, which is exactly when the answer is needed. * during dragover, which is exactly when the answer is needed.
*/ */
draggingId: Id | null; draggingIds: Id[];
init(): Promise<void>; init(): Promise<void>;
/** Browse an account: the reader's own, or one shared with them. */ /** 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<Id>; saveText(id: Id, text: string, seenBlobId: Id | null): Promise<Id>;
move(id: Id, parentId: Id | null): Promise<void>; move(id: Id, parentId: Id | null): Promise<void>;
/** Move several at once, in one round trip -- see the note on the implementation. */
moveMany(ids: Id[], parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>; destroy(ids: Id[]): Promise<void>;
refresh(ids: Id[]): Promise<void>; refresh(ids: Id[]): Promise<void>;
setDragging(id: Id | null): void; setDragging(ids: Id[]): void;
/** Every directory in the account, for the tree in the sidebar. */ /** Every directory in the account, for the tree in the sidebar. */
loadTree(): Promise<void>; loadTree(): Promise<void>;
/** Upload a planned drop, creating the folders it needs as it goes. */ /** Upload a planned drop, creating the folders it needs as it goes. */
@@ -115,7 +117,7 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
* accounts. * accounts.
*/ */
export function emptyForAccount(accountId: Id | null) { 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<FilesState>((set, get) => ({ export const useFiles = create<FilesState>((set, get) => ({
@@ -130,7 +132,7 @@ export const useFiles = create<FilesState>((set, get) => ({
uploads: [], uploads: [],
dirIds: [], dirIds: [],
treeLoaded: false, treeLoaded: false,
draggingId: null, draggingIds: [],
async init() { async init() {
const session = useSession.getState(); const session = useSession.getState();
@@ -276,8 +278,8 @@ export const useFiles = create<FilesState>((set, get) => ({
/* Re-read named nodes in place. Sharing changes one property of one node and /* 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 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. */ would be a bigger round trip to land in the same place. */
setDragging(id) { setDragging(ids) {
set({ draggingId: id }); set({ draggingIds: ids });
}, },
async refresh(ids) { async refresh(ids) {
@@ -353,12 +355,27 @@ export const useFiles = create<FilesState>((set, get) => ({
}, },
async move(id, parentId) { 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 accountId = get().accountId!;
const from = get().nodes[id]?.parentId ?? null; const from = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } }); const update = Object.fromEntries(ids.map((id) => [id, { parentId }]));
const err = res.notUpdated?.[id]; const res = await client.call<SetResponse>("FileNode/set", { accountId, update });
if (err) throw new Error(setErrorMessage(err)); const failed = Object.values(res.notUpdated ?? {})[0];
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]); if (failed) throw new Error(setErrorMessage(failed));
from.add(parentId);
for (const p of from) await get().loadChildren(p);
void get().loadTree(); void get().loadTree();
}, },
+4
View File
@@ -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 /* 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 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. */ 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 { 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.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"] { cursor: grab; }
+12 -14
View File
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, P
import { useFiles } from "@/store/files"; import { useFiles } from "@/store/files";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import type { FileNode, Id } from "@/jmap/types"; 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 { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog"; import { confirmDialog, promptDialog } from "@/ui/dialog";
@@ -34,8 +34,6 @@ async function refreshShares(force = false): Promise<void> {
await useFiles.getState().init(); 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. * 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 /* 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. */ the other. See the note on `draggingId` in the store. */
const draggingId = useFiles((s) => s.draggingId); const draggingIds = useFiles((s) => s.draggingIds);
const setDraggingId = useFiles((s) => s.setDragging); const setDragging = useFiles((s) => s.setDragging);
useEffect(() => { useEffect(() => {
if (available && !treeLoaded) void loadTree(); 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 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 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) => { const moveTo = async (ids: Id[], parentId: Id | null) => {
setDraggingId(null); setDragging([]);
try { try {
await useFiles.getState().move(id, parentId); await useFiles.getState().moveMany(ids, parentId);
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true })); if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);
@@ -132,8 +130,8 @@ export function FilesTree() {
e.stopPropagation(); e.stopPropagation();
setRootDrop(false); setRootDrop(false);
if (e.dataTransfer.types.includes(NODE_MIME)) { if (e.dataTransfer.types.includes(NODE_MIME)) {
const id = e.dataTransfer.getData(NODE_MIME); const ids = readDraggedIds(e.dataTransfer);
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId); if (canDropFileNodes(nodes, ids, targetId)) void moveTo(ids, targetId);
return; return;
} }
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer); if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
@@ -153,13 +151,13 @@ export function FilesTree() {
return ( return (
<div key={d.id}> <div key={d.id}>
<div <div
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && canDropOn(d.id) ? "drop-target" : ""}`} className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingIds.length && canDropOn(d.id) ? "drop-target" : ""}`}
style={{ paddingLeft: 8 + depth * 14 }} style={{ paddingLeft: 8 + depth * 14 }}
onClick={() => navigate(`/files/${d.id}`)} onClick={() => navigate(`/files/${d.id}`)}
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }} onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
draggable draggable
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }} onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDragging([d.id]); }}
onDragEnd={() => setDraggingId(null)} onDragEnd={() => setDragging([])}
onDragOver={onDragOver(d.id)} onDragOver={onDragOver(d.id)}
onDrop={onDrop(d.id)} onDrop={onDrop(d.id)}
> >
+142 -26
View File
@@ -1,31 +1,34 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; 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 { useFiles } from "@/store/files";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import type { FileNode, Id } from "@/jmap/types"; import type { FileNode, Id } from "@/jmap/types";
import { formatSize, formatListDate } from "@/lib/format"; 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 { previewKind } from "@/lib/preview";
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload"; import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
import { NODE_MIME } from "./FilesTree";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { Empty, Spinner } from "@/ui/misc"; import { Empty, Spinner } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview"; import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
export function FilesView({ nodeId }: { nodeId?: string }) { export function FilesView({ nodeId }: { nodeId?: string }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const files = useFiles(); const files = useFiles();
const parentId = nodeId ?? null; const parentId = nodeId ?? null;
const [dropping, setDropping] = useState(false); const [dropping, setDropping] = useState(false);
const [selected, setSelected] = useState<string | null>(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<Set<Id>>(() => new Set());
const [anchor, setAnchor] = useState<Id | null>(null);
const menu = useMenu(); const menu = useMenu();
const [menuNode, setMenuNode] = useState<FileNode | null>(null); const [menuNode, setMenuNode] = useState<FileNode | null>(null);
const [moveNode, setMoveNode] = useState<FileNode | null>(null); const [moveNodes, setMoveNodes] = useState<FileNode[] | null>(null);
const [shareNode, setShareNode] = useState<FileNode | null>(null); const [shareNode, setShareNode] = useState<FileNode | null>(null);
const [preview, setPreview] = useState<PreviewFile | null>(null); const [preview, setPreview] = useState<PreviewFile | null>(null);
/* What the open editor is editing, and the blob its text came from -- the /* 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); const [startInEdit, setStartInEdit] = useState(false);
/* Shared with the sidebar tree, so a row dragged onto a folder there is /* Shared with the sidebar tree, so a row dragged onto a folder there is
recognised. See the note on `draggingId` in the store. */ recognised. See the note on `draggingId` in the store. */
const draggingId = files.draggingId; const draggingIds = files.draggingIds;
const setDraggingId = files.setDragging; const setDragging = files.setDragging;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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(() => { useEffect(() => {
if (files.available) void files.loadChildren(parentId); if (files.available) void files.loadChildren(parentId);
// `accountId` is in here because opening a share changes which account the // `accountId` is in here because opening a share changes which account the
@@ -86,10 +106,11 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
e.stopPropagation(); e.stopPropagation();
setDropping(false); setDropping(false);
if (e.dataTransfer.types.includes(NODE_MIME)) { if (e.dataTransfer.types.includes(NODE_MIME)) {
const id = e.dataTransfer.getData(NODE_MIME); const ids = readDraggedIds(e.dataTransfer);
setDraggingId(null); setDragging([]);
if (id && canDropFileNode(files.nodes, id, into)) { if (canDropFileNodes(files.nodes, ids, into)) {
void files.move(id, into).catch((err) => toast.error((err as Error).message)); setSelection(new Set());
void files.moveMany(ids, into).catch((err) => toast.error((err as Error).message));
} }
return; return;
} }
@@ -119,6 +140,66 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
a.click(); 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. */ /* 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; 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 ( return (
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}> <div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { 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}>
<div className="files-toolbar"> <div className="files-toolbar">
<div className="breadcrumb"> <div className="breadcrumb">
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button> <button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
@@ -180,9 +261,24 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
{files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)} {files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)}
</div> </div>
)} )}
{selection.size > 1 && (
<div className="selection-bar">
<span className="grow">{plural(selection.size, { one: "{n} item selected", other: "{n} items selected" })}</span>
<button className="btn btn-sm" onClick={() => setMoveNodes(selectedNodes())}><FolderInput size={16} /> {t("Move to…")}</button>
<button className="btn btn-sm btn-danger" onClick={() => void removeNodes(selectedNodes())}><Trash2 size={16} /> {t("Delete")}</button>
<button className="icon-btn sm" aria-label={t("Clear selection")} title={t("Clear selection")} onClick={() => setSelection(new Set())}><X size={16} /></button>
</div>
)}
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>} {files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
<div <div
className="files-scroll" className="files-scroll"
/* Clicking past the last row clears the selection, the way it does in
every file manager. Rows stop the click from reaching here by
handling it themselves, so this only ever sees the empty space. */
onClick={(e) => {
if ((e.target as HTMLElement).closest("tr")) return;
setSelection((cur) => (cur.size ? new Set() : cur));
}}
onContextMenu={(e) => { onContextMenu={(e) => {
// Only the empty space below the rows: a row has its own menu. // Only the empty space below the rows: a row has its own menu.
if ((e.target as HTMLElement).closest("tr")) return; if ((e.target as HTMLElement).closest("tr")) return;
@@ -200,24 +296,32 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
{nodes.map((n) => ( {nodes.map((n) => (
<tr <tr
key={n.id} key={n.id}
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`} className={`${selection.has(n.id) ? "selected" : ""} ${n.nodeType === "directory" && canDropFileNodes(files.nodes, draggingIds, n.id) ? "drop-target" : ""}`}
draggable draggable
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }} onDragStart={(e) => {
onDragEnd={() => setDraggingId(null)} /* 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) => { onDragOver={(e) => {
if (n.nodeType !== "directory") return; if (n.nodeType !== "directory") return;
const node = e.dataTransfer.types.includes(NODE_MIME); 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = node ? "move" : "copy"; e.dataTransfer.dropEffect = node ? "move" : "copy";
}} }}
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }} 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); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td> <td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td>
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td> <td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td> <td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label={t("Options")}><MoreVertical size={16} /></button></td> <td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); } setMenuNode(n); menu.open(e); }} aria-label={t("Options")}><MoreVertical size={16} /></button></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -231,7 +335,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
<MenuItem icon={<FolderPlus size={16} />} 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); } } }} /> <MenuItem icon={<FolderPlus size={16} />} 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 && (
<>
<MenuItem icon={<FolderInput size={16} />} label={plural(targets(menuNode).length, { one: "Move {n} item…", other: "Move {n} items…" })} onClick={() => setMoveNodes(targets(menuNode))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} 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" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : ( {menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
<> <>
@@ -241,14 +352,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
</> </>
)} )}
<MenuItem icon={<Pencil size={16} />} 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); } } }} /> <MenuItem icon={<Pencil size={16} />} 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); } } }} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} /> <MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNodes([menuNode])} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} /> <MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep /> <MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} 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); } } }} /> <MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={() => void removeNodes([menuNode])} />
</> </>
)} )}
</Popover> </Popover>
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />} {moveNodes && <MoveDialog nodes={moveNodes} onClose={() => setMoveNodes(null)} onMoved={() => setSelection(new Set())} />}
<FilePreviewDialog <FilePreviewDialog
file={preview} file={preview}
onClose={() => { setPreview(null); setEditTarget(null); setStartInEdit(false); }} onClose={() => { 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 files = useFiles();
const [cur, setCur] = useState<string | null>(null); const [cur, setCur] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
void files.loadChildren(cur); void files.loadChildren(cur);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cur]); }, [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 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 ( return (
<Dialog open onClose={onClose} title={t("Move “{name}”", { name: node.name })} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success(t("Moved")); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}> <Dialog open onClose={onClose} title={title} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={already} onClick={async () => { try { await files.moveMany(nodes.map((n) => n.id), cur); toast.success(t("Moved")); onMoved(); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
<div className="breadcrumb mb-8"> <div className="breadcrumb mb-8">
<button onClick={() => setCur(null)}><Home size={14} /></button> <button onClick={() => setCur(null)}><Home size={14} /></button>
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)} {path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}