diff --git a/web/src/lib/__tests__/folderMove.test.ts b/web/src/lib/__tests__/folderMove.test.ts new file mode 100644 index 0000000..14d1585 --- /dev/null +++ b/web/src/lib/__tests__/folderMove.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { canDropFolder, descendantIds, movable } from "../folderMove"; +import type { Id, Mailbox } from "@/jmap/types"; + +const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox => + ({ id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] }); + +/** root ── Work ── Clients ── EU + * └─ Archive (role) + * └─ Inbox (role) */ +const tree: Record = Object.fromEntries([ + mb("inbox", "Inbox", null, "inbox"), + mb("arch", "Archive", null, "archive"), + mb("work", "Work", null), + mb("clients", "Clients", "work"), + mb("eu", "EU", "clients"), + mb("news", "Newsletters", null), +].map((m) => [m.id, m])); + +describe("movable", () => { + it("refuses folders the server gave a role", () => { + expect(movable(tree.inbox!)).toBe(false); + expect(movable(tree.arch!)).toBe(false); + expect(movable(tree.work!)).toBe(true); + }); +}); + +describe("descendantIds", () => { + it("finds the whole subtree, not just the children", () => { + expect([...descendantIds(tree, "work")].sort()).toEqual(["clients", "eu"]); + expect([...descendantIds(tree, "eu")]).toEqual([]); + }); +}); + +describe("canDropFolder", () => { + it("allows a plain move into another folder", () => { + expect(canDropFolder(tree, "news", "work")).toBe(true); + expect(canDropFolder(tree, "eu", "news")).toBe(true); + }); + + it("allows a move into a role folder, which may hold subfolders", () => { + expect(canDropFolder(tree, "news", "arch")).toBe(true); + }); + + it("refuses to move a folder into itself or its own subtree", () => { + expect(canDropFolder(tree, "work", "work")).toBe(false); + expect(canDropFolder(tree, "work", "clients")).toBe(false); + expect(canDropFolder(tree, "work", "eu")).toBe(false); // grandchild, not just child + }); + + it("refuses a move to the parent it already has", () => { + expect(canDropFolder(tree, "clients", "work")).toBe(false); + }); + + it("refuses to move a role folder anywhere", () => { + expect(canDropFolder(tree, "inbox", "work")).toBe(false); + expect(canDropFolder(tree, "arch", null)).toBe(false); + }); + + it("handles the root: allowed from a parent, refused when already there", () => { + expect(canDropFolder(tree, "eu", null)).toBe(true); + expect(canDropFolder(tree, "news", null)).toBe(false); + }); + + it("refuses a target that does not exist", () => { + expect(canDropFolder(tree, "news", "gone")).toBe(false); + expect(canDropFolder(tree, "gone", "work")).toBe(false); + }); +}); diff --git a/web/src/lib/folderMove.ts b/web/src/lib/folderMove.ts new file mode 100644 index 0000000..f5e81b7 --- /dev/null +++ b/web/src/lib/folderMove.ts @@ -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: Id): Set { + const out = new Set(); + const all = Object.values(mailboxes); + let frontier = new Set([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(); + 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, 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); +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 8b8edd7..d380190 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -323,6 +323,9 @@ img { max-width: 100%; } .nav-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 650; } .nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; } .nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; } +.nav-item.folder-row.dragging { opacity: .45; } +/* The Folders heading doubles as the way back to the top level while dragging. */ +.nav-section.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); color: var(--accent-soft-fg); } .nav-item svg { flex: 0 0 auto; color: var(--fg-muted); } .nav-item.active svg { color: inherit; } .nav-item .nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; } diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index ca755e5..bf0119a 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -10,6 +10,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; import { loadRaw, saveJson } from "@/lib/storage"; +import { canDropFolder, movable } from "@/lib/folderMove"; const ROLE_ICONS: Record = { inbox: , @@ -23,6 +24,9 @@ const ROLE_ICONS: Record = { important: , }; +/** Its own drag type, so a folder can only be dropped where folders belong. */ +const FOLDER_MIME = "application/x-ihasmail-folder"; + export function MailboxTree() { const mailboxes = useMail((s) => s.mailboxes); const loaded = useMail((s) => s.mailboxesLoaded); @@ -34,6 +38,32 @@ export function MailboxTree() { const menu = useMenu(); const [menuTarget, setMenuTarget] = useState(null); const [shareTarget, setShareTarget] = useState(null); + /** + * The folder being dragged. Held here rather than read from the drag itself: + * dataTransfer.getData is blocked during dragover, so a row cannot ask what + * is over it, and every row needs to know whether it is a legal target. + */ + const [draggingId, setDraggingId] = useState(null); + const [rootDrop, setRootDrop] = useState(false); + /** Whether the folder in flight may be dropped on this folder, or on the root. */ + const canDropOn = (targetId: Id | null): boolean => Boolean(draggingId) && canDropFolder(mailboxes, draggingId!, targetId); + + const moveFolder = async (id: Id, parentId: Id | null) => { + const m = mailboxes[id]; + setDraggingId(null); + try { + await useMail.getState().updateMailbox(id, { parentId }); + // Show where it landed rather than leaving it hidden in a closed parent. + if (parentId) { + const next = { ...expanded, [parentId]: true }; + setExpanded(next); + saveJson("mbx-expanded", next); + } + toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`); + } catch (err) { + toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`); + } + }; // Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and // collapsed by default. Expansion state is remembered per folder. @@ -93,14 +123,46 @@ export function MailboxTree() { return ( <>