trash: a delete can be undone

`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they
delete is moved, whole, under one hidden root, and `u` puts the last of it
back where it was. `:trash` lists everything deleted, the last first:
Enter or `r` restores, `x` deletes one for good and `:trash purge` empties
it, each asking first. Something whose folder has gone since comes back to
the root of its space; something whose space is in the trash too waits for
the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30),
and only then are its files deleted.

While it waits, a trashed node is out of the tree, the search, the agenda,
the mails, the SFTP mount, the export and the readable files/ view, and a
shared document answers 404 until it comes back with the same link. `rm`
over the mount trashes too; a rename onto a taken name still replaces what
was there, so editor saves do not fill the trash.
This commit is contained in:
nvrl committed 2026-10-20 18:15:22 +02:00
1 parent 439d5b083b
commit 8ffad6350f
22 files changed
+1154 -78

No files matched your search

+28 -2
View File
@@ -23,7 +23,7 @@ export interface NodeSummary {
excerpt: string
position: number
has_board: boolean
/** Spaces the app has to tell apart: `diary`, or nothing. */
/** Nodes the app has to tell apart: `diary`, `trash`, or nothing. */
role: string | null
child_count: number
created_at: number
@@ -78,6 +78,21 @@ export interface SshKey {
vault: boolean
}
/** Something deleted, waiting in the trash to be restored or purged. */
export interface TrashItem {
id: number
kind: NodeKind
name: string
slug: string
/** The folder it was deleted from, `work/notes`; empty for a space. */
from: string
deleted_at: number
/** When the sweep deletes it for good. */
purge_at: number
/** How many nodes are inside it. */
inside: number
}
export interface SharedDocument {
name: string
body: string
@@ -383,8 +398,19 @@ export const api = {
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
/** Into the trash; `purged` when it was in the trash already and is now gone. */
deleteNode: (id: number) =>
request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }),
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
trash: () => request<TrashItem[]>('/trash'),
/** Back where it was; `elsewhere` when that folder is gone and it went to its space. */
restore: (id: number) =>
request<{ node: WorkspaceNode; elsewhere: boolean }>(`/trash/${id}/restore`, { method: 'POST' }),
purge: (id: number) => request<{ ok: true; removed: number }>(`/trash/${id}`, { method: 'DELETE' }),
emptyTrash: () => request<{ ok: true; removed: number }>('/trash', { method: 'DELETE' }),
moveNode: (id: number, parent_id: number, position?: number) =>
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),