Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.
There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.
The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.
Two things the writing of this turned up.
The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.
And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.
Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.
Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
68 lines
3.1 KiB
TypeScript
68 lines
3.1 KiB
TypeScript
/**
|
|
* FileNode shapes, as Stalwart 0.16 defines them.
|
|
*
|
|
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
|
|
* in ways the server does not report: `nodeType` did not exist and sending it
|
|
* failed the create outright, `FileNode/query` masked directories out of its
|
|
* own results, and rights were a single `mayWrite` rather than the four
|
|
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
|
|
* 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[] {
|
|
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
|
|
}
|
|
|
|
/** Create-arguments for a directory. */
|
|
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
|
|
return { parentId, name, nodeType: "directory" };
|
|
}
|
|
|
|
/** Create-arguments for a file with an already-uploaded blob. */
|
|
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
|
return { parentId, name, blobId, type, nodeType: "file" };
|
|
}
|
|
|
|
/**
|
|
* Whether a node is shared with anyone.
|
|
*
|
|
* Stalwart answers `shareWith` as `{}` for "nobody", not `null` — confirmed
|
|
* against 0.16.19 on 2026-08-27, where every unshared node in the account came
|
|
* back that way. So a truthiness test passes for every node ever returned, and
|
|
* a badge driven by one would say the whole account is shared. Count the keys.
|
|
*/
|
|
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);
|
|
}
|