diff --git a/src/routes/entries.rs b/src/routes/entries.rs index 23d6e7c..469c8e5 100644 --- a/src/routes/entries.rs +++ b/src/routes/entries.rs @@ -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 { .ok_or(AppError::NotFound)?; let share_token: Option = 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::()), + body, created_at: row.get("created_at"), updated_at: row.get("updated_at"), shared: share_token.is_some(), diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 7507fb4..21c4347 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -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({ diff --git a/web/src/lib/EntryPane.svelte b/web/src/lib/EntryPane.svelte index 654bf45..91802d2 100644 --- a/web/src/lib/EntryPane.svelte +++ b/web/src/lib/EntryPane.svelte @@ -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() diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a3eca16..72b63df 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,6 +12,7 @@ export interface Entry { id: number title: string body: string + excerpt: string created_at: number updated_at: number shared: boolean diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index c34e87b..ea2e011 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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`) }, diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 380e0bb..2b0c028 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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 | 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