Skip to content

feat(bin): explicit cascade deletion — user-confirmed cleanup of orphans (JS-9809)#2288

Merged
requilence merged 4 commits into
developfrom
js-cascade-deletion-orphans
Jul 10, 2026
Merged

feat(bin): explicit cascade deletion — user-confirmed cleanup of orphans (JS-9809)#2288
requilence merged 4 commits into
developfrom
js-cascade-deletion-orphans

Conversation

@requilence

Copy link
Copy Markdown
Contributor

Client implementation of GO-7323. Backend lives on anytype-heart@go-7323-cascade-deletion-orphan-events; brief is at docs/GO-7323-cascade-deletion-client-impl.md on that branch.

⚠️ Do not merge before the backend does. This branch requires the new event + RPCs. Regenerate protos from the heart branch, not from develop.

What changed

The backend no longer archives, restores or deletes anything implicitly when you archive, delete, or unlink an object. Every orphan it used to sweep up — nested objects at any depth, and files at any depth, including direct children — is now reported via Object.CleanupSuggestion, and the client decides what to do with them.

Protocol

  • Handle CleanupSuggestion (mapper + dispatcher).
  • Retire ObjectAutoArchive / ObjectAutoRestore. They remain in the proto but are never emitted, so their handlers, toasts, I.ToastAction entries, I.Toast fields and strings are gone. Files now appear in the candidate list like any other orphan.
  • Thread skipCascade through ObjectListSetIsArchived. The confirmation call passes true, so archiving the user's picks can't re-trigger detection and re-open the popup forever.
  • Wire the on-demand half: ObjectCleanupSuggestions (forest of details / isRoot / reason) and ObjectCleanupSuggestionIgnore.

UX — non-blocking, per the brief's "client decides"

The brief explicitly leaves blocking-vs-informative to us. A blocking popup on every archive is intrusive; a passive notice alone lets orphans pile up invisibly. So: both.

1. The Archive toast grows a line and a Review button.
Events are drained before the command callback, so a standalone toast was always clobbered by "N moved to Bin". The ids ride inside the RPC response, so request() lifts them onto message.cleanupIds and the existing toast extends itself — no race to fight. A standalone toast still covers delete (broadcast) and link removal, which have no toast of their own.

2. Review opens a popup — a checkbox tree of candidates nested by createdInContext, cascade-selecting subtrees, confirming with skipCascade=true.

3. Missed the toast? The Bin grows a Cleanup suggestions section, grouped by the source object each orphan was created in and labelled with what happened to it:

Created in         📄 Install Anytype on another device   [In Bin]
                      ☐ download-ios.png
Created in         👻 Deleted object
                      ☐ Anytype Docs
Link removed from  📄 hh
                      ☐ image_1783610022658_0.png

Source rows are deliberately not checkable — which is exactly why the Bin's table component couldn't just be reused. The section reuses its DOM and classes (.listObject > .table > .row > .cell, the −36px checkbox gutter, hover highlight) so it inherits the styling without a fork. Offers Delete permanently — mirroring the Bin's shared-space rule that a non-moderator may only delete objects they created — and Don't suggest again.

4. The Bin widget now appears when the space has archived objects or cleanup suggestions, seeded by one delayed probe on space open and kept in sync by the event and the section's own reloads.

Drive-by fixes

  • Action.delete invoked its callback before the RPC landed, despite its doc promising "after deletion" — callers refresh their lists from it. Audited all 7 callers; every one is more correct running after the delete confirms.
  • Dark theme was painting a circular background on toast buttons (separate commit; pre-existing, affected the lone Undo button too).

Design notes

  • Badges state facts about the object they decorate. In Bin is true of the source; "link removed" isn't (that object is alive and unchanged), so link-removal is carried by the prefix instead.
  • isRoot is derived, not read. The forest is built with the shared U.Data.treeFromRecords, which yields the same roots the backend marks; reason is read from the server. Avoids a second tree implementation.
  • The section's suggestion probe passes keys: ['id'] — passing any key replaces the RPC's default set, so [] would have pulled name/type/creator/snippet/icons just to read .length.

Open questions from the brief

  • Delete is broadcast to all sessions, so the notice appears on every device. Handled correctly; confirm it's acceptable or move to session-targeted delivery.
  • Un-ignore has no UI. CleanupSuggestionIgnore is reversible (ignored=false); a settings screen is a sensible follow-up.

Verification

bun run typecheck ✓ · eslint ✓ · lint-staged ✓ · SCSS compiles ✓ · text.json valid ✓ · dark-mode audit ✓ (theme vars only, no hardcoded colours)

Note: committed with --no-verify. The husky hook runs npx lint-staged, and npx can't start at all with min-release-age=7d in .npmrc (npx lint-staged --version fails on a clean tree). package.json's own precommit uses bunx, which passes — worth fixing the hook separately.

https://claude.ai/code/session_01VcHUsR2mAzi8i2Yn3aJbjx

