mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
f42c0c6e4b
commit
54743346fe
6 files changed
+129
-13
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Entry {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
excerpt: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
shared: boolean
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user