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
* 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 {
const dragged = nodes[draggedId];
if (!dragged) return false;
@@ -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"],
);
});
});
+28 -11
View File
@@ -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<void>;
/** 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>;
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>;
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. */
loadTree(): Promise<void>;
/** 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<FilesState>((set, get) => ({
@@ -130,7 +132,7 @@ export const useFiles = create<FilesState>((set, get) => ({
uploads: [],
dirIds: [],
treeLoaded: false,
draggingId: null,
draggingIds: [],
async init() {
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
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<FilesState>((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<SetResponse>("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<SetResponse>("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();
},
+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
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; }
+12 -14
View File
@@ -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<void> {
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 (
<div key={d.id}>
<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 }}
onClick={() => navigate(`/files/${d.id}`)}
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
draggable
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }}
onDragEnd={() => setDraggingId(null)}
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)}
>
+142 -26
View File
@@ -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<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 [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 [preview, setPreview] = useState<PreviewFile | null>(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<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(() => {
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 (
<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="breadcrumb">
<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>)}
</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>}
<div
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) => {
// 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) => (
<tr
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
onDragStart={(e) => { 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); }}>
<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">{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>
))}
</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); } } }} />
</>
)}
{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}`)} /> : (
<>
@@ -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={<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)} />
<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>
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
{moveNodes && <MoveDialog nodes={moveNodes} onClose={() => setMoveNodes(null)} onMoved={() => setSelection(new Set())} />}
<FilePreviewDialog
file={preview}
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 [cur, setCur] = useState<string | null>(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 (
<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">
<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>)}