mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
439d5b083b
commit
8ffad6350f
22 files changed
+1154
-78
No files matched your search
+42
-1
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 }]
|
||||
: []),
|
||||
|
||||
@@ -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
@@ -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
@@ -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>',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user