Skip to content
Documentation GitHub
Workspace

Workspace Trash & Soft Delete

Workspace Trash & Soft Delete

Covers the full trash lifecycle: soft-delete (move to trash), cascade delete, listing trash contents, restoring pages, restoring with ancestor chain, permanent delete, empty trash, and rapid delete/restore cycles. This spec is P0 because trash is the last safety net before data is irreversibly destroyed — a bug in restore, permanent delete, or empty-trash logic can cause unrecoverable data loss.

Preconditions

  • HTTP bridge running on port 9990
  • A workspace initialized via initialize_workspace before each scenario
  • Bridge shim injected via playwright.config.ts

Scenarios

Seed: seed.spec.ts

1. Soft-delete a single leaf page

Soft-deleting a page moves it to the trash without destroying the data.

Steps:

  1. Create a page titled “Marked for Trash”.
  2. Right-click “Marked for Trash” in the sidebar and select “Delete” (or “Move to Trash”).
  3. Confirm the deletion if a dialog appears.
  4. Observe the sidebar page tree.
  5. Navigate to the Trash section in the sidebar (if accessible).

Expected: “Marked for Trash” disappears from the active sidebar page tree. The Trash section (if visible) shows “Marked for Trash” with its title and slug intact.

2. Soft-deleted page is not retrievable via normal navigation

A page in the trash should not be accessible through the normal sidebar or editor.

Steps:

  1. Create “Invisible” and navigate to it to confirm it loads.
  2. Delete “Invisible” via right-click → Delete in the sidebar.
  3. Attempt to click on “Invisible” in the sidebar (it should no longer be there) or search for it.

Expected: “Invisible” does not appear in the sidebar page tree. Any search or direct navigation to “Invisible” results in a “Page not found” state or shows it only within the Trash view. The page data is preserved in trash but not exposed in the active workspace.

3. Trash view shows all soft-deleted pages

The Trash section shows all pages currently in the trash, not just the most recent.

Steps:

  1. Create pages “Trash A”, “Trash B”, “Trash C”.
  2. Delete all three via right-click → Delete on each page.
  3. Navigate to the Trash section in the sidebar.

Expected: The Trash section shows at least 3 entries. “Trash A”, “Trash B”, and “Trash C” are all listed.

4. Trash is empty when no pages have been deleted

In a fresh workspace with no deletions, the Trash section is empty.

Steps:

  1. Open a fresh workspace (no deletions performed yet).
  2. Create one page so the workspace is not completely empty.
  3. Navigate to the Trash section in the sidebar.

Expected: The Trash section shows an empty state (e.g., “Trash is empty” or no items listed). The live page is visible in the active sidebar but not in Trash.

5. Cascade delete — parent and all children moved to trash

Deleting a page with the “Delete all” or cascade option moves the entire subtree to trash.

Steps:

  1. Create “Root” with subpages “Child A” and “Child B”. Create “Grandchild” as a subpage of “Child A”.
  2. Right-click “Root” in the sidebar and select “Delete”.
  3. When the confirmation dialog appears (showing descendant count), choose to delete all (cascade).
  4. Observe the sidebar and Trash section.

Expected: All four pages (Root, Child A, Child B, Grandchild) disappear from the active sidebar. The Trash section lists all four pages. The active page tree contains none of the deleted pages.

6. Cascade delete — deleted count reflects the full subtree

The confirmation dialog or post-delete feedback must reflect the full number of pages moved to trash.

Steps:

  1. Build a 5-page tree: a root page with 2 children, each child having 1 grandchild (5 pages total).
  2. Right-click the root and choose “Delete” with cascade.
  3. Observe the confirmation dialog or any post-delete feedback.

Expected: The confirmation dialog indicates 5 pages will be deleted (root + 4 descendants), or a post-delete toast shows “5 pages moved to trash”. The count is exactly 5.

7. Delete without cascade — only the target page is deleted

Choosing a non-cascade delete option removes only the specified page.

Steps:

  1. Create “Parent” and create “Child” as a subpage of “Parent”.
  2. Right-click “Parent” in the sidebar and select “Delete”.
  3. If the confirmation dialog offers a choice, select “Delete only this page” (not cascade).
  4. Observe the sidebar.

