feat(lists): list folders (/api/folders) with a client-side cycle guard - #180
Merged
Merged
Conversation
An entirely missing domain. The app implements DOCUMENT folders
(/api/documents/folders) but not LIST folders — a separate resource with
different endpoints and ids, despite the similar shape.
All four operations verified live 2026-09-16 against throwaway folders, since
deleted and the account confirmed restored:
GET /api/folders -> 200 {"folders":[{id,name,parentId}, …]} // FLAT
POST /api/folders -> 201 {"message":"Folder created successfully",
"folder":{id,name,parentId}}
PUT /api/folders/{id} -> 200 {"message":"Folder updated successfully","folder":{…}}
DELETE /api/folders/{id} -> 200
The payload is exactly {id, name, parentId} — no timestamps, no userId, and no
server-side nesting. The tree is built client-side from parentId.
*** The important finding: the server does NOT guard against cycles. ***
Moving a folder into its own child returns 200 and creates a genuine loop —
verified by doing it (and then repairing it). Nothing server-side prevents this,
so a tree renderer walking parentId would recurse forever.
Two defences, because one isn't enough:
1. InterlinedApiClient.WouldCreateCycle walks the ancestor chain before any
move and refuses it client-side. This is not defensive politeness; it is the
only thing standing between a move and an unrenderable tree.
2. The tree BUILD is itself cycle-tolerant — nodes are placed at most once and
anything left over sets HasOrphanedFolders, so data that already contains a
loop (possible, since the server allows it) shows a message explaining how
to repair it instead of hanging or silently dropping folders.
The guard is bounded by the folder count, so it terminates even when the input
already contains a cycle — verified (0ms on a 2-node loop).
Also verified, and relevant to #179: folderId is NOT among the eleven fields
POST /api/lists accepts, so a list cannot be created straight into a folder.
PUT /api/lists/{id} with {"folderId": …} does work (200, reflected on re-read)
and {"folderId": null} clears it — exposed as SetListFolderAsync.
Create is subscriber-gated; 402/403 renders as "Creating list folders is a
Subscriber feature" rather than a bare status code. Delete cascades to
subfolders, which the endpoint name doesn't convey, so it is two-step and the
armed label says "Delete this and its subfolders?".
Additive only: new model, new InterlinedApiClient.ListFolders.cs partial, and a
self-contained ListFolderPanel with its own ViewModel. ListsView is being
reworked across #161/#163/#164, so hosting is a one-line add afterward.
Verified by harness: the live payload parses; the cycle guard blocks
into-own-child / into-deep-descendant / into-itself and permits
up-the-tree / unrelated-branch / to-root / unknown-parent; and it terminates on
pre-looped data. Build green in Debug and Release.
Closes #63
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63
An entirely missing domain. The app implements document folders (
/api/documents/folders) but not list folders — a separate resource with different endpoints and ids, despite the similar shape.All four operations verified live against throwaway folders (since deleted, account confirmed restored):
The payload is exactly
{id, name, parentId}— no timestamps, nouserId, no server-side nesting. The tree is built client-side.Moving a folder into its own child returns
200and creates a genuine loop. I verified it by doing exactly that:(Then repaired, and both folders deleted.) Nothing server-side prevents this, so a tree renderer walking
parentIdwould recurse forever.Two defences, because one isn't enough:
WouldCreateCyclewalks the ancestor chain before any move and refuses it client-side. Not defensive politeness — it's the only thing standing between a move and an unrenderable tree.HasOrphanedFoldersand the panel says "Some folders form a loop and aren't shown. Move one of them to the top level to fix it." Because the server permits cycles, the data can genuinely already contain one — hanging or silently dropping folders would both be wrong.The guard is bounded by the folder count, so it terminates even on already-looped input.
Harness results
Also verified — relevant to #179
folderIdis not among the eleven fieldsPOST /api/listsaccepts, so a list cannot be created straight into a folder.PUT /api/lists/{id}with{"folderId": …}does work (200, reflected on re-read) and{"folderId": null}clears it — exposed asSetListFolderAsync.I found this by assigning the account's real
New listto a probe folder, which I then had to undo. It's restored (folderId: None), confirmed by a closingGET.Smaller decisions
402/403renders as "Creating list folders is a Subscriber feature" rather than a bare status code.parentIdis omitted rather than sent asnullon create, since an explicitnullis a500on some writers on this API (Settings: "Save profile" has never worked — it sendstheme: nulland gets HTTP 500 #171).Additive only
New model, new
InterlinedApiClient.ListFolders.cspartial, self-containedListFolderPanel.ListsViewis being reworked across #161/#163/#164, so hosting is a one-line add afterward:dotnet build -c Debug— green.dotnet build -c Release— green.🤖 Generated with Claude Code