Toast buttons are underline-only, but the dark theme's generic
`.button.black:not(.disabled):hover` rule carries an extra element (html) and so
outranks toast.scss's `background-color: transparent` at equal class count. The
base `.button` radius of 20px, applied to the toast's text-height, zero-padding
box, then rendered that background as a circle around the label.

Pre-existing: the lone Undo button had it too; it only became obvious once a
second button sat beside it. Restore the intended hover (underline only) with a
dark-theme scoped override.

Claude-Session: https://claude.ai/code/session_01VcHUsR2mAzi8i2Yn3aJbjx
The backend no longer archives, restores or deletes anything implicitly when an
object is archived, deleted, or unlinked. Every orphan it would previously have
swept up — nested objects at any depth, and files at any depth — is now reported
via the new `Object.CleanupSuggestion` event, and the client decides what to do.

Protocol
- Handle `CleanupSuggestion` (mapper + dispatcher). `ObjectAutoArchive` /
  `ObjectAutoRestore` are retired: they are still defined in the proto but never
  emitted, so their handlers, toasts, `I.ToastAction` entries, `I.Toast` fields
  and strings are removed.
- Thread `skipCascade` through `ObjectListSetIsArchived`. The confirmation call
  passes `true` so archiving the user's picks cannot re-trigger detection and
  re-open the popup forever.
- Wire the on-demand half: `ObjectCleanupSuggestions` (forest of
  `details`/`isRoot`/`reason`) and `ObjectCleanupSuggestionIgnore`.

UX — non-blocking, per the brief's "client decides"
- The event extends the existing Archive toast rather than racing it: events are
  drained before the command callback, so a standalone toast was always clobbered
  by "N moved to Bin". The ids ride inside the RPC response, so `request()` lifts
  them onto `message.cleanupIds` and the toast grows a line plus a Review button.
  A standalone toast still covers delete (broadcast) and link removal, which have
  no toast of their own.
- Review opens a popup: a checkbox tree of candidates nested by
  `createdInContext`, cascade-selecting subtrees, confirming with skipCascade.
- Missed the toast? The Bin grows a "Cleanup suggestions" section, grouped by the
  source object each orphan was created in, labelled with what happened to it
  (In Bin / ghost + Deleted object / "Link removed from"). Source rows are not
  checkable, which is why the Bin's table component could not simply be reused —
  the section reuses its DOM and classes instead. Offers Delete permanently
  (mirroring the Bin's shared-space creator rule) and Don't suggest again.
- The Bin widget appears when the space has archived objects *or* cleanup
  suggestions, seeded by one delayed probe on space open and kept in sync by the
  event and the section's own reloads.

Also fixes `Action.delete`, whose callback fired before the RPC landed despite
its doc promising "after deletion" — callers refresh their lists from it.

Claude-Session: https://claude.ai/code/session_01VcHUsR2mAzi8i2Yn3aJbjx
@requilence requilence changed the title feat(bin): user-confirmed cleanup of orphaned objects (GO-7323) feat(bin): explicit cascade deletion — user-confirmed cleanup of orphans (JS-9809) Jul 9, 2026
Replace the section that hung under the Bin list with a second tab beside it:
"Bin | Suggestions N". Bin is default; the tab shows only when there are
suggestions and carries the count in a badge; it auto-returns to Bin once the
last item is cleared.

- Count is lifted to the page via onCountChange so the badge/tab visibility work
  without mounting the tab's body; the component stays mounted on either tab to
  keep it live, and only renders its body when active.
- Actions move to the Bin's header toolbar: reuse the trash icon for Delete
  permanently (with the same shared-space creator gate) and a new
  menu/action/minus for Don't suggest again. No search or view toggle here.
  ArchiveSuggested exposes onDelete/onIgnore via ref and reports selection up.
- Explanation shows as a hover hint on the tab rather than a static line.
- Keep the Suggestions section immune to the Bin's detailed view mode and match
  the "Select all" row to the "Name" head row so switching tabs doesn't jump.

Claude-Session: https://claude.ai/code/session_01VcHUsR2mAzi8i2Yn3aJbjx
Large spaces produce long suggestion lists, so make each source group foldable
and add a Collapse all / Expand all toggle beside Select all.

- Group headers toggle collapse (small arrow/button caret); collapsed groups skip
  their rows. Select all also expands every group so the user can see what got
  selected rather than ticking rows hidden in collapsed sections.
- Remember the active tab on the history entry (location.state), not globally:
  back/forward restores it (state-only replace, no remount), while opening Bin
  fresh from the sidebar is a new entry with no tab and shows Bin. Count is null
  until loaded so a restored Suggestions tab isn't bounced to Bin on mount.
- Header polish: match the Select all row to the Bin "Name" head (no jump on tab
  change) and keep the Suggestions section out of the Bin's detailed view mode;
  group description moved to a hover hint on the tab; no hover-fill on header rows.

Claude-Session: https://claude.ai/code/session_01VcHUsR2mAzi8i2Yn3aJbjx
@requilence requilence merged commit 889bc7f into develop Jul 10, 2026
3 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant