Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod note;
mod project;
mod split;
mod vault;
mod versions;
mod watcher;

use std::collections::{BTreeMap, HashSet};
Expand Down Expand Up @@ -707,9 +708,13 @@ fn update_note(
.filter(|t| !t.is_empty())
.collect();
note.frontmatter.model = model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
note.body = format!("\n{}\n", body.trim());
let before = std::mem::replace(&mut note.body, format!("\n{}\n", body.trim()));
note.frontmatter.updated = note::now_rfc3339();

if versions::is_destructive(&before, &note.body) {
versions::snapshot(&open.vault.root, &id, &content);
}

note.write_to(&path)
.map_err(|e| err("could not write note", e))?;

Expand Down Expand Up @@ -1694,9 +1699,16 @@ fn save(state: &State<AppState>, id: &str, edit: impl FnOnce(&mut Note)) -> Resu
let content = std::fs::read_to_string(&path).map_err(|e| err("could not read note", e))?;

let mut note = Note::parse(&content, &title_from_path(&path));
let before = note.body.clone();
edit(&mut note);
note.frontmatter.updated = note::now_rfc3339();

// A save that drops most of a note is a rewrite or a bug, and the note
// is the only copy either way. Keep the old text before overwriting it.
if versions::is_destructive(&before, &note.body) {
versions::snapshot(&open.vault.root, id, &content);
}

note.write_to(&path)
.map_err(|e| err("could not write note", e))?;
sync_mirror(&note);
Expand Down
129 changes: 129 additions & 0 deletions app/src-tauri/src/versions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Last-resort snapshots of a note's previous text.
//!
//! A note is the user's only copy. Editing shrinks and grows a body a line at a
//! time, so a save that discards most of one is not typing — it is a
//! replacement, and the only cases that produce one are a deliberate rewrite
//! and a bug. The rewrite is cheap to archive; the bug is unrecoverable without
//! it, so the previous text is kept before the write either way.
//!
//! This module only ever *adds* files under the vault's own `.sudonotes/`
//! folder. It never edits, moves or deletes a note, and the only files it
//! removes are its own older snapshots for the same note.

use std::path::{Path, PathBuf};

use crate::vault::INDEX_DIR;

const VERSIONS_DIR: &str = "versions";

/// Bodies below this are too small for "half of it went" to mean anything —
/// clearing a two-line note is ordinary editing.
const MIN_BODY_BYTES: usize = 200;

/// How much of a body a single save may drop before it is archived first.
const KEEP_RATIO: f64 = 0.5;

/// Snapshots kept per note, newest first. Markdown is tiny; this is generous.
const KEEP_PER_NOTE: usize = 20;

/// Whether replacing `before` with `after` discards enough of the note to be
/// worth keeping the old text.
pub fn is_destructive(before: &str, after: &str) -> bool {
let before = before.trim();
if before.len() < MIN_BODY_BYTES {
return false;
}
(after.trim().len() as f64) < (before.len() as f64) * KEEP_RATIO
}

fn versions_dir(vault_root: &Path, id: &str) -> PathBuf {
vault_root.join(INDEX_DIR).join(VERSIONS_DIR).join(id)
}

/// Archive `raw` — the note's full file contents as they are on disk right now
/// — before it is overwritten. Best effort: failing to keep a snapshot must
/// never be the reason a save fails.
pub fn snapshot(vault_root: &Path, id: &str, raw: &str) {
let dir = versions_dir(vault_root, id);
if std::fs::create_dir_all(&dir).is_err() {
return;
}

// Sortable and filename-safe, so rotation can order by name alone.
let stamp = crate::note::now_rfc3339().replace(':', "-");
let path = dir.join(format!("{stamp}.md"));
// A second save inside the same second must not clobber the first snapshot.
let path = if path.exists() {
dir.join(format!("{stamp}-{}.md", ulid::Ulid::generate()))
} else {
path
};
if std::fs::write(&path, raw).is_err() {
return;
}

rotate(&dir);
}

/// Keep the newest `KEEP_PER_NOTE` snapshots for one note and drop the rest.
fn rotate(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
.collect();
if files.len() <= KEEP_PER_NOTE {
return;
}
files.sort();
let cutoff = files.len() - KEEP_PER_NOTE;
for path in &files[..cutoff] {
let _ = std::fs::remove_file(path);
}
}

#[cfg(test)]
mod tests {
use super::*;

fn body(n: usize) -> String {
"idea bubble text\n".repeat(n)
}

#[test]
fn ordinary_editing_is_not_destructive() {
let before = body(30);
// A few lines removed by hand.
let after = body(28);
assert!(!is_destructive(&before, &after));
}

#[test]
fn short_notes_are_never_destructive() {
assert!(!is_destructive("a short note", ""));
}

#[test]
fn losing_most_of_a_long_note_is_destructive() {
assert!(is_destructive(&body(75), &body(3)));
}

#[test]
fn snapshot_writes_and_rotates() {
let root = std::env::temp_dir().join(format!("sudonotes-versions-{}", ulid::Ulid::generate()));
std::fs::create_dir_all(root.join(INDEX_DIR)).unwrap();

for n in 0..KEEP_PER_NOTE + 5 {
snapshot(&root, "note-id", &format!("version {n}"));
}

let dir = versions_dir(&root, "note-id");
let kept = std::fs::read_dir(&dir).unwrap().count();
assert_eq!(kept, KEEP_PER_NOTE);

std::fs::remove_dir_all(&root).ok();
}
}
47 changes: 31 additions & 16 deletions app/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3071,24 +3071,39 @@ export function Editor() {
}
if (!update.docChanged || applying.current) return;
const id = activeId.current;
if (id) {
const docStr = update.state.doc.toString();
const scrollTop = update.view.scrollDOM.scrollTop;
const vaultPath = useStore.getState().vaultPath ?? "";

editorStateCache.set(id, {
state: update.state,
scrollTop,
updatedAt: Date.now(),
});
const active = useStore.getState().active;
// The ref and the store are updated by separate paths, so they can fall
// out of step for a tick. Persisting the document under a mismatched id
// writes one note's text over another's, so nothing is filed under `id`
// until the store agrees it is the note on screen. The reload effect
// resyncs the document, and the keystroke is re-saved from there.
if (!id || !active || active.id !== id) {
if (id && active) {
console.warn(
"sudonotes: dropped a save for",
id,
"while the open note was",
active.id,
);
}
return;
}

saveHistoryDebounced(vaultPath, id, update.state, scrollTop);
useStore.getState().queueSave(id, docStr);
const docStr = update.state.doc.toString();
const scrollTop = update.view.scrollDOM.scrollTop;
const vaultPath = useStore.getState().vaultPath ?? "";

const active = useStore.getState().active;
if (active && active.id === id && active.body !== docStr) {
useStore.setState({ active: { ...active, body: docStr } });
}
editorStateCache.set(id, {
state: update.state,
scrollTop,
updatedAt: Date.now(),
});

saveHistoryDebounced(vaultPath, id, update.state, scrollTop);
useStore.getState().queueSave(id, docStr);

if (active.body !== docStr) {
useStore.setState({ active: { ...active, body: docStr } });
}
}),
];
Expand Down
21 changes: 16 additions & 5 deletions app/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,17 +1093,26 @@ export const useStore = create<AppState>((set, get) => ({

const active = get().active;
if (!active) return;
const id = active.id;

try {
const fresh = await api.readNote(active.id);
const fresh = await api.readNote(id);

// The read is a round trip, and the user can pick another note while it
// is in flight. Writing the note captured above back into `active` would
// then reopen a note they have already left — and, worse, leave the
// editor holding one note's text under another's id. Anything stale is
// simply dropped; the note that is open now has its own refresh.
const current = get().active;
if (!current || current.id !== id) return;

// Mid-edit, the text on disk is older than what is on screen, so it must
// not be taken. The issue states are not the user's to edit and change
// without them, so those are merged in regardless — otherwise a sync that
// lands between two keystrokes is simply lost, and a bubble keeps showing
// a state its issue left minutes ago.
if (get().dirty) {
set({ active: { ...active, issueStates: fresh.issueStates } });
set({ active: { ...current, issueStates: fresh.issueStates } });
return;
}

Expand All @@ -1112,11 +1121,13 @@ export const useStore = create<AppState>((set, get) => ({
// note on a text change left the editor showing a stale issue state
// forever. Only the doc version is gated, because bumping it reloads
// CodeMirror and moves the cursor.
const textChanged = fresh.body !== active.body || fresh.title !== active.title;
const textChanged = fresh.body !== current.body || fresh.title !== current.title;
set(textChanged ? { active: fresh, docVersion: get().docVersion + 1 } : { active: fresh });
} catch {
// The note was deleted or moved while it was open.
set({ active: null, backlinks: [] });
// The note was deleted or moved while it was open — unless the user has
// already moved on, in which case this says nothing about what is open.
const current = get().active;
if (current?.id === id) set({ active: null, backlinks: [] });
}
},

Expand Down