feat: autosave drafts, and stop losing them on a switch

Only Ctrl-S and :w ever wrote. A closed laptop, a crashed tab or an
expired session took the draft with it, and clicking another entry in the
list silently discarded unsaved work without so much as a prompt.

Every change now goes to localStorage immediately and to the server two
and a half seconds after typing stops. Opening an entry that has a stash
newer than the server's copy restores it and says so, the way vim reports
a swap file; :e! and :q! discard it. Switching entries flushes the
outgoing draft first.

The save response carries the entry's excerpt, so a save patches its list
row in place instead of re-reading the whole list — which autosave would
otherwise have done every few seconds.
This commit is contained in:
nvrl committed 2026-09-05 09:10:04 +02:00
1 parent f42c0c6e4b
commit 54743346fe
6 files changed
+129 -13

No files matched your search

+6 -1
View File
@@ -28,6 +28,9 @@ pub struct Entry {
pub id: i64,
pub title: String,
pub body: String,
/// The same preview the list shows, so a client that has just saved can
/// patch its list row without re-reading the whole list.
pub excerpt: String,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
@@ -252,10 +255,12 @@ async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
.ok_or(AppError::NotFound)?;
let share_token: Option<String> = row.get("share_token");
let body: String = row.get("body");
Ok(Entry {
id: row.get("id"),
title: row.get("title"),
body: row.get("body"),
excerpt: excerpt(&body.chars().take(HEAD_CHARS).collect::<String>()),
body,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
+1 -1
View File
@@ -182,7 +182,7 @@
EditorView.updateListener.of((update) => {
if (update.docChanged) {
diary.draft.body = update.state.doc.toString()
diary.dirty = true
diary.touch()
}
}),
EditorView.domEventHandlers({
+1 -1
View File
@@ -56,7 +56,7 @@
class="title"
placeholder="title (optional)"
bind:value={diary.draft.title}
oninput={() => (diary.dirty = true)}
oninput={() => diary.touch()}
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
+1
View File
@@ -12,6 +12,7 @@ export interface Entry {
id: number
title: string
body: string
excerpt: string
created_at: number
updated_at: number
shared: boolean
+5 -1
View File
@@ -107,7 +107,10 @@ export const commands: CommandSpec[] = [
const open = diary.open
diary.editing = false
diary.dirty = false
if (open) await diary.guard(() => diary.openEntry(open.id))
if (open) {
diary.dropStash(open.id)
await diary.guard(() => diary.openEntry(open.id))
}
hooks.focusList()
},
},
@@ -168,6 +171,7 @@ export const commands: CommandSpec[] = [
const open = requireOpen()
if (!open) return
diary.dirty = false
diary.dropStash(open.id)
await diary.guard(() => diary.openEntry(open.id, diary.editing))
diary.say(`entry:${open.id} reloaded`)
},
+115 -9
View File
@@ -37,6 +37,9 @@ class Diary {
/** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */
clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off')
/** Pending debounced write, if the draft has changed since the last save. */
#autosave: ReturnType<typeof setTimeout> | null = null
get selected(): EntrySummary | undefined {
return this.entries[this.cursor]
}
@@ -51,6 +54,71 @@ class Diary {
}
}
/*
* Autosave. Typing is where work gets lost, so a draft goes to localStorage
* on every change — cheap, synchronous, survives a killed tab — and to the
* server a couple of seconds after typing stops.
*/
static readonly AUTOSAVE_MS = 2500
#stashKey(id: number) {
return `diary:draft:${id}`
}
/** Call whenever the draft changes. Replaces a bare `dirty = true`. */
touch() {
this.dirty = true
this.stash()
if (this.#autosave) clearTimeout(this.#autosave)
this.#autosave = setTimeout(() => void this.#writeBack(), Diary.AUTOSAVE_MS)
}
stash() {
if (!this.open) return
try {
localStorage.setItem(this.#stashKey(this.open.id), JSON.stringify(this.draft))
} catch {
/* a full or disabled localStorage must not stop the typing */
}
}
dropStash(id: number) {
try {
localStorage.removeItem(this.#stashKey(id))
} catch {
/* nothing to do */
}
}
#readStash(id: number): typeof this.draft | null {
try {
const raw = localStorage.getItem(this.#stashKey(id))
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
async #writeBack() {
this.#autosave = null
if (!this.open || !this.dirty) return
await this.guard(() => this.save({ quiet: true }))
}
/**
* Write out a pending draft before doing something that would replace it.
* Without this, clicking another entry in the list discarded unsaved work
* without a word.
*/
async flush() {
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
}
async boot() {
try {
const { user } = await api.me()
@@ -96,6 +164,8 @@ class Diary {
}
async openEntry(id: number, edit = false) {
if (this.open?.id !== id) await this.flush()
const entry = await api.getEntry(id)
this.open = entry
this.draft = { title: entry.title, body: entry.body, created_at: entry.created_at }
@@ -104,6 +174,17 @@ class Diary {
this.pane = 'entry'
const index = this.entries.findIndex((e) => e.id === id)
if (index >= 0) this.cursor = index
// A stash that differs from what the server has is work the last session
// did not manage to write. Vim would call this a swap file.
const stashed = this.#readStash(id)
if (stashed && (stashed.body !== entry.body || stashed.title !== entry.title)) {
this.draft = stashed
this.dirty = true
this.say('recovered an unsaved draft — :w to keep it, :e! to discard', 'error')
} else {
this.dropStash(id)
}
}
async openSelected(edit = false) {
@@ -121,20 +202,45 @@ class Diary {
this.say(`new entry #${entry.id}`)
}
async save() {
async save({ quiet = false } = {}) {
if (!this.open) return
const saved = await api.updateEntry(this.open.id, {
title: this.draft.title.trim(),
body: this.draft.body,
created_at: this.draft.created_at,
})
// Snapshot what goes over the wire: an autosave races the typing that
// triggered it, and anything typed since must stay marked unsaved.
const sent = { ...this.draft, title: this.draft.title.trim() }
const saved = await api.updateEntry(this.open.id, sent)
this.open = saved
this.dirty = false
await this.refresh()
this.say(`"entry:${saved.id}" ${saved.body.split('\n').length}L written`)
this.dirty =
this.draft.body !== sent.body ||
this.draft.title.trim() !== sent.title ||
this.draft.created_at !== sent.created_at
if (this.dirty) this.touch()
else this.dropStash(saved.id)
// The response carries the same excerpt the list query builds, so the row
// can usually be patched in place rather than re-reading the whole list.
if (!this.patch(saved)) await this.refresh()
if (!quiet) this.say(`"entry:${saved.id}" ${saved.body.split('\n').length}L written`)
}
/**
* Fold a freshly saved entry back into its list row. Returns false when the
* row cannot be patched in place — a new entry, or one whose date moved it
* somewhere else in the order — and the caller should re-read the list.
*/
patch(entry: Entry): boolean {
const index = this.entries.findIndex((e) => e.id === entry.id)
if (index < 0 || this.entries[index].created_at !== entry.created_at) return false
const { id, title, excerpt, created_at, updated_at, shared, share_token } = entry
this.entries[index] = { id, title, excerpt, created_at, updated_at, shared, share_token }
return true
}
async deleteEntry(id: number) {
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
this.dropStash(id)
await api.deleteEntry(id)
if (this.open?.id === id) {
this.open = null