Expected: “Parent” disappears from the active sidebar. “Child” remains visible in the active page tree (it is not deleted). The Trash section contains only “Parent”. Note: “Child” may appear at root level or as an orphan in the sidebar — this is expected behavior for non-cascade delete.

8. Restore a page from trash

A soft-deleted page can be brought back to the active workspace from the Trash view.

Steps:

  1. Create “Restorable” and then delete it.
  2. Navigate to the Trash section.
  3. Find “Restorable” in the Trash list.
  4. Click “Restore” on the “Restorable” entry (or right-click → Restore).

Expected: “Restorable” disappears from the Trash list and reappears in the active sidebar page tree. Clicking “Restorable” in the sidebar opens it in the editor with its original title and content intact.

9. Restored page appears in the active sidebar

After restoration, the page must appear in the active page list.

Steps:

  1. Create “Back from the Dead” and delete it.
  2. Navigate to Trash and restore “Back from the Dead”.
  3. Observe the active sidebar.

Expected: “Back from the Dead” appears in the active sidebar page tree and is navigable. The Trash section no longer shows “Back from the Dead”.

10. Restore page with ancestors — orphaned child re-attaches to its parent

When a child was cascade-deleted along with its parent, restoring with the “restore ancestors” option also restores the parent chain.

Steps:

  1. Create “Parent” with a subpage “Child”.
  2. Delete “Parent” using cascade (both go to Trash).
  3. Navigate to Trash and find “Child”.
  4. Click “Restore” on “Child” and, if prompted, choose to also restore its ancestors.
  5. Observe the sidebar.

Expected: Both “Parent” and “Child” reappear in the active sidebar. “Child” is nested under “Parent” as before. The Trash section no longer contains either page.

11. Restore page without ancestors — child is restored but parent remains in trash

Without the “restore ancestors” option, restoring a child page does not automatically restore its deleted parent.

Steps:

  1. Create “Parent” with a subpage “Child”.
  2. Delete “Parent” with cascade (both go to Trash).
  3. Navigate to Trash and restore “Child” without restoring ancestors (if the UI offers this choice, select it; otherwise proceed with the default restore).
  4. Observe the active sidebar and Trash section.

Expected: “Child” appears in the active sidebar (possibly at root level as an orphan). “Parent” remains in the Trash section. The Trash still shows “Parent” but not “Child”.

12. Permanent delete a page from trash

A page in the trash can be permanently and irrecoverably deleted.

Steps:

  1. Create “Doomed” and delete it (moves to Trash).
  2. Navigate to Trash and find “Doomed”.
  3. Click “Delete permanently” on “Doomed” (or right-click → Delete forever).
  4. Confirm the permanent deletion if prompted.
  5. Observe the Trash section.

Expected: “Doomed” disappears from the Trash section. It is no longer recoverable — no “Restore” option exists for it. The Trash section no longer lists “Doomed”.

13. Permanent delete — page not in trash cannot be permanently deleted

permanent_delete_page should only operate on trash contents. A live page cannot be permanently deleted directly.

Steps:

  1. Create “Active Page” (do not delete it).
  2. Observe that “Active Page” is in the active sidebar, not in Trash.
  3. Attempt to permanently delete it (this would only be possible via an API call or if the UI erroneously exposed a permanent delete option for active pages).

Expected: No permanent delete option is shown for live pages in the sidebar. The page remains active and accessible. get_page with its slug still succeeds.

14. Permanent delete — non-existent slug returns NotFound

Attempting to permanently delete a page that never existed must not cause unexpected errors.

Steps:

  1. Navigate to Trash.
  2. Observe that “never-existed” is not listed (it was never created).
  3. Attempt a permanent delete on that slug (only possible via API / bridge call).

Expected: A “Page not found” or equivalent error is returned. No database state is corrupted.

15. Empty trash permanently deletes all trash contents

The “Empty Trash” action permanently removes all pages currently in the Trash.

Steps:

  1. Create “Trash Item 1”, “Trash Item 2”, “Trash Item 3” and delete all three (they move to Trash).
  2. Navigate to the Trash section.
  3. Click “Empty Trash” (or equivalent bulk permanent-delete action).
  4. Confirm if prompted.
  5. Observe the Trash section.

Expected: The Trash section shows an empty state (e.g., “Trash is empty”). All three items are gone permanently. The active sidebar is unaffected.

16. Empty trash on an already-empty trash is a no-op

