mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
revisions: a save keeps what it replaces
Every write of a document's text keeps the text it replaces, in the same transaction. Autosave writes every few seconds, so an ordinary save keeps one at most every ten minutes; `:w!`, a save over the SFTP mount and a restore always keep one, because each may undo something. A rename alone, or a document that was empty, keeps nothing. `gh` or `:history` lists the open document's earlier versions, the newest first, and shows the highlighted one as a line diff against the text as it is now. Enter brings it back, and what it replaces becomes a version of its own, so a restore is undone the same way. The daily sweep forgets versions older than WORKSPACE_REVISION_DAYS (90) but never the newest 20 of a document, and keeps an upload as long as a version still embeds it. Versions stay out of the export, the mount and the readable mirror; a document in the trash shows none.
This commit is contained in:
1 parent
8ffad6350f
commit
fc2be66b0c
17 files changed
+887
-31
No files matched your search
+35
-1
@@ -15,6 +15,7 @@
|
||||
import Overview from './lib/Overview.svelte'
|
||||
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
|
||||
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
|
||||
import History, { moveHistory, restoreShown } from './lib/History.svelte'
|
||||
import Actions from './lib/Actions.svelte'
|
||||
import Prompt from './lib/Prompt.svelte'
|
||||
import SecretPrompt from './lib/SecretPrompt.svelte'
|
||||
@@ -421,6 +422,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The history's keys: j/k through the versions, Enter brings the one shown back. */
|
||||
function historyKey(event: KeyboardEvent): boolean {
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
moveHistory(1)
|
||||
return true
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
moveHistory(-1)
|
||||
return true
|
||||
case 'G':
|
||||
moveHistory(workspace.history.length)
|
||||
return true
|
||||
case 'Enter':
|
||||
void restoreShown()
|
||||
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.
|
||||
@@ -608,6 +631,12 @@
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspace.overlay === 'history' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (historyKey(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (trashKey(event)) {
|
||||
event.preventDefault()
|
||||
@@ -759,6 +788,11 @@
|
||||
void runCommand('outline')
|
||||
return
|
||||
}
|
||||
if (previous === 'g' && key === 'h') {
|
||||
event.preventDefault()
|
||||
void runCommand('history')
|
||||
return
|
||||
}
|
||||
if (previous === 'g' && key === 'x') {
|
||||
event.preventDefault()
|
||||
openInTab()
|
||||
@@ -1041,7 +1075,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 if workspace.overlay === 'trash'}<Trash />{: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 if workspace.overlay === 'history'}<History />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
['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 — into the trash (asks y/N)'],
|
||||
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
||||
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
|
||||
[':', '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'],
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts" module>
|
||||
import type { RevisionSummary } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
/** What Enter and the button do: the shown revision becomes the document again. */
|
||||
export async function restoreShown() {
|
||||
const revision = workspace.revision
|
||||
if (!revision) return
|
||||
if (!(await workspace.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in :history`))) return
|
||||
await workspace.guard(() => workspace.restoreRevision(revision))
|
||||
}
|
||||
|
||||
export function moveHistory(by: number) {
|
||||
const last = Math.max(workspace.history.length - 1, 0)
|
||||
const index = Math.min(Math.max(workspace.historyCursor + by, 0), last)
|
||||
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
|
||||
}
|
||||
|
||||
const REASONS: Record<string, string> = {
|
||||
edit: 'saved over',
|
||||
force: 'written over by :w!',
|
||||
sftp: 'saved over the mount',
|
||||
restore: 'replaced by a restore',
|
||||
}
|
||||
export const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { diffLines, folded } from './diff'
|
||||
import { formatStamp, relative } from './util'
|
||||
|
||||
let list = $state<HTMLUListElement>()
|
||||
|
||||
$effect(() => {
|
||||
const index = workspace.historyCursor
|
||||
list?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
// Against the text as it is now: what restoring would take out and put back.
|
||||
const rows = $derived(workspace.revision ? folded(diffLines(workspace.draft.body, workspace.revision.body)) : [])
|
||||
const changed = $derived(rows.some((row) => row.kind === 'gone' || row.kind === 'added'))
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule">
|
||||
<span class="marker"></span><span class="title">history</span>
|
||||
{workspace.open?.name.trim() || workspace.open?.slug} · {workspace.history.length} earlier
|
||||
</span>
|
||||
{#if workspace.revision}
|
||||
<button onclick={restoreShown}>restore</button>
|
||||
{/if}
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<ul bind:this={list}>
|
||||
{#each workspace.history as summary, index (summary.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<li class:at={index === workspace.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.showRevision(index))}>
|
||||
<span class="when">{formatStamp(summary.saved_at)}</span>
|
||||
<span class="muted">{relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="muted">no earlier versions yet — one is kept when a save replaces the text.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="diff">
|
||||
{#if workspace.revision}
|
||||
{#if workspace.revision.name !== workspace.draft.name}
|
||||
<div class="row gone">- # {workspace.draft.name}</div>
|
||||
<div class="row added">+ # {workspace.revision.name}</div>
|
||||
{/if}
|
||||
{#each rows as row, index (index)}
|
||||
{#if row.kind === 'fold'}
|
||||
<div class="row fold">··· {row.count} unchanged</div>
|
||||
{:else}
|
||||
<div class="row {row.kind}">{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if !changed}
|
||||
<div class="row fold">the text is the same as now</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="label">
|
||||
<span class="kbd">j</span><span class="kbd">k</span> earlier / later
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">enter</span> restore
|
||||
<span class="gap"></span>
|
||||
<span class="muted">- goes, + comes back</span>
|
||||
<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;
|
||||
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%, 140ch);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; }
|
||||
header .marker { background: var(--light); }
|
||||
header .title { color: var(--light); }
|
||||
|
||||
.split { display: flex; gap: 18px; min-height: 0; flex: 1; }
|
||||
|
||||
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; width: 34ch; flex: none; }
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-m);
|
||||
cursor: pointer;
|
||||
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); }
|
||||
.when { color: var(--fg); font-size: 13px; }
|
||||
.muted { font-size: 11px; }
|
||||
|
||||
.diff {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.row { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 10px; }
|
||||
.row.gone { background: color-mix(in oklab, var(--alarm) 16%, transparent); }
|
||||
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
||||
.row.fold { color: var(--muted); }
|
||||
|
||||
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
footer .kbd { margin-right: 2px; }
|
||||
footer .gap { width: 14px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.split { flex-direction: column; }
|
||||
ul { width: auto; max-height: 30%; }
|
||||
footer { display: none; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
footer { display: none; }
|
||||
}
|
||||
</style>
|
||||
+33
-2
@@ -78,6 +78,21 @@ export interface SshKey {
|
||||
vault: boolean
|
||||
}
|
||||
|
||||
/** What a document said before a save replaced it. */
|
||||
export interface RevisionSummary {
|
||||
id: number
|
||||
name: string
|
||||
saved_at: number
|
||||
/** `edit`, `force` (`:w!`), `sftp`, or `restore`. */
|
||||
reason: string
|
||||
/** Characters of text. */
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface Revision extends Omit<RevisionSummary, 'size'> {
|
||||
body: string
|
||||
}
|
||||
|
||||
/** Something deleted, waiting in the trash to be restored or purged. */
|
||||
export interface TrashItem {
|
||||
id: number
|
||||
@@ -392,12 +407,28 @@ export const api = {
|
||||
created_at?: number
|
||||
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
||||
|
||||
/** `base_updated_at`: refused with 409 if the node was saved since then. */
|
||||
/**
|
||||
* `base_updated_at`: refused with 409 if the node was saved since then.
|
||||
* `restore`: the text is a revision brought back, so what it replaces is
|
||||
* kept as a revision whatever the last one was.
|
||||
*/
|
||||
updateNode: (
|
||||
id: number,
|
||||
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
|
||||
input: {
|
||||
name: string
|
||||
body: string
|
||||
created_at?: number
|
||||
has_board?: boolean
|
||||
base_updated_at?: number
|
||||
restore?: boolean
|
||||
},
|
||||
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
||||
|
||||
/** A document's earlier versions, the newest first. */
|
||||
revisions: (id: number) => request<RevisionSummary[]>(`/nodes/${id}/revisions`),
|
||||
|
||||
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
|
||||
|
||||
/** Into the trash; `purged` when it was in the trash already and is now gone. */
|
||||
deleteNode: (id: number) =>
|
||||
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -776,6 +776,19 @@ export const commands: CommandSpec[] = [
|
||||
await workspace.guard(() => workspace.deleteSpace(space.id))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
aliases: ['hist'],
|
||||
group: 'documents',
|
||||
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
|
||||
run: async () => {
|
||||
if (!requireOpen()) return
|
||||
await workspace.guard(async () => {
|
||||
await workspace.loadHistory()
|
||||
workspace.overlay = 'history'
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'trash',
|
||||
args: '[purge]',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** One line of a diff: kept, only in the old text, or only in the new. */
|
||||
export type DiffLine = { kind: 'same' | 'gone' | 'added'; text: string }
|
||||
|
||||
/** A run of unchanged lines too long to be worth showing, folded to a count. */
|
||||
export type DiffRow = DiffLine | { kind: 'fold'; count: number }
|
||||
|
||||
/*
|
||||
* Past this many lines in the part that differs, the table a longest common
|
||||
* subsequence needs would be tens of megabytes; the change is shown as the
|
||||
* old lines out and the new ones in instead. Documents are rarely near it.
|
||||
*/
|
||||
const MAX_CELLS = 4_000_000
|
||||
|
||||
/** Lines of `before` and `after`, matched up the way `diff` would. */
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n')
|
||||
const b = after.split('\n')
|
||||
|
||||
// What both start and end with needs no table.
|
||||
let start = 0
|
||||
while (start < a.length && start < b.length && a[start] === b[start]) start++
|
||||
let end = 0
|
||||
while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
|
||||
|
||||
const head = a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text }))
|
||||
const tail = a.slice(a.length - end).map((text): DiffLine => ({ kind: 'same', text }))
|
||||
const x = a.slice(start, a.length - end)
|
||||
const y = b.slice(start, b.length - end)
|
||||
|
||||
if (x.length * y.length > MAX_CELLS) {
|
||||
return [
|
||||
...head,
|
||||
...x.map((text): DiffLine => ({ kind: 'gone', text })),
|
||||
...y.map((text): DiffLine => ({ kind: 'added', text })),
|
||||
...tail,
|
||||
]
|
||||
}
|
||||
|
||||
// lcs[i][j]: the longest common subsequence of x[i..] and y[j..].
|
||||
const width = y.length + 1
|
||||
const lcs = new Uint32Array((x.length + 1) * width)
|
||||
for (let i = x.length - 1; i >= 0; i--) {
|
||||
for (let j = y.length - 1; j >= 0; j--) {
|
||||
lcs[i * width + j] =
|
||||
x[i] === y[j] ? lcs[(i + 1) * width + j + 1] + 1 : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const middle: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < x.length && j < y.length) {
|
||||
if (x[i] === y[j]) {
|
||||
middle.push({ kind: 'same', text: x[i] })
|
||||
i++
|
||||
j++
|
||||
} else if (lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) {
|
||||
middle.push({ kind: 'gone', text: x[i++] })
|
||||
} else {
|
||||
middle.push({ kind: 'added', text: y[j++] })
|
||||
}
|
||||
}
|
||||
while (i < x.length) middle.push({ kind: 'gone', text: x[i++] })
|
||||
while (j < y.length) middle.push({ kind: 'added', text: y[j++] })
|
||||
|
||||
return [...head, ...middle, ...tail]
|
||||
}
|
||||
|
||||
/** The diff with only `context` unchanged lines kept around each change. */
|
||||
export function folded(lines: DiffLine[], context = 3): DiffRow[] {
|
||||
const near = new Uint8Array(lines.length)
|
||||
lines.forEach((line, index) => {
|
||||
if (line.kind === 'same') return
|
||||
for (let k = Math.max(index - context, 0); k <= Math.min(index + context, lines.length - 1); k++) near[k] = 1
|
||||
})
|
||||
const rows: DiffRow[] = []
|
||||
let skipped = 0
|
||||
lines.forEach((line, index) => {
|
||||
if (near[index]) {
|
||||
if (skipped) rows.push({ kind: 'fold', count: skipped })
|
||||
skipped = 0
|
||||
rows.push(line)
|
||||
} else skipped++
|
||||
})
|
||||
if (skipped) rows.push({ kind: 'fold', count: skipped })
|
||||
return rows
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
type WorkspaceNode,
|
||||
type MediaFile,
|
||||
type NodeSummary,
|
||||
type Revision,
|
||||
type RevisionSummary,
|
||||
type SpaceOverview,
|
||||
type TrashItem,
|
||||
type VaultStatus,
|
||||
} from './api'
|
||||
import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util'
|
||||
import { formatBytes, formatStamp, isTouchDevice, sealedNote, type Heading } from './util'
|
||||
|
||||
export type Pane = 'list' | 'document'
|
||||
/** The tree of documents, or the board of the space being browsed. */
|
||||
@@ -279,7 +281,7 @@ class Workspace {
|
||||
pane = $state<Pane>('list')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none')
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history'>('none')
|
||||
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
||||
outline = $state.raw<Heading[]>([])
|
||||
outlineCursor = $state(0)
|
||||
@@ -311,6 +313,10 @@ class Workspace {
|
||||
trashCursor = $state(0)
|
||||
/** The last thing deleted here, which `u` puts back. */
|
||||
lastDeleted = $state<number | null>(null)
|
||||
/** `:history`: the open document's earlier versions, and the one shown. */
|
||||
history = $state<RevisionSummary[]>([])
|
||||
historyCursor = $state(0)
|
||||
revision = $state.raw<Revision | null>(null)
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
overviewCursor = $state(0)
|
||||
|
||||
@@ -961,7 +967,7 @@ class Workspace {
|
||||
* since it was opened here — another tab, the phone, the SFTP mount —
|
||||
* unless `force`, which is `:w!`.
|
||||
*/
|
||||
async save({ quiet = false, force = false, tile = this.tile } = {}) {
|
||||
async save({ quiet = false, force = false, restore = false, tile = this.tile } = {}) {
|
||||
if (!tile.open) return
|
||||
// Snapshot what goes over the wire: an autosave races the typing that
|
||||
// triggered it, and anything typed since must stay marked unsaved.
|
||||
@@ -969,6 +975,7 @@ class Workspace {
|
||||
const saved = await api.updateNode(tile.open.id, {
|
||||
...sent,
|
||||
base_updated_at: force ? undefined : tile.open.updated_at,
|
||||
restore,
|
||||
})
|
||||
|
||||
tile.open = saved
|
||||
@@ -1048,6 +1055,47 @@ class Workspace {
|
||||
if (this.overlay === 'trash') await this.loadTrash()
|
||||
}
|
||||
|
||||
/** `:history`: the open document's earlier versions, the newest first. */
|
||||
async loadHistory() {
|
||||
const open = this.open
|
||||
if (!open) return
|
||||
// What is typed and not yet written is part of what a revision is
|
||||
// compared with, and of what a restore must not lose.
|
||||
await this.flushTile()
|
||||
this.history = await api.revisions(open.id)
|
||||
this.historyCursor = 0
|
||||
this.revision = null
|
||||
await this.showRevision(0)
|
||||
}
|
||||
|
||||
/** Read the revision at `index`, for the overlay to compare with the draft. */
|
||||
async showRevision(index: number) {
|
||||
const open = this.open
|
||||
const summary = this.history[index]
|
||||
this.historyCursor = index
|
||||
if (!open || !summary) return
|
||||
const revision = await api.revision(open.id, summary.id)
|
||||
// A later j may have moved on while this one was being read.
|
||||
if (this.history[this.historyCursor]?.id === revision.id) this.revision = revision
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a revision back: it becomes the document again, and what it
|
||||
* replaces is kept as a revision of its own, so a restore is undone the
|
||||
* same way. The editor reads the draft when it opens, so this leaves it.
|
||||
*/
|
||||
async restoreRevision(revision: Revision) {
|
||||
const tile = this.tile
|
||||
if (!tile.open) return
|
||||
await this.flushTile(tile)
|
||||
tile.editing = false
|
||||
tile.draft = { ...tile.draft, name: revision.name, body: revision.body }
|
||||
tile.dirty = true
|
||||
await this.save({ quiet: true, restore: true, tile })
|
||||
this.overlay = 'none'
|
||||
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
|
||||
}
|
||||
|
||||
async loadTrash() {
|
||||
this.trash = await api.trash()
|
||||
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
|
||||
|
||||
Reference in new issue
Block a user