Move a folder by dragging it

Reparenting a folder meant the Folders settings page, or nothing at all.
The tree already accepted messages dropped onto a folder, so folders now
travel the same way: drag one onto another to nest it, or onto the
Folders heading to bring it back to the top level.

The heading says "Drop here for the top level" while a folder is in
flight, because an unlabelled strip of heading is not a discoverable
target. The row being dragged fades, the row under the pointer is
outlined, and only rows that would accept the drop light up.

Four drops are refused: a folder onto itself, into its own subtree,
onto the parent it already has, and any folder the server gave a role,
which is not draggable in the first place. The subtree case is the one
that matters -- it would orphan the branch -- and it checks the whole
subtree rather than the immediate children.

Whether a drop is legal has to be known during dragover, when
dataTransfer.getData is blocked, so the tree remembers what is being
dragged rather than asking the drag.

The move goes through updateMailbox, so the filter rules pointing at the
folder follow it, and the target folder is expanded afterwards so the
folder can be seen where it landed.
This commit is contained in:
2026-08-25 12:36:09 -07:00
parent 418d3fafce
commit bc4bad8466
4 changed files with 203 additions and 6 deletions
+47
View File
@@ -0,0 +1,47 @@
import type { Id, Mailbox } from "@/jmap/types";
/**
* A folder can be moved unless the server gave it a role. Inbox, Sent, Trash and
* the rest are structural, and the server refuses to reparent them anyway --
* better not to offer the drag at all.
*/
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> {
const out = new Set<Id>();
const all = Object.values(mailboxes);
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.
for (let depth = 0; depth < 20 && frontier.size; depth++) {
const next = new Set<Id>();
for (const m of all) {
if (m.parentId && frontier.has(m.parentId) && !out.has(m.id)) {
out.add(m.id);
next.add(m.id);
}
}
frontier = next;
}
return out;
}
/**
* Whether `draggedId` may be dropped on `targetId`, where null means the root.
*
* Four ways it cannot: the folder is not movable at all, it is being dropped on
* itself, into its own subtree — which would orphan the branch — or onto the
* parent it already has, which would be a no-op dressed up as a move.
*/
export function canDropFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id | null): boolean {
const dragged = mailboxes[draggedId];
if (!dragged || !movable(dragged)) return false;
if (targetId === null) return dragged.parentId != null;
if (targetId === draggedId) return false;
if (dragged.parentId === targetId) return false;
if (!mailboxes[targetId]) return false;
return !descendantIds(mailboxes, draggedId).has(targetId);
}