Merge pull request #92 from LINUXexpert-org/files-tree
A folder tree, and dragging things into it
This commit is contained in:
@@ -721,7 +721,15 @@ const handlers: Record<string, Handler> = {
|
||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||
// directories and was handed files, which it then drew as folders.
|
||||
const list = fileNodes.filter((n) => {
|
||||
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||
return true;
|
||||
});
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": genericGet(fileNodes),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
* gets wrong if you take it at face value.
|
||||
*
|
||||
* `readEntries` answers with *up to* some number of entries and signals the end
|
||||
* of a directory with an empty array, so a single call quietly loses everything
|
||||
* past the first batch — a real folder of a few hundred files would upload the
|
||||
* first hundred and look like it had finished. And a directory tree that cycles
|
||||
* has to stop somewhere the tab is still alive.
|
||||
*/
|
||||
|
||||
const file = (name: string) => new File([name], name);
|
||||
|
||||
/** A directory whose contents arrive a batch at a time, as a real one does. */
|
||||
const dir = (name: string, children: unknown[], batch = 2) => {
|
||||
let at = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb: (e: never[]) => void) => {
|
||||
const slice = children.slice(at, at + batch);
|
||||
at += slice.length;
|
||||
cb(slice as never[]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const leaf = (name: string) => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (cb: (f: File) => void) => cb(file(name)),
|
||||
});
|
||||
|
||||
describe("walking a dropped folder", () => {
|
||||
it("reads a directory across as many batches as it takes", async () => {
|
||||
// Five children, two per readEntries call: a single read would find two.
|
||||
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
|
||||
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the folder each file came from", async () => {
|
||||
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
|
||||
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
|
||||
["outer", "top"],
|
||||
["outer/inner", "deep"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a loose file at the drop itself", async () => {
|
||||
const plan = await planUpload([leaf("loose")] as never[]);
|
||||
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
|
||||
});
|
||||
|
||||
it("stops rather than following a cycle for ever", async () => {
|
||||
const loop: Record<string, unknown> = {};
|
||||
Object.assign(loop, dir("loop", []));
|
||||
(loop as { createReader: () => unknown }).createReader = () => ({
|
||||
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
|
||||
});
|
||||
// Terminating at all is the assertion; the caps decide where. Both are set
|
||||
// low so the test does not have to read twenty thousand phantom entries.
|
||||
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
|
||||
expect(plan).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a plan needs", () => {
|
||||
it("lists parents before their children", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a", "b", "c"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("names each folder once, however many files are in it", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("asks for nothing when everything lands at the drop", () => {
|
||||
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotting a folder in the drop", () => {
|
||||
it("is true when any entry is a directory", () => {
|
||||
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
|
||||
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDropFileNode } from "@/lib/filenode";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Dragging a folder into its own subtree is the move that has to be refused
|
||||
* rather than reported: the server would orphan the branch, and the folder the
|
||||
* reader was dragging would leave the tree with everything under it.
|
||||
*/
|
||||
|
||||
const rights = (over: Partial<FileNode["myRights"]> = {}) => ({
|
||||
mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true, ...over,
|
||||
});
|
||||
|
||||
/** a > b > c, plus a file in a and a second top-level folder. */
|
||||
const tree = (): Record<Id, FileNode> => {
|
||||
const mk = (id: string, parentId: string | null, nodeType: "directory" | "file", over: Partial<FileNode> = {}) =>
|
||||
({ id, parentId, nodeType, name: id, myRights: rights(), ...over }) as FileNode;
|
||||
return {
|
||||
a: mk("a", null, "directory"),
|
||||
b: mk("b", "a", "directory"),
|
||||
c: mk("c", "b", "directory"),
|
||||
other: mk("other", null, "directory"),
|
||||
doc: mk("doc", "a", "file"),
|
||||
};
|
||||
};
|
||||
|
||||
describe("what a folder may be dropped on", () => {
|
||||
it("allows a move to an unrelated folder", () => {
|
||||
expect(canDropFileNode(tree(), "a", "other")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a drop on itself", () => {
|
||||
expect(canDropFileNode(tree(), "a", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a drop into its own subtree, however deep", () => {
|
||||
expect(canDropFileNode(tree(), "a", "b")).toBe(false);
|
||||
expect(canDropFileNode(tree(), "a", "c")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the parent it already has, which is a no-op dressed as a move", () => {
|
||||
expect(canDropFileNode(tree(), "b", "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a child up to the top level, but not one already there", () => {
|
||||
expect(canDropFileNode(tree(), "b", null)).toBe(true);
|
||||
expect(canDropFileNode(tree(), "a", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("targets that cannot take it", () => {
|
||||
it("refuses a file as a target", () => {
|
||||
expect(canDropFileNode(tree(), "b", "doc")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a folder that will not take children", () => {
|
||||
const t = tree();
|
||||
t.other = { ...t.other!, myRights: rights({ mayAddChildren: false }) };
|
||||
expect(canDropFileNode(t, "a", "other")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a target that is not there at all", () => {
|
||||
expect(canDropFileNode(tree(), "a", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a file to be moved like anything else", () => {
|
||||
expect(canDropFileNode(tree(), "doc", "other")).toBe(true);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -9,6 +9,7 @@
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { descendantIds } from "./folderMove";
|
||||
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
@@ -36,3 +37,31 @@ export function fileCreate(parentId: Id | null, name: string, blobId: Id, type:
|
||||
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
||||
return Object.keys(node.shareWith ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the node being dragged may be dropped on `targetId`, null being the
|
||||
* top level.
|
||||
*
|
||||
* The same four refusals as folders: onto itself, into its own subtree, onto
|
||||
* the parent it already has, or -- for the top level -- when it is already
|
||||
* there. `descendantIds` is shared with the mailbox tree, since both are the
|
||||
* same shape of tree asking the same question.
|
||||
*
|
||||
* Rights are deliberately only half-checked. A target that will not take
|
||||
* children is refused here, because that is unambiguous. Whether the node may
|
||||
* leave the parent it is in is not: JMAP models a move as an update of
|
||||
* `parentId` and does not say which right covers it, and guessing would hide
|
||||
* legal moves behind a disabled drop. The server refuses those with a message
|
||||
* of its own, which is a better answer than a silent one.
|
||||
*/
|
||||
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
||||
const dragged = nodes[draggedId];
|
||||
if (!dragged) return false;
|
||||
if (targetId === null) return dragged.parentId != null;
|
||||
if (targetId === draggedId) return false;
|
||||
if (dragged.parentId === targetId) return false;
|
||||
const target = nodes[targetId];
|
||||
if (!target || target.nodeType !== "directory") return false;
|
||||
if (target.myRights && !target.myRights.mayAddChildren) return false;
|
||||
return !descendantIds(nodes, draggedId).has(targetId);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ export function movable(m: Mailbox): boolean {
|
||||
return !m.role || m.role === "subscribed";
|
||||
}
|
||||
|
||||
/** Every folder beneath this one, so a folder cannot be dropped inside itself. */
|
||||
export function descendantIds(mailboxes: Record<Id, Mailbox>, id: Id): Set<Id> {
|
||||
/**
|
||||
* Every node beneath this one, so a node cannot be dropped inside itself.
|
||||
*
|
||||
* Written against `{ id, parentId }` rather than `Mailbox` because file nodes
|
||||
* form the same shape of tree and need the same answer -- see `canDropFileNode`.
|
||||
*/
|
||||
export function descendantIds<T extends { id: Id; parentId: Id | null }>(tree: Record<Id, T>, id: Id): Set<Id> {
|
||||
const out = new Set<Id>();
|
||||
const all = Object.values(mailboxes);
|
||||
const all = Object.values(tree);
|
||||
let frontier = new Set<Id>([id]);
|
||||
// Depth is bounded by the server's own mailbox depth limit; the guard is only
|
||||
// here so a cycle in the data cannot spin forever.
|
||||
// Depth is bounded by the server's own depth limit; the guard is only here so
|
||||
// a cycle in the data cannot spin forever.
|
||||
for (let depth = 0; depth < 20 && frontier.size; depth++) {
|
||||
const next = new Set<Id>();
|
||||
for (const m of all) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
||||
import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
|
||||
import { isAppFolder } from "@/lib/appFolder";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
@@ -13,6 +14,22 @@ interface FilesState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
|
||||
dirIds: Id[];
|
||||
treeLoaded: boolean;
|
||||
/*
|
||||
* The node being dragged, if any.
|
||||
*
|
||||
* Kept here rather than in whichever pane started the drag, because a drag
|
||||
* crosses between them -- a row dragged onto the sidebar tree, a folder in
|
||||
* the tree dragged onto a row -- and every possible target has to know what
|
||||
* is in flight to say whether it will take it. Two panes each holding their
|
||||
* own copy meant the one that did not start the drag never lit up and never
|
||||
* accepted the drop.
|
||||
*
|
||||
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
|
||||
* during dragover, which is exactly when the answer is needed.
|
||||
*/
|
||||
draggingId: Id | null;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadChildren(parentId: Id | null): Promise<void>;
|
||||
@@ -22,6 +39,11 @@ interface FilesState {
|
||||
move(id: Id, parentId: Id | null): Promise<void>;
|
||||
destroy(ids: Id[]): Promise<void>;
|
||||
refresh(ids: Id[]): Promise<void>;
|
||||
setDragging(id: Id | null): 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. */
|
||||
uploadPlan(parentId: Id | null, plan: PlannedUpload[]): Promise<void>;
|
||||
pathTo(id: Id | null): FileNode[];
|
||||
applyChanges(types: Set<string>): void;
|
||||
}
|
||||
@@ -61,6 +83,9 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
uploads: [],
|
||||
dirIds: [],
|
||||
treeLoaded: false,
|
||||
draggingId: null,
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
@@ -69,6 +94,44 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
set({ available });
|
||||
},
|
||||
|
||||
/*
|
||||
* The whole directory tree in one query.
|
||||
*
|
||||
* `filter: { nodeType: "directory" }` returns every folder in the account,
|
||||
* confirmed against 0.16.19 on 2026-08-27, so the sidebar tree is complete
|
||||
* from the first paint: expanding costs nothing, and a drag knows every
|
||||
* folder it could be dropped on without having opened it first.
|
||||
*
|
||||
* It is deliberately its own request rather than a call appended to another.
|
||||
* A filter Stalwart refuses fails with a request-level 400 that takes every
|
||||
* method call in the request with it -- `{ parentId: null }` does exactly
|
||||
* that -- so a tree query batched alongside the folder listing would blank
|
||||
* the whole view instead of just the sidebar.
|
||||
*/
|
||||
async loadTree() {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
|
||||
// Filtered again here rather than trusted: a server that ignores the
|
||||
// nodeType filter answers with files as well, and the tree would draw
|
||||
// them as folders you could open into nothing.
|
||||
const dirs = withoutAppFolder(g.list).filter((n) => n.nodeType === "directory");
|
||||
set((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const n of dirs) nodes[n.id] = n;
|
||||
return { nodes, dirIds: dirs.map((n) => n.id), treeLoaded: true };
|
||||
});
|
||||
} catch (err) {
|
||||
// The listing still works without a tree, so this must not blank the view.
|
||||
set({ error: (err as Error).message, treeLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
async loadChildren(parentId) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId) return;
|
||||
@@ -103,6 +166,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const err = res.notCreated?.d;
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await get().loadChildren(parentId);
|
||||
void get().loadTree();
|
||||
return res.created!.d!.id;
|
||||
},
|
||||
|
||||
@@ -133,6 +197,10 @@ export const useFiles = create<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 });
|
||||
},
|
||||
|
||||
async refresh(ids) {
|
||||
const accountId = get().accountId;
|
||||
if (!accountId || !ids.length) return;
|
||||
@@ -144,12 +212,36 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
async uploadPlan(parentId, plan) {
|
||||
// Folders first, parents before children, so every file has somewhere to go.
|
||||
const dirIds = new Map<string, Id | null>([["", parentId]]);
|
||||
for (const path of foldersNeeded(plan)) {
|
||||
const parent = dirIds.get(path.slice(0, -1).join(" ")) ?? parentId;
|
||||
const name = path[path.length - 1]!;
|
||||
try {
|
||||
dirIds.set(path.join(" "), await get().mkdir(parent, name));
|
||||
} catch (err) {
|
||||
// Leave it unmapped: its files land in the nearest folder that exists
|
||||
// rather than vanishing, and the error is shown against the upload.
|
||||
set({ error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
const byFolder = new Map<string, File[]>();
|
||||
for (const item of plan) {
|
||||
const key = item.path.join(" ");
|
||||
byFolder.set(key, [...(byFolder.get(key) ?? []), item.file]);
|
||||
}
|
||||
for (const [key, files] of byFolder) await get().upload(dirIds.get(key) ?? parentId, files);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async rename(id, name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await get().loadChildren(get().nodes[id]?.parentId ?? null);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async move(id, parentId) {
|
||||
@@ -159,6 +251,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
async destroy(ids) {
|
||||
@@ -168,6 +261,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
const failed = Object.values(res.notDestroyed ?? {})[0];
|
||||
if (failed) throw new Error(setErrorMessage(failed));
|
||||
for (const p of parents) await get().loadChildren(p);
|
||||
void get().loadTree();
|
||||
},
|
||||
|
||||
pathTo(id) {
|
||||
|
||||
@@ -1014,3 +1014,13 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.dp-split { flex-direction: column; }
|
||||
.dp-times { flex-direction: row; overflow-x: auto; max-height: none; border-left: 0; border-top: 1px solid var(--border); padding: 6px 0 0; }
|
||||
}
|
||||
|
||||
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
|
||||
no equivalent for are here. A row in the list is a drop target the same way a
|
||||
folder in the tree is, and says so the same way. */
|
||||
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
|
||||
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.files-table tbody tr[draggable="true"] { cursor: grab; }
|
||||
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
|
||||
.sidebar .nav-item[draggable="true"] { cursor: pointer; }
|
||||
.f-name .faint { flex: none; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { MailboxTree } from "./mail/MailboxTree";
|
||||
import { FilesTree } from "./files/FilesTree";
|
||||
import { CalendarSidebar } from "./calendar/CalendarSidebar";
|
||||
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
@@ -128,7 +129,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
{section === "calendar" && <CalendarSidebar />}
|
||||
{section === "contacts" && <div className="nav-section"><span>Contacts</span></div>}
|
||||
{section === "files" && <div className="nav-section"><span>Files</span></div>}
|
||||
{section === "files" && <FilesTree />}
|
||||
{section === "settings" && <div className="nav-section"><span>Settings</span></div>}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { useFiles } from "@/store/files";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
|
||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
/**
|
||||
* The folder tree beside the file list.
|
||||
*
|
||||
* Every directory in the account arrives in one query, so this never waits on
|
||||
* an expand and a drag always knows every folder it could land on -- including
|
||||
* ones the reader has never opened.
|
||||
*/
|
||||
export function FilesTree() {
|
||||
const [location, navigate] = useLocation();
|
||||
const nodes = useFiles((s) => s.nodes);
|
||||
const dirIds = useFiles((s) => s.dirIds);
|
||||
const treeLoaded = useFiles((s) => s.treeLoaded);
|
||||
const available = useFiles((s) => s.available);
|
||||
const loadTree = useFiles((s) => s.loadTree);
|
||||
// Kept across sessions, the way the mailbox tree keeps its own.
|
||||
const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {}));
|
||||
const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; });
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||
const [rootDrop, setRootDrop] = useState(false);
|
||||
const menu = useMenu();
|
||||
|
||||
/* Shared with the list pane: a drag starting in one has to be recognised by
|
||||
the other. See the note on `draggingId` in the store. */
|
||||
const draggingId = useFiles((s) => s.draggingId);
|
||||
const setDraggingId = useFiles((s) => s.setDragging);
|
||||
|
||||
useEffect(() => {
|
||||
if (available && !treeLoaded) void loadTree();
|
||||
}, [available, treeLoaded, loadTree]);
|
||||
|
||||
const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null;
|
||||
|
||||
// Open the branch the reader is looking at, so the current folder is visible
|
||||
// without them having to find it.
|
||||
useEffect(() => {
|
||||
if (!currentId) return;
|
||||
const open: Record<Id, boolean> = {};
|
||||
for (let id: Id | null | undefined = nodes[currentId]?.parentId; id; id = nodes[id]?.parentId) open[id] = true;
|
||||
if (Object.keys(open).length) setExpanded((x) => ({ ...x, ...open }));
|
||||
}, [currentId, nodes]);
|
||||
|
||||
if (!available) return null;
|
||||
|
||||
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
|
||||
const canDropOn = (targetId: Id | null) => Boolean(draggingId) && canDropFileNode(nodes, draggingId!, targetId);
|
||||
|
||||
const moveTo = async (id: Id, parentId: Id | null) => {
|
||||
setDraggingId(null);
|
||||
try {
|
||||
await useFiles.getState().move(id, parentId);
|
||||
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
/** Files dropped from outside land in the folder they were dropped on. */
|
||||
const dropFiles = async (parentId: Id | null, dt: DataTransfer) => {
|
||||
const entries = entriesFromDrop(dt);
|
||||
const flat = Array.from(dt.files);
|
||||
if (entries.length && hasDirectory(entries)) {
|
||||
const plan = await planUpload(entries);
|
||||
if (plan.length) await useFiles.getState().uploadPlan(parentId, plan);
|
||||
return;
|
||||
}
|
||||
if (flat.length) await useFiles.getState().upload(parentId, flat);
|
||||
};
|
||||
|
||||
const onDrop = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRootDrop(false);
|
||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||
const id = e.dataTransfer.getData(NODE_MIME);
|
||||
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId);
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
|
||||
};
|
||||
|
||||
const onDragOver = (targetId: Id | null) => (e: React.DragEvent) => {
|
||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||
if (node ? !canDropOn(targetId) : !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||
};
|
||||
|
||||
const row = (d: FileNode, depth: number) => {
|
||||
const kids = childrenOf(d.id);
|
||||
const open = Boolean(expanded[d.id]);
|
||||
return (
|
||||
<div key={d.id}>
|
||||
<div
|
||||
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && 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)}
|
||||
onDragOver={onDragOver(d.id)}
|
||||
onDrop={onDrop(d.id)}
|
||||
>
|
||||
<button
|
||||
className="nav-twisty"
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
style={{ visibility: kids.length ? "visible" : "hidden" }}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((x) => ({ ...x, [d.id]: !open })); }}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{open && kids.length ? <FolderOpen size={17} /> : <Folder size={17} />}
|
||||
<span className="grow truncate">{d.name}</span>
|
||||
{isShared(d) && <Share2 size={12} className="faint" aria-label="Shared" />}
|
||||
</div>
|
||||
{open && kids.map((k) => row(k, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>Files</span></div>
|
||||
<div
|
||||
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
|
||||
onClick={() => navigate("/files")}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(null); menu.openAt(e.clientX, e.clientY); }}
|
||||
onDragOver={(e) => { onDragOver(null)(e); if (!e.defaultPrevented) return; setRootDrop(true); }}
|
||||
onDragLeave={() => setRootDrop(false)}
|
||||
onDrop={onDrop(null)}
|
||||
>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<HardDrive size={17} />
|
||||
<span className="grow truncate">All files</span>
|
||||
</div>
|
||||
{childrenOf(null).map((d) => row(d, 1))}
|
||||
{treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>No folders yet.</p>}
|
||||
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
|
||||
<MenuItem
|
||||
icon={<FolderPlus size={16} />}
|
||||
label="New folder"
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
|
||||
if (menuNode) setExpanded((x) => ({ ...x, [menuNode.id]: true }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{menuNode && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<Pencil size={16} />}
|
||||
label="Rename"
|
||||
disabled={!menuNode.myRights?.mayRename}
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename", defaultValue: menuNode.name });
|
||||
if (!name?.trim() || name === menuNode.name) return;
|
||||
try {
|
||||
await useFiles.getState().rename(menuNode.id, name.trim());
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
label="Delete"
|
||||
disabled={!menuNode.myRights?.mayDelete}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
try {
|
||||
await useFiles.getState().destroy([menuNode.id]);
|
||||
if (currentId === menuNode.id) navigate("/files");
|
||||
toast.success("Deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { useFiles } from "@/store/files";
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { isShared } from "@/lib/filenode";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { NODE_MIME } from "./FilesTree";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
@@ -22,6 +24,10 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
||||
recognised. See the note on `draggingId` in the store. */
|
||||
const draggingId = files.draggingId;
|
||||
const setDraggingId = files.setDragging;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,13 +57,37 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
const nodes = ids.map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||
const path = files.pathTo(parentId);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
/* A drop lands in `into`, which is the folder under the pointer when there is
|
||||
one and the folder being listed otherwise. Entries have to be read out
|
||||
before the first await -- the list is emptied the moment the handler
|
||||
returns -- so that happens here, synchronously, for every path. */
|
||||
const dropOnto = (into: string | null, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropping(false);
|
||||
const list = Array.from(e.dataTransfer.files);
|
||||
if (list.length) void files.upload(parentId, list);
|
||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||
const id = e.dataTransfer.getData(NODE_MIME);
|
||||
setDraggingId(null);
|
||||
if (id && canDropFileNode(files.nodes, id, into)) {
|
||||
void files.move(id, into).catch((err) => toast.error((err as Error).message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
const entries = entriesFromDrop(e.dataTransfer);
|
||||
const flat = Array.from(e.dataTransfer.files);
|
||||
void (async () => {
|
||||
if (entries.length && hasDirectory(entries)) {
|
||||
const plan = await planUpload(entries);
|
||||
if (plan.length) await files.uploadPlan(into, plan);
|
||||
return;
|
||||
}
|
||||
if (flat.length) await files.upload(into, flat);
|
||||
})();
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => dropOnto(parentId, e);
|
||||
|
||||
const download = (n: FileNode) => {
|
||||
if (!n.blobId) return;
|
||||
const a = document.createElement("a");
|
||||
@@ -67,7 +97,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} 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) && canDropFileNode(files.nodes, draggingId ?? "", 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>
|
||||
@@ -88,7 +118,16 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
</div>
|
||||
)}
|
||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||
<div className="files-scroll">
|
||||
<div
|
||||
className="files-scroll"
|
||||
onContextMenu={(e) => {
|
||||
// Only the empty space below the rows: a row has its own menu.
|
||||
if ((e.target as HTMLElement).closest("tr")) return;
|
||||
e.preventDefault();
|
||||
setMenuNode(null);
|
||||
menu.openAt(e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
{files.loading && !nodes.length ? <Spinner /> : !nodes.length ? (
|
||||
<Empty icon={<FolderOpen size={40} />} title="This folder is empty">Drag files here or use Upload.</Empty>
|
||||
) : (
|
||||
@@ -96,7 +135,22 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
<thead><tr><th>Name</th><th className="hide-mobile">Size</th><th className="hide-mobile">Modified</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<tr
|
||||
key={n.id}
|
||||
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onDragOver={(e) => {
|
||||
if (n.nodeType !== "directory") return;
|
||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||
if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||
}}
|
||||
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
||||
onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<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="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>
|
||||
@@ -108,6 +162,12 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
)}
|
||||
</div>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={200}>
|
||||
{!menuNode && (
|
||||
<>
|
||||
<MenuItem icon={<Upload size={16} />} label="Upload files…" onClick={() => inputRef.current?.click()} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label="New folder" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
|
||||
|
||||
Reference in New Issue
Block a user