List folders in sidebar order in the move-to picker

The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.

treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.

Closes #1
This commit is contained in:
2026-09-21 08:25:58 -07:00
parent 5b353d1e54
commit ea03406646
4 changed files with 119 additions and 4 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf, treeOrder } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
@@ -119,3 +119,23 @@ describe("neighbour", () => {
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
});
});
describe("treeOrder", () => {
const ids = (all: Record<Id, Mailbox>) => treeOrder(all).map((m) => m.id);
it("lists the tree the way the sidebar does, each folder followed by its subfolders", () => {
expect(ids(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "clients", "zeta"]);
});
it("follows a saved order rather than A–Z", () => {
// #1 on GitLab: the move-to picker kept the old order after the sidebar changed.
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(ids(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work", "clients"]);
});
it("still lists a folder the walk from the top can't reach", () => {
const looped = apply(fresh, { work: { parentId: "clients" } });
expect(ids(looped)).toHaveLength(Object.keys(looped).length);
expect(ids(looped)).toEqual(expect.arrayContaining(["work", "clients"]));
});
});
+31
View File
@@ -25,6 +25,37 @@ function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
}
/**
* Every folder, parents before their children and siblings in
* `compareFolders` order: the sidebar's order with every folder expanded.
* Lists that show all folders at once, like the move-to picker, use this so a
* folder sits where the user dragged it rather than where A–Z would put it.
*
* A folder the walk from the top never reaches (a parent loop the server
* should not allow) is appended rather than dropped, so it can still be
* picked.
*/
export function treeOrder(mailboxes: Record<Id, Mailbox>): Mailbox[] {
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of Object.values(mailboxes)) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
for (const list of byParent.values()) list.sort(compareFolders);
const out: Mailbox[] = [];
const seen = new Set<Id>();
const walk = (parent: Id | null) => {
for (const m of byParent.get(parent) ?? []) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
walk(m.id);
}
};
walk(null);
return out.concat(Object.values(mailboxes).filter((m) => !seen.has(m.id)).sort(compareFolders));
}
/** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes)