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

+42 -1
View File
@@ -14,6 +14,7 @@
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte'
@@ -391,6 +392,35 @@
}
}
/** The trash's keys: j/k down what was deleted, Enter or r restores, x deletes for good. */
function trashKey(event: KeyboardEvent): boolean {
const items = workspace.trash
const last = Math.max(items.length - 1, 0)
const item = items[workspace.trashCursor]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.trashCursor = Math.min(workspace.trashCursor + 1, last)
return true
case 'k':
case 'ArrowUp':
workspace.trashCursor = Math.max(workspace.trashCursor - 1, 0)
return true
case 'G':
workspace.trashCursor = last
return true
case 'Enter':
case 'r':
if (item) void restoreTrashed(item)
return true
case 'x':
if (item) void purgeTrashed(item)
return true
default:
return false
}
}
/*
* `F`: the hints are pinned where the targets are, so a pane still sliding
* in — a space switch, the board just opened — is let settle first.
@@ -578,6 +608,12 @@
return
}
}
if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (trashKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (outlineKey(event)) {
event.preventDefault()
@@ -862,6 +898,11 @@
event.preventDefault()
void runCommand('d')
break
case 'u':
// A delete goes to the trash, so it can be undone the way vim undoes.
event.preventDefault()
void workspace.guard(() => workspace.undoDelete())
break
case 's':
// Sharing is a key away; taking a link back from its readers asks first.
event.preventDefault()
@@ -1000,7 +1041,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+2 -1
View File
@@ -41,7 +41,8 @@
['n / N', 'next / previous search result, opened beside the results — :noh clears the search'],
['s', 'toggle sharing for the document — asks before taking it back'],
['y', 'mint a new share link and copy it — asks, the old one stops working'],
['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'],
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
['Ctrl-S', 'save, from anywhere'],
['?', 'this screen — in the editor too'],
+1
View File
@@ -83,6 +83,7 @@
],
[
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
{ icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') },
...(workspace.marked.length
? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }]
: []),
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts" module>
import type { TrashItem } from './api'
import { workspace } from './store.svelte'
/*
* What a row's buttons do, out here so the keys in App.svelte do the same:
* Enter or r restores, x deletes for good.
*/
export function restoreTrashed(item: TrashItem) {
return workspace.guard(() => workspace.restore(item.id))
}
export async function purgeTrashed(item: TrashItem) {
const inside = item.inside > 0 ? ` and the ${item.inside} inside it` : ''
if (!(await workspace.ask(`delete "${item.name.trim() || item.slug}"${inside} for good? this cannot be undone.`))) return
await workspace.guard(() => workspace.purge(item.id))
}
export async function emptyTrash() {
if (!(await workspace.ask('empty the trash? everything in it is deleted for good.'))) return
await workspace.guard(() => workspace.emptyTrash())
}
</script>
<script lang="ts">
import { relative } from './util'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.trashCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
const days = (at: number) => Math.max(Math.ceil((at - Date.now() / 1000) / 86400), 0)
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">trash</span> {workspace.trash.length} deleted</span>
{#if workspace.trash.length > 0}
<button class="danger" onclick={emptyTrash}>empty</button>
{/if}
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each workspace.trash as item, index (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.trashCursor} data-index={index} onpointerenter={() => (workspace.trashCursor = index)}>
<span class="kind label">{item.kind}</span>
<div class="info">
<span class="name">{item.name.trim() || item.slug}</span>
<span class="muted">
{item.from || 'the root'} · deleted {relative(item.deleted_at)}{item.inside > 0 ? ` · ${item.inside} inside` : ''} ·
gone in {days(item.purge_at)}d
</span>
</div>
<div class="actions">
<button onclick={() => restoreTrashed(item)}>restore</button>
<button class="danger" onclick={() => purgeTrashed(item)}>rm</button>
</div>
</li>
{:else}
<li class="muted">the trash is empty.</li>
{/each}
</ul>
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">enter</span><span class="kbd">r</span> restore
<span class="gap"></span>
<span class="kbd">x</span> delete for good
<span class="gap"></span>
<span class="kbd">q</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
max-height: 100%;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 92ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 8px;
border-radius: var(--radius-m);
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
.kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.actions { display: flex; gap: 4px; flex: none; }
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
</style>
+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 }) }),
+21 -4
View File
@@ -1,6 +1,7 @@
import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { emptyTrash } from './Trash.svelte'
import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
export type { ScrollAmount, SidewaysAmount }
@@ -693,7 +694,7 @@ export const commands: CommandSpec[] = [
aliases: ['d', 'rm'],
args: '[id]',
group: 'documents',
help: 'delete a node, and everything under it (asks first)',
help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (workspace.view === 'board') {
@@ -712,7 +713,7 @@ export const commands: CommandSpec[] = [
node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?`
if (!(await workspace.ask(`${what} this cannot be undone.`))) return
if (!(await workspace.ask(`${what} it goes to the trash.`))) return
await workspace.guard(() => workspace.deleteNode(id))
},
},
@@ -761,7 +762,7 @@ export const commands: CommandSpec[] = [
aliases: ['rms'],
args: '[name]',
group: 'tree',
help: 'delete a space and everything in it (asks first)',
help: 'delete a space and everything in it, into the trash (asks first)',
complete: spaceNames,
run: async (arg) => {
const wanted = arg.trim().toLowerCase()
@@ -769,12 +770,28 @@ export const commands: CommandSpec[] = [
? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted)
: workspace.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? this cannot be undone.`))) {
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) {
return
}
await workspace.guard(() => workspace.deleteSpace(space.id))
},
},
{
name: 'trash',
args: '[purge]',
group: 'tree',
help: 'what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it',
complete: () => ['purge'],
run: async (arg) => {
if (arg.trim() === 'purge') return emptyTrash()
if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error')
await workspace.guard(async () => {
await workspace.loadTrash()
workspace.trashCursor = 0
workspace.overlay = 'trash'
})
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
+77 -4
View File
@@ -12,6 +12,7 @@ import {
type MediaFile,
type NodeSummary,
type SpaceOverview,
type TrashItem,
type VaultStatus,
} from './api'
import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util'
@@ -278,7 +279,7 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
@@ -305,6 +306,11 @@ class Workspace {
vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([])
mediaCursor = $state(0)
/** What `:trash` lists: everything deleted, the last first. */
trash = $state<TrashItem[]>([])
trashCursor = $state(0)
/** The last thing deleted here, which `u` puts back. */
lastDeleted = $state<number | null>(null)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -998,13 +1004,67 @@ class Workspace {
for (const tile of showing) tile.stopAutosave()
this.dropStash(id)
const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id)
const { removed } = await api.deleteNode(id)
const { removed, purged } = await api.deleteNode(id)
for (const tile of showing) tile.clear()
this.prune()
this.marked = this.marked.filter((n) => n.id !== id)
await this.refresh()
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`)
if (purged) return this.say(`deleted ${name} for good`)
this.lastDeleted = id
const what = removed > 1 ? `${name} and ${removed - 1} inside it` : name
this.say(`${what} went to the trash — u brings it back`)
}
/** `u`: the last delete, undone. */
async undoDelete() {
const id = this.lastDeleted
if (id === null) return this.say('nothing deleted here to undo — :trash has the rest', 'error')
await this.restore(id)
}
/**
* Out of the trash, back where it was. A space is entered again; anything
* else in the space being browsed gets the cursor, so `u` lands on what it
* brought back.
*/
async restore(id: number) {
const { node, elsewhere } = await api.restore(id)
if (this.lastDeleted === id) this.lastDeleted = null
const name = `"${node.name.trim() || node.slug}"`
if (node.kind === 'space') {
this.spaces = await api.listSpaces()
if (this.overlay !== 'trash') await this.enterSpace(node.id)
this.say(`restored the space ${name}`)
} else {
const parent = node.path.at(-2)
if (parent && node.path[0]?.id === this.spaceId && this.view === 'tree') {
if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id)
}
const where = node.path.slice(0, -1).map((crumb) => crumb.slug).join('/')
this.say(elsewhere ? `restored ${name} to ${where} — the folder it was in is gone` : `restored ${name} to ${where}`)
}
if (this.overlay === 'trash') await this.loadTrash()
}
async loadTrash() {
this.trash = await api.trash()
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
}
/** Delete one thing in the trash for good, files and all. */
async purge(id: number) {
await api.purge(id)
if (this.lastDeleted === id) this.lastDeleted = null
await this.loadTrash()
}
async emptyTrash() {
const { removed } = await api.emptyTrash()
this.lastDeleted = null
if (this.overlay === 'trash') await this.loadTrash()
this.say(removed === 1 ? 'the trash is empty — 1 thing deleted for good' : `the trash is empty — ${removed} things deleted for good`)
}
/**
@@ -1038,6 +1098,7 @@ class Workspace {
*/
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
this.lastDeleted = id
for (const tile of this.tiles) tile.clear()
this.prune()
this.rawBoard = null
@@ -1053,7 +1114,7 @@ class Workspace {
this.nodes = []
this.cursor = 0
}
this.say(`deleted the space and ${removed - 1} node(s) in it`)
this.say(`the space and ${removed - 1} node(s) in it went to the trash — u brings it back`)
}
/** Reparent the node under the cursor, for `:mv`. */
@@ -1443,6 +1504,18 @@ class Workspace {
const space = this.spaceId
if (!card || space === null) return
let nodeId = card.node_id
if (nodeId !== null) {
// Deleted notes read as gone, but the card still points at them, so
// the way back to them is named rather than a bare "not found".
try {
await api.getNode(nodeId)
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return this.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error')
}
throw error
}
}
if (nodeId === null) {
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
await api.updateCard(card.id, { node_id: node.id })