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

Open
opened 2026-09-02 06:09:37 +00:00 by jcoffey-dev · 0 comments
Owner

A flat list is fine at five labels and unreadable at thirty, and there was no way to keep a label 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. 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.json written before this parses unchanged and means what it did — no migration.

Shapes that should not exist, but will

Settings sync between devices, so the tree has to survive being wrong:

  • A parent deleted elsewhere puts its child back at the top level rather than dropping it. 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 — nothing lost, nothing hung.
  • The parent picker will not offer a label's own descendants, so one cannot be built here in the first place.

The decision worth reviewing

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.

The alternative — promote orphans — is defensible, and if you prefer it the change is one branch in visibleLabels.

Unread counts

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. Refreshed on the same beat as the folder counts, since the things that move them are the same things. A failure is swallowed: a count is decoration, and the sidebar draws the label without one.

Also fixed

The Labels page 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.

Testing

16 unit tests over the tree and visibility rules: three-deep nesting, the missing parent, both cycle shapes, per-label counts, each visibility value, ancestors retained through one and two levels, a whole branch dropping when nothing survives, absent visibility meaning always, and the descendant set the picker uses.

Suite green: web 733 across 80 files, server 110, typecheck clean.

Verified in the running app

Both new pickers render; nesting indents correctly (12px → 26px); the parent picker excludes descendants; and setting a label to "only when unread" with zero unread removes it from the sidebar while its sibling stays.

One thing worth knowing for anyone testing this against the mock: labels do not survive a full page reload there, because the mock cannot persist settings and labels sync to Files. labelsSidebar does survive, because sidebar state is a local per-device setting. Use in-app navigation, not a reload.

Merged 2026-09-01 as coffey-labs/ihasmail@60d275f38b

Rebuilt from: git history, session transcript.

A flat list is fine at five labels and unreadable at thirty, and there was no way to keep a label 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. 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.json` written before this parses unchanged and means what it did — no migration. ## Shapes that should not exist, but will Settings sync between devices, so the tree has to survive being wrong: - **A parent deleted elsewhere** puts its child back at the top level rather than dropping it. 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 — nothing lost, nothing hung. - **The parent picker will not offer a label's own descendants**, so one cannot be built here in the first place. ## The decision worth reviewing **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. The alternative — promote orphans — is defensible, and if you prefer it the change is one branch in `visibleLabels`. ## Unread counts 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. Refreshed on the same beat as the folder counts, since the things that move them are the same things. A failure is swallowed: a count is decoration, and the sidebar draws the label without one. ## Also fixed The Labels page 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. ## Testing 16 unit tests over the tree and visibility rules: three-deep nesting, the missing parent, both cycle shapes, per-label counts, each visibility value, ancestors retained through one and two levels, a whole branch dropping when nothing survives, absent visibility meaning always, and the descendant set the picker uses. Suite green: web 733 across 80 files, server 110, typecheck clean. ## Verified in the running app Both new pickers render; nesting indents correctly (12px → 26px); the parent picker excludes descendants; and setting a label to "only when unread" with zero unread removes it from the sidebar while its sibling stays. One thing worth knowing for anyone testing this against the mock: **labels do not survive a full page reload there**, because the mock cannot persist settings and labels sync to Files. `labelsSidebar` does survive, because sidebar state is a local per-device setting. Use in-app navigation, not a reload. **Merged** 2026-09-01 as coffey-labs/ihasmail@60d275f38bb0 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.