From 907d6f9a0fe4925a877efacbb6418bdcae002e23 Mon Sep 17 00:00:00 2001 From: Andrei Hajdukewycz Date: Tue, 16 Jun 2026 23:26:12 +0000 Subject: [PATCH] Collapse and expand folder subtrees in the folder list, starting collapsed. (Fixes #40) Each folder with children now shows a disclosure toggle; the tree starts fully collapsed and tracks expand/collapse state per folder, so toggling a branch does not change the selected folder and selecting a folder does not change its state. Adds FolderTree unit coverage and documents the behavior as R-2.13 in the MVP spec. --- specs/001-mvp-scope/spec.md | 1 + src/components/FolderNode.vue | 137 +++++++++++++----- src/components/FolderTree.vue | 22 +++ tests/unit/components/folder-tree.test.ts | 161 ++++++++++++++++++++++ tests/unit/components/message-dnd.test.ts | 4 +- 5 files changed, 286 insertions(+), 39 deletions(-) create mode 100644 tests/unit/components/folder-tree.test.ts diff --git a/specs/001-mvp-scope/spec.md b/specs/001-mvp-scope/spec.md index f70ba2a..2587baf 100644 --- a/specs/001-mvp-scope/spec.md +++ b/specs/001-mvp-scope/spec.md @@ -66,6 +66,7 @@ capability. | R-2.10 🟧 Planned | The system shall use route-backed browser navigation for app spaces, folders, and opened messages so refresh, direct links, and the browser back and forward buttons preserve the expected mail context. Folder URLs shall use human-readable names where possible, and message URLs shall use stable server identifiers rather than local cache row ids. | | R-2.11 🟩 Done | When an opened message references inline images by `cid:`, the system shall resolve them for display rather than leaving a broken reference: it shall download the referenced message part through the authenticated worker transport and rewrite the `cid:` reference to an inline `data:` URL inside the same sanitization pass (set via the DOM so the value cannot break out of the attribute). Resolution shall apply only to parts of the message being viewed and only to references the body actually uses. Both resolved inline images and author-embedded `data:` images shall be restricted to a raster image allowlist (PNG, JPEG, GIF, WebP, BMP, AVIF, ICO); SVG and non-image `data:` payloads shall be stripped, since DOMPurify cannot inspect bytes inside a `data:` URL. The HTML body shall continue to render inside the sandboxed, script-free iframe of R-2.3. | | R-2.12 🟩 Done | When the app connects or reconnects to the server — including a returning sign-in (R-1.3) that first paints the mailbox window from the previous session's local cache — the system shall reconcile its active mailbox windows against the server before treating the cached list as current, so newly-delivered mail and messages removed elsewhere appear without a manual refresh (R-3.8). The reconciliation shall use JMAP query-state deltas (`Email/queryChanges`, falling back to a full window rebuild when the server reports it cannot calculate changes) for the most-recently-accessed mailbox windows, and shall always include the Inbox window even when the user last viewed other folders, because the Inbox is the folder opened by default on load. The reconciliation shall be resilient to failures of auxiliary startup syncs: an error fetching identities or contacts shall not prevent the mailbox windows from being reconciled. This requirement covers the connect/reconnect moment; R-3.9 covers the steady-state online case while the user is already connected. | +| R-2.13 🟩 Done | In the folder hierarchy (R-2.1), each folder that has subfolders shall present a disclosure control that expands or collapses its child folders, and the hierarchy shall start fully collapsed so only top-level mailboxes are shown until the user expands a branch. Toggling a folder's disclosure control shall not change the selected folder, and selecting a folder shall not change its expand/collapse state. Each folder's expand/collapse state shall be tracked independently, so collapsing an ancestor hides its subtree without discarding a descendant's retained expanded state. | ### 3. Triage diff --git a/src/components/FolderNode.vue b/src/components/FolderNode.vue index 6dda4ee..94bb5c2 100644 --- a/src/components/FolderNode.vue +++ b/src/components/FolderNode.vue @@ -5,6 +5,8 @@ const props = defineProps({ folder: { type: Object, required: true }, currentFolderId: { type: [Number, String, null], default: null }, onPick: { type: Function, required: true }, + isCollapsed: { type: Function, required: true }, + onToggle: { type: Function, required: true }, dropState: { type: Function, default: null }, onFolderDragEnter: { type: Function, default: null }, onFolderDragOver: { type: Function, default: null }, @@ -15,6 +17,8 @@ const props = defineProps({ const current = computed(() => props.currentFolderId === props.folder.id); const unread = computed(() => Number(props.folder.unread_emails) || 0); const iconSvg = computed(() => props.folder.icon); +const hasChildren = computed(() => (props.folder.children?.length ?? 0) > 0); +const collapsed = computed(() => hasChildren.value && props.isCollapsed(props.folder.id)); const indent = computed(() => `${10 + (props.folder.depth ?? 0) * 16}px`); const style = computed(() => ({ paddingLeft: indent.value, @@ -27,11 +31,14 @@ const showIndexProgress = computed(() => && indexPercent.value < 100, ); const dropStateValue = computed(() => props.dropState?.(props.folder) ?? null); + +function toggle() { + props.onToggle(props.folder.id); +}