Emptying the trash when it is already empty must not cause errors.

Steps:

  1. Open a fresh workspace where no pages have been deleted.
  2. Navigate to the Trash section (it should be empty).
  3. Click “Empty Trash” if the button is present even for an empty Trash.

Expected: No error occurs. The Trash remains empty. A success indicator or no-op response is shown (or the “Empty Trash” button may be disabled when Trash is already empty).

17. Empty trash does not affect live pages

The “Empty Trash” action only removes pages in the Trash, leaving active pages untouched.

Steps:

  1. Create “Live Page A” and “Live Page B” (do not delete them).
  2. Create “Doomed” and delete it (moves to Trash).
  3. Click “Empty Trash” in the Trash section.
  4. Observe the active sidebar.

Expected: “Live Page A” and “Live Page B” are still visible in the active sidebar after emptying the Trash. Only “Doomed” was permanently removed. The Empty Trash operation affected exactly 1 page.

18. Restore then re-delete a page

A page can be sent to trash, restored, and sent to trash again — the round-trip must be stable.

Steps:

  1. Create “Yo-yo Page”.
  2. Delete it (moves to Trash).
  3. Restore it from Trash (appears back in active sidebar).
  4. Delete it again (moves to Trash again).
  5. Observe the Trash section.

Expected: “Yo-yo Page” is in the Trash after step 4. The active sidebar does not show it. No errors occurred at any step. The page went through the full cycle without data corruption.

19. Rapid delete and restore cycle (3 iterations)

Cycling through delete and restore three times must not corrupt the page state.

Steps:

  1. Create “Resilient Page”.
  2. Delete it → Restore it from Trash. (Iteration 1)
  3. Delete it again → Restore it again. (Iteration 2)
  4. Delete it again → Restore it again. (Iteration 3)
  5. Navigate to “Resilient Page” in the active sidebar.

Expected: After the final restore, “Resilient Page” opens in the editor with its original title and content intact. The Trash section does not contain “Resilient Page”. No errors occurred during any of the 6 operations.

20. Delete a page that is already in trash cannot be double-deleted

A page that is already in the Trash is not visible in the active sidebar and cannot be deleted again from there.

Steps:

  1. Create “Already Gone” and delete it (moves to Trash).
  2. Observe that “Already Gone” is no longer in the active sidebar.
  3. Attempt to delete “Already Gone” again (only possible via API call with its slug).

Expected: The second delete attempt returns a “Not found” error (the page is not active). The Trash contains “Already Gone” exactly once, not twice.

21. Cascade delete on a leaf page

Using the cascade delete option on a page with no children behaves the same as a regular delete.

Steps:

  1. Create “Lone Leaf” with no subpages.
  2. Right-click “Lone Leaf” → Delete, and choose cascade/delete-all if prompted.

Expected: “Lone Leaf” moves to the Trash. The delete affects exactly 1 page. No error occurs.

22. Deleted pages do not appear in the sidebar tree

The sidebar must only show active pages, not deleted ones.

Steps:

  1. Create “Visible” and “Hidden” as two root-level pages.
  2. Delete “Hidden” via right-click → Delete.
  3. Observe the sidebar.

Expected: “Visible” remains in the active sidebar. “Hidden” does not appear in the active sidebar page tree. The sidebar is clean with only live pages.

23. Permanent delete after cascade delete removes only the specified page

After cascade-deleting a parent and its children into the Trash, permanently deleting the parent does not automatically remove the children from the Trash.

Steps:

  1. Create “Parent” with two subpages “Child A” and “Child B”.
  2. Delete “Parent” with cascade (Parent, Child A, Child B all go to Trash — 3 total).
  3. Navigate to Trash and permanently delete only “Parent”.
  4. Observe the Trash section.

Expected: “Parent” is permanently removed from the Trash. “Child A” and “Child B” remain in the Trash section (2 items). Their permanent removal was not triggered by permanently deleting the parent.

24. Empty trash after cascade delete removes entire subtree

When a cascade-deleted subtree is in the Trash, “Empty Trash” clears all of it.

Steps:

  1. Create “Family Root” with children “Son” and “Daughter”, and “Son” has a child “Grandson” (4 pages total).
  2. Delete “Family Root” with cascade (all 4 go to Trash).
  3. Navigate to Trash and click “Empty Trash”.
  4. Observe the Trash section.

