Nest labels, and let each one say how prominent it is

A flat list is fine at five labels and unreadable at thirty, and there was
no way to keep one that matters occasionally without it holding a row for
ever.

A label can now sit under another, and each says whether it belongs in the
sidebar always, only while it has unread mail, or never.

Nesting is display only. The keywords stay flat on the message, which is
what keeps them readable by every other client: moving a label under
another rewrites nothing in the mailbox, and a client that knows nothing
about ihasmail sees exactly what it always did. Both new fields are
optional, so a settings file written before this parses unchanged and
means what it did.

Settings sync between devices, so the tree has to survive shapes that
should not exist. A label whose parent was deleted on another device comes
back to the top level rather than vanishing -- a label that disappears
because something else was deleted is one the reader cannot get back. A
cycle arriving from an older device is broken by treating the label that
closes the loop as a root, so nothing is lost and nothing hangs. The
parent picker will not offer a label's own descendants, so one cannot be
built here in the first place.

A label kept by the unread rule keeps its ancestors, whatever they were
set to. A child cannot be drawn under a parent that is not there, and
promoting it to the top level would silently rearrange the tree at the
moment the reader is least able to explain why. The parent comes back as a
container instead, and its own count still says whether it has anything of
its own.

Unread counts come from one request carrying a query per label rather than
a request each, with limit 0 so the server does not send ids that would
only be thrown away. They refresh on the same beat as the folder counts,
since the things that move them are the same things, and a failure is
swallowed: a count is decoration, and the sidebar draws the label without
one.

