Sidebar CRUD — Design Spec
Milestone: sidebar-crud
Design-led spec (Testing Constitution). The design lead owns this intent; tests enforce it; code makes the tests pass. One line per behavior; each maps to a pinning test cited by name in the test’s docstring. Status: APPROVED — creative director ratified; shipped.
Intent (the design)
The sidebar IS the library’s file tree. Users create, rename, move, and delete folders and items directly in it, and the tree and the selection stay coherent through every operation and through the live change-stream rebuilds. No operation on a child ever mutates or mis-selects an ancestor.
Behaviors (each → one pinning test)
Retagged 2026-09-18 (a Fabel review, filed as #4696/#4697, checked every [OK] claim
against the code and the test tree — this pass re-verified each one directly, reading the
actual test bodies rather than pattern-matching names, and updated tags/citations to match).
Both issues are already on this milestone (#291); no new issues were needed.
Create
create.item.same-rule— [GAP] (#4697) no dedicated “create item” (non-folder) handler mirrorshandleCreateNewFolder/createFolder’s placement+select rule was found inSidebarCreationHandlers.swift; items appear to enter the tree by other paths (import, workflow/chain/schedule creation, each with its own bespoke selection logic), not a shared “new item follows the same rule as new folder” mechanism. Unverified as described.create.folder.lands-under-context— [PARTIAL] (#4697) two of three claims hold, one doesn’t: placement under the selected folder is correct (SidebarCreationHandlers.swift’shandleCreateNewFoldersetsnewFolderParentIdfrom the selection,createFolderhonors it — an earlier fix landed for “previously existed but was never read”), and the new folder IS selected (selectedItemId = "doc:\(newFolder.id)"). But it does NOT enter rename mode — it shows a name-entry DIALOG (showingNewFolderDialog) before creation, andcreateFoldernever expands the new parent afterward, so the freshly-selected row can be invisible (unexpanded) — the “phantom selection” #4697 describes. No test covers any of this (#4697: zero sidebar UI tests exist).
Read
read.tree.shows-all— [GAP] (#4697) a rendering claim (every non-deleted child of an expanded folder is shown); no test — pure SwiftUIListrendering, and #4697 confirms zero sidebar UI/XCUITest files exist to pin it.read.expand.persists— [GAP] (#4697) no test asserts expand/collapse state or selection survives a change-stream rebuild.SidebarExpandSubtreeTestspins what gets DESCENDED/cached on expand/collapse, not whether that state survives a rebuild.read.disclosure-triangle-visible-without-selection— [GAP] (#3355) a folder/PDF with children must show its disclosure triangle as soon as it renders, not only after the user clicks it — today the triangle (and so the existence of nested children) is invisible until the row has been selected at least once, which reads as “no children” for anything not yet clicked. Distinct fromread.tree.shows-all(which is about an EXPANDED folder’s children rendering) — this is about the disclosure affordance existing before expansion is even attempted.read.filter-never-hides-selection— [GAP] (#4099) applying a sidebar filter must never hide the currently-selected row, even when the row no longer matches the filter text — losing the selected row out from under the user reads as the app forgetting what was open. No filter mechanism/test found for this specifically; distinct from the delete/rebuild selection-resilience behaviors above, which cover deletion and rebuild gaps, not filtering.
Update — rename
rename.in-place— [GAP] (#4697)SidebarItemRow+Rename.swift’scommitRenamedoes commit on the editor’s current text andcancelRename()on failure paths, but no test drives Return-commits / Esc-cancels — it’s SwiftUI row state, and #4697 confirms zero sidebar UI tests exist.rename.rejects-empty— [GAP] (#4697)commitRenamedoesguard !newName.isEmpty else { renameState.cancelRename(); return }— the guard is real, but untested; same zero-UI-tests gap.
Update — move / reparent
move.onto-folder-reparents— [PARTIAL] (#4697) the backend contract is proven —TestDocumentMoveAction.test_move_effect_audit_and_undo(fichero-server/tests/unit/api/test_document_actions.py) assertsparent_idactually changes and both parents’child_countevents fire — but the client’s drag-drop wiring (SidebarItemRow+DropHandlers.swift’sprocessFolderDropItem→ the move dispatch) that triggers it from an actual sidebar drag has no test (zero sidebar UI tests, #4697).move.onto-nonfolder-rejected— [OK]handleDropIntoFolder(SidebarItemRow+DropHandlers.swift:192-195) guardstargetFolder.folderKindand refuses (returnsfalse, no-op) when the target isn’t a folder;folderKindreturnsnilfor a non-folder target. Pinned:SidebarItemFactoryTests.folderKindDocumentFile.move.cross-library-rejected-not-silently-dropped— [GAP] (#2397) dragging an item from one open library’s sidebar tree onto another open library has no supported outcome today — neither a cross-library move nor an explicit, honest refusal; the drop appears to simply do nothing. Distinct frommove.onto-nonfolder-rejected/.no-cycle(which cover in-library drop targets) — this is about the SOURCE and TARGET being different libraries entirely, which the move policy doesn’t appear to consider at all yet.move.no-cycle— [OK]SidebarMovePolicy.isValidTarget(SidebarItemRow+Helpers.swift:8-25) walks the target’s ancestor chain and refuses when the source appears in it (self, direct child, or deep descendant), bounded against a malformed cyclic chain. Pinned:SidebarMovePolicyTests(client); the backend independently rejects the same cases —TestDocumentMoveAction.test_move_into_self_rejected,TestDocumentMoveAction.test_move_into_descendant_rejected(fichero-server/tests/unit/api/test_document_actions.py).
Library table columns
sidebar.table-columns-not-compiler-limited— [PARTIAL] (#4482, legacy milestone fold, 2026-09-19) the Library table’s column set should be a product decision, not a side effect of SwiftUI’sTableColumnBuildercapping at 10 direct children. Verified at HEAD:LibraryView+TableColumns.swift’s own comments confirm the cap was real and is now worked around by grouping columns to fit —modifiedDateandsizeare both back (restored,customizationIDs"modifiedDate"/"size"present). Two of the original four dropped columns stay deliberately excluded, not forgotten:path(a local path is a lie on any host but the one that has it — the no-local-paths rule) andartifacts(overlaps the six per-type columns already shown; “wants a decision” per the code’s own comment, not a bug). PARTIAL, not OK: the arity limit itself is solved and two real columns are back, but theartifactsquestion is still open and no test was found pinning either the restored columns or the arity workaround itself.
Delete
delete.subtree-only— [PARTIAL] (#4697) deleting a folder cascades to exactly its descendants — proven byTestDeleteDocument.test_delete_soft_deletes_children(fichero-server/tests/unit/api/test_routes_documents.py), which asserts both the parent AND child end up soft-deleted from one parent-delete call. The INVERSE direction this behavior also claims — deleting a CHILD never touches an ancestor — is not directly asserted by any test (the algorithm only ever descends, so it’s structurally unlikely to regress, but “unlikely by construction” isn’t “pinned”).delete.selection-safe— [PARTIAL] (#4805, #4697) the “never resurrected” half is real and tested:SidebarView.droppedRowIsMomentarilyMissingtreats a just-deleted row as gone, not a momentary rebuild gap, so it can’t be resurrected into the selection. Pinned:SidebarSelectionResilienceTests.testRecentlyDeletedRowIsNotResurrected. The “moves deterministically to a safe target — parent, else sibling, else clears” half is FALSE as written:SidebarActions.swift:177always clears (selectedItemId = nil); there is no parent/sibling fallback in the code today. Decided (manager, under the standing Finder-like principle): the spec’s claim stands and the code is the bug — selection moves to the next sibling, else the previous, else the parent, else clears. Tracked by #4805.delete.multi— [BROKEN] (#4696) “no whole-tree flash” does not hold while #4696’s H2 stands:SidebarActions.swift:224(and:85) calldocumentStore.refresh()unconditionally per item in a batch-delete loop — a wholesaleloadCollectionsper deleted item, not the one-splice-then-one-rebuild the behavior promises.delete.trash-browsable— [GAP] (#2077) a deleted item is reachable in a browsable Trash surface (restore, or purge permanently) and soft-delete extends beyond Document to claim/ entity/annotation/note/workflow. Verified: the backend routes exist for Document only (GET /trash,POST /{doc_id}/restore,DELETE /{doc_id}/purge—documents.py:739,1585, 1601); no Swift Trash view exists (grep -rl "TrashView" fichero/fichero/Views= empty) and no other type has the soft-delete/restore/purge pattern. This is the FRONTEND-and-other-types remainderdelete.subtree-only/.selection-safe/.multiabove don’t cover — those pin the Document-delete MECHANICS; this pins whether a deleted item can be found and undone again.
Legacy milestone fold — three issues redirected from “UX - Library & Reading Surface”
While folding library-view-modes.md’s pass 2, three sidebar-structure issues from that
legacy milestone were re-read against this spec instead — sidebar row mechanics and the
sidebar’s own code health, not a Library view-mode question:
sidebar.drag-session-consistent-across-row— [GAP] (#713) dragging a row’s icon/name versus dragging elsewhere on the row body should produce the SAME drag session inside aDisclosureGroup— today they diverge. Not verified as built.sidebar.code-structure-consolidated— [GAP] (#585)SidebarItemRowshould split and the sidebar’s several state managers should consolidate — a code-health ask, not a user-facing behavior, kept here as a GAP so it stays tracked rather than lost when its milestone folds.sidebar.accessibility-pass— [GAP] (#584) the sidebar has zero VoiceOver/accessibility coverage today (issue’s own claim, not independently re-verified this pass).
First worked example (this PR — the delete behaviors)
Root cause (scoped): SidebarView.droppedRowIsMomentarilyMissing (the function this section
originally called sidebarResilientSelection — corrected 2026-09-18 to the name in the
actual code)
(fichero/…/Sidebar/Sections/SidebarView+ViewComponents.swift) treats a just-deleted
row as “momentarily missing” (indistinguishable from a lazy-rebuild gap) and re-adds
it to the selection, which then routes to the parent. Fix: a dedicated
recentlyDeletedDocumentIds signal on DocumentStore (set in removeDocuments), so
the resilience filter drops a genuinely-deleted row instead of resurrecting it.
Tests that pin the two delete behaviors:
1. Unit (pure): droppedRowIsMomentarilyMissing — a dropped id that is recently-deleted
is NOT resurrected; a dropped id that is merely a rebuild gap IS kept. Pinned:
SidebarSelectionResilienceTests.testRecentlyDeletedRowIsNotResurrected /
.testRebuildGapRowIsStillKept. (delete.selection-safe)
2. Store unit: applying a document.deleted change drops those ids in place
(delete.subtree-only local half) — proven by
ObservableDomainStoreTests.testApplyDeletedRemovesRowsInPlaceAcrossListsAndCache;
whether it ALSO records them into recentlyDeletedDocumentIds specifically is not
directly asserted (2026-09-18 re-check — see delete.subtree-only’s [PARTIAL] tag above).
3. UX (broad, Decision 9): delete a child folder in the sidebar → assert the
PARENT row still exists and is not the resurrected selection. This UX test does not
exist (2026-09-18 re-check, #4697: zero sidebar UI/XCUITest files exist) — the spec
promised it as “UX test 3” but it was never written.
Everything above the Delete section is the larger design, pinned in later waves — NOT this PR.