Expected: The Trash section shows an empty state after emptying. All 4 pages are permanently gone. Any feedback (toast or count) indicates 4 pages were permanently deleted.

25. Restore with ancestors creates the full ancestor chain

When a deeply nested page is cascade-deleted, restoring the deepest child with the “restore ancestors” option restores the entire chain.

Steps:

  1. Create a 3-level hierarchy: “Top” → “Middle” → “Bottom”.
  2. Delete “Top” with cascade (all 3 go to Trash).
  3. Navigate to Trash and restore “Bottom” with the option to also restore its ancestors.
  4. Observe the active sidebar — expand “Top” and then “Middle”.

Expected: All three pages (“Top”, “Middle”, “Bottom”) are restored. “Bottom” is nested under “Middle”, which is nested under “Top”. The Trash section is empty (all three were restored). Clicking each page opens it correctly in the editor.

26. Restore page — non-existent or live page returns an error

Attempting to restore a page that is not in the Trash must not corrupt state.

Steps:

  1. Create “Still Active” (do not delete it).
  2. Attempt to restore “Still Active” from the Trash (this would only surface via an API/bridge call, since the Trash UI only shows deleted pages).

Expected: An error or “not found” response is returned for the restore attempt. “Still Active” remains in the active sidebar, unaffected. Its content is not corrupted.

27. Trash list reflects deletion order and includes deleted timestamps

The Trash section shows enough metadata to support a “Recently Deleted” UI panel.

Steps:

  1. Create “First” and delete it.
  2. Create “Second” and delete it shortly after.
  3. Navigate to the Trash section.

Expected: Both “First” and “Second” appear in the Trash section. Each entry shows a deletion time or “Deleted X ago” timestamp. The entries are ordered by deletion time, with the most recently deleted appearing first (or in the documented order).

28. Permanent delete with an invalid slug is rejected

Slug validation applies to the permanent delete operation as well.

Steps:

  1. Attempt a permanent delete using a slug containing path-traversal characters (e.g., ../traversal) via an API or bridge call.

Expected: A validation error is returned. No database operation is performed. The Trash section is unaffected.

29. Delete page with empty slug is rejected

An empty slug must be caught before any database access.

Steps:

  1. Attempt a delete operation with an empty slug (e.g., via API with { slug: "", cascade: false }).

Expected: A validation error is returned. No page is deleted. The active sidebar and Trash are unaffected.

30. Restoring one page from a multi-item trash does not affect others

Restore operations must be scoped to the requested page only.

Steps:

  1. Create “Page X”, “Page Y”, and “Page Z” and delete all three (all go to Trash).
  2. Navigate to Trash and restore only “Page Y”.
  3. Observe the Trash section and the active sidebar.

Expected: “Page Y” disappears from the Trash and reappears in the active sidebar. “Page X” and “Page Z” remain in the Trash, unaffected by the restore of “Page Y”. Clicking “Page Y” in the sidebar opens it correctly.

Test Data

KeyValueNotes
single_page_slugmarked-for-trashBasic soft-delete target
cascade_root_slugrootParent of a 4-page subtree for cascade test
restore_target_slugrestorablePage deleted then restored
yo_yo_slugyo-yo-pagePage cycled through trash/restore multiple times
traversal_slug../traversalInvalid slug for boundary validation tests
deep_hierarchytop → top/middle → top/middle/bottom3-level chain for ancestor-restore tests
family_size4family-root + son + daughter + grandson for empty-trash test

Notes

  • delete_page with cascade: false on a parent does not move children to trash — children remain active but their parent reference becomes a ghost. This is the documented behavior, not a bug. The hierarchy spec covers the implications for list_page_tree.
  • restore_page returns null on success (not the restored page object). Tests should verify the restoration by navigating to the page or observing the sidebar.
  • permanent_delete_page is irreversible. There is no undo. Tests that permanently delete pages should use isolated, disposable page names not shared across scenarios.
  • empty_trash is also irreversible. Reserve empty-trash tests for dedicated scenarios that have full control over trash contents.
  • The bridge does not fire analytics or sync events for trash operations (no Tauri AppHandle). This is expected and documented.
  • list_trash returns DeletedPageInfo structs, not full Page objects. The exact fields available (slug, title, deleted_at, parent info) should be confirmed from the first test run and captured in the test data table.

Was this page helpful?