Also corrects the Labels page, which said names and colours are kept in
this browser. They live in the account's own Files and follow it between
devices, like every other setting that is not about this screen.
This commit is contained in:
2026-09-01 23:09:21 -07:00
parent d300107be0
commit f7ef886b45
7 changed files with 389 additions and 8 deletions
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/labelTree";
import type { Label } from "@/store/settings";
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
const flat = (labels: Label[], counts: Record<string, number> = {}) =>
visibleLabels(labelTree(labels, counts)).map((n) => `${" ".repeat(n.depth)}${n.label.keyword}`);
describe("labelTree", () => {
it("nests a label under its parent and indents it", () => {
const roots = labelTree([L("work"), L("work_urgent", { parent: "work" })]);
expect(roots).toHaveLength(1);
expect(roots[0]!.label.keyword).toBe("work");
expect(roots[0]!.children[0]!.label.keyword).toBe("work_urgent");
expect(roots[0]!.children[0]!.depth).toBe(1);
});
it("nests three deep", () => {
expect(flat([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" })])).toEqual(["a", " b", " c"]);
});
it("puts a label back at the top when its parent no longer exists", () => {
// Settings sync between devices; a parent can be deleted on one while
// another still points at it. Dropping the child would lose it for good.
expect(flat([L("orphan", { parent: "gone" })])).toEqual(["orphan"]);
});
it("survives a cycle rather than hanging", () => {
const out = flat([L("a", { parent: "b" }), L("b", { parent: "a" })]);
expect(out).toHaveLength(2);
expect(out.map((s) => s.trim()).sort()).toEqual(["a", "b"]);
});
it("survives a label parented to itself", () => {
expect(flat([L("a", { parent: "a" })])).toEqual(["a"]);
});
it("carries each label's own unread count, not its children's", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" })], { a: 2, b: 5 });
expect(roots[0]!.unread).toBe(2);
expect(roots[0]!.children[0]!.unread).toBe(5);
});
});
describe("visibleLabels", () => {
it("draws everything set to always", () => {
expect(flat([L("a"), L("b")])).toEqual(["a", "b"]);
});
it("never draws a hidden label", () => {
expect(flat([L("a"), L("b", { visibility: "hidden" })], { b: 9 })).toEqual(["a"]);
});
it("draws an unread-only label just while it has unread mail", () => {
const labels = [L("a", { visibility: "unread" })];
expect(flat(labels, { a: 0 })).toEqual([]);
expect(flat(labels, { a: 1 })).toEqual(["a"]);
});
it("keeps a parent that would otherwise be dropped, when a child survives", () => {
// A child cannot be drawn under a parent that is not there, and promoting
// it would silently rearrange the tree. The parent comes back as a
// container instead.
const labels = [L("work", { visibility: "unread" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, { work: 0 })).toEqual(["work", " work_urgent"]);
});
it("keeps a hidden parent too, when a child survives", () => {
const labels = [L("work", { visibility: "hidden" }), L("work_urgent", { parent: "work" })];
expect(flat(labels, {})).toEqual(["work", " work_urgent"]);
});
it("drops a whole branch when nothing in it survives", () => {
const labels = [
L("work", { visibility: "unread" }),
L("work_urgent", { parent: "work", visibility: "unread" }),
L("other"),
];
expect(flat(labels, { work: 0, work_urgent: 0 })).toEqual(["other"]);
});
it("keeps a grandparent when only a grandchild survives", () => {
const labels = [
L("a", { visibility: "hidden" }),
L("b", { parent: "a", visibility: "hidden" }),
L("c", { parent: "b" }),
];
expect(flat(labels, {})).toEqual(["a", " b", " c"]);
});
it("treats a label with no visibility set as always, so old settings parse unchanged", () => {
const l = L("a");
expect(l.visibility).toBeUndefined();
expect(flat([l], {})).toEqual(["a"]);
});
});
describe("descendantKeywords", () => {
it("names everything below a label, so the parent picker cannot offer a cycle", () => {
const roots = labelTree([L("a"), L("b", { parent: "a" }), L("c", { parent: "b" }), L("d")]);
expect([...descendantKeywords(roots, "a")].sort()).toEqual(["b", "c"]);
expect([...descendantKeywords(roots, "d")]).toEqual([]);
});
it("says nothing about a label that is not there", () => {
expect([...descendantKeywords(labelTree([L("a")]), "missing")]).toEqual([]);
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Labels arranged into a tree, and which of them the sidebar draws.
*
* **Nesting is display only.** The keywords stay flat on the message, which is
* what keeps them readable by every other client — moving a label under
* another rewrites nothing in the mailbox, and a client that knows nothing
* about ihasmail still sees the same keywords it always did.
*/
import type { Label, LabelVisibility } from "@/store/settings";
export interface LabelNode {
label: Label;
/** 0 at the top; only ever used to indent. */
depth: number;
children: LabelNode[];
/** Unread messages carrying this keyword. Its own, not its children's. */
unread: number;
}
const visibilityOf = (l: Label): LabelVisibility => l.visibility ?? "always";
/**
* Build the tree.
*
* Two malformed shapes have to survive, because settings sync between devices
* and a label can be deleted on one while another is still pointing at it:
*
* - **A parent that no longer exists** puts its child back at the top level
* rather than dropping it. A label that vanishes from the sidebar because
* something else was deleted is a label the reader cannot get back.
* - **A cycle** — a under b, b under a — is broken by treating the first
* label that closes the loop as a root. Nothing is lost and nothing hangs.
*/
export function labelTree(labels: Label[], counts: Record<string, number> = {}): LabelNode[] {
const byKeyword = new Map<string, Label>();
for (const l of labels) byKeyword.set(l.keyword, l);
/** Whether following `parent` from here reaches a real root without looping. */
const rooted = (l: Label): boolean => {
const seen = new Set<string>([l.keyword]);
let cur = l.parent ? byKeyword.get(l.parent) : undefined;
while (cur) {
if (seen.has(cur.keyword)) return false;
seen.add(cur.keyword);
cur = cur.parent ? byKeyword.get(cur.parent) : undefined;
}
return true;
};
const nodes = new Map<string, LabelNode>();
for (const l of labels) nodes.set(l.keyword, { label: l, depth: 0, children: [], unread: counts[l.keyword] ?? 0 });
const roots: LabelNode[] = [];
for (const l of labels) {
const node = nodes.get(l.keyword)!;
const parent = l.parent && l.parent !== l.keyword && rooted(l) ? nodes.get(l.parent) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const setDepth = (n: LabelNode, depth: number) => {
n.depth = depth;
for (const c of n.children) setDepth(c, depth + 1);
};
for (const r of roots) setDepth(r, 0);
return roots;
}
/**
* The nodes the sidebar draws, flattened in the order they appear.
*
* `hidden` removes a label outright. `unread` shows it only while it has
* unread mail — which is the point of it: a label you filed something under
* two years ago should not take up a row for ever.
*
* **A label kept by the rule keeps its ancestors, whatever they said.** A
* child cannot be drawn under a parent that is not there; the alternative is
* promoting it to the top level, which silently rearranges the tree at the
* moment the reader is least able to explain why. The parent comes back as a
* container, and its own count still says whether it has anything of its own.
*/
export function visibleLabels(roots: LabelNode[]): LabelNode[] {
const keep = new Set<LabelNode>();
const walk = (n: LabelNode): boolean => {
// Depth-first: a node's fate depends on its descendants, not the reverse.
let keptChild = false;
for (const c of n.children) keptChild = walk(c) || keptChild;
const v = visibilityOf(n.label);
const self = v === "always" || (v === "unread" && n.unread > 0);
if (self || keptChild) {
keep.add(n);
return true;
}
return false;
};
for (const r of roots) walk(r);
const out: LabelNode[] = [];
const emit = (n: LabelNode) => {
if (!keep.has(n)) return;
out.push(n);
for (const c of n.children) emit(c);
};
for (const r of roots) emit(r);
return out;
}
/**
* Keywords that would become unreachable if `keyword` were re-parented under
* `candidate` — used to keep the parent picker from offering a cycle.
*/
export function descendantKeywords(roots: LabelNode[], keyword: string): Set<string> {
const out = new Set<string>();
const find = (n: LabelNode): LabelNode | null => {
if (n.label.keyword === keyword) return n;
for (const c of n.children) {
const hit = find(c);
if (hit) return hit;
}
return null;
};
let node: LabelNode | null = null;
for (const r of roots) {
node = find(r);
if (node) break;
}
if (!node) return out;
const collect = (n: LabelNode) => {
for (const c of n.children) {
out.add(c.label.keyword);
collect(c);
}
};
collect(node);
return out;
}