feat: local note snapshots and the Versions tab
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Every save of a changed note keeps a snapshot (at most one per 5 minutes, newest 20, under
.notescraft/history), engine history.list/history.read, a Versions tab with Compare and Restore.
Task 31 marked built (git history existed), task 34 deferred by the owner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-19 16:05:29 -03:00
parent 6ecbeadcff
commit 46d5c3ec96
26 changed files with 467 additions and 41 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ Pending, by area:
- **Code blocks, grammars and formatting (done, 15 of 15 tasks, specs/code-blocks-and-formatting):** `toml` and `ts` fences now highlight through two original grammars embedded in `notescraft-syntax` (`crates/syntax/grammars/*.sublime-syntax`), and the batch review has a third check, "Add missing language labels": `blocks::detect_language` names the language of an unlabeled fence conservatively (shebang, PHP, HTML, XML, JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML, on the first 4 KiB and 40 non-blank lines) and each `ChangeKind::Label` is a zero-length insert right after the fence run that always needs review (`batch.start` takes `labels`, on by default). Not built — the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. A note with 50 code blocks draws its first frame in 15 ms median (release), 87 ms in a cold process.
- **Editor previews (done, 11 of 11 tasks, specs/editor-previews):** not built or limited — no swatches or arrows in Source mode or in rows carrying inline math, and a hex that Obsidian reads as a tag (`#cba6f7` in prose) stays a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only if the diagram parser returns an unrenderable kind (today it answers `NoHeader` for one it does not know, so those show the Error chip). Scanning 100 rows of 10 colours (1,000 hits) takes 0.05 ms median, 0.15 ms worst, in release.
- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), audio embeds with Play/Stop through the system player, video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers, user themes, the graph timelapse and Source-mode heading folding (30), workspaces (32) and heading/block links (33) built; unbuilt: hand check of the packaged app and a Windows playback run.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers, user themes, the graph timelapse and Source-mode heading folding (30), workspaces (32), heading/block links (33) and note versions (31, local snapshots plus the git history) built; unbuilt: hand check of the packaged app and a Windows playback run.
- **Linux and Windows feedback (2026-10-19):** built: the Explorer calendar folds (chevron, remembered, folded on a first run in a window under 720 pt), the Explorer toolbar is laid out by hand (filter box plus two pill buttons, fixed gaps, flush right), the empty Bookmarks section is gone, the menu bar words are flat text with a soft hover/open background, the Related tab scrolls, a new Outline pane (count, filter, folding, active heading, scroll), a performance overlay (`view.performance`: fps, frame ms, memory, CPU; `/proc`, `ps` or PowerShell, no new dependency), and a first-run UI scale taken from the desktop on Linux when the window was not told its scale (`QT_SCALE_FACTOR`, `GDK_SCALE`, `Xft.dpi`, GNOME text scaling). Not confirmed on those systems: the title bar blink (the bar is now drawn whole on every frame and sits above the cards) and Explorer icons drawn out of bounds on a fast scroll (not reproduced offscreen; rows are now clipped to the viewport). Windows paths (PowerShell sampler, menu bar) were not run.
- **Editor (built, see phase 14):** spell-check needs an owner decision (new dependency or in-house checker); open: mobile bottom toolbar, inline fix popover, block menu by keyboard, embed with other text on one line, checkboxes inside embeds, embed refresh after editing another tab.
- **Editor settings:** the page has only Vim mode; more settings need editor features first (line numbers, spellcheck, font size, ...), then sub-menus for them.
+2
View File
@@ -114,6 +114,8 @@ pub const READ_COMMANDS: &[&str] = &[
"trash.list",
"workspace.list",
"workspace.load",
"history.list",
"history.read",
"note.list",
"note.read",
"note.properties",
+20
View File
@@ -96,6 +96,11 @@ fn core_specs() -> Vec<CommandSpec<Session>> {
ParamKind::String,
"the workspace name",
)),
CommandSpec::new("history.list", "List versions", "Lists the local snapshots of a note, newest first", |_| true, history_list)
.with_param(ParamSpec::required("path", ParamKind::String, "the note")),
CommandSpec::new("history.read", "Read a version", "Returns the text of one local snapshot", |_| true, history_read)
.with_param(ParamSpec::required("path", ParamKind::String, "the note"))
.with_param(ParamSpec::required("id", ParamKind::Integer, "the snapshot id from history.list")),
CommandSpec::new("note.delete", "Delete note", "Moves a note to the trash", writable, note_delete).with_param(ParamSpec::required(
"path",
ParamKind::String,
@@ -289,6 +294,21 @@ fn workspace_delete(session: &mut Session, params: &Value) -> CommandResult {
json(json!({ "name": name, "deleted": true }))
}
/// `history.list`: the local snapshots of a note.
fn history_list(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
let versions: Vec<Value> = session.note_snapshots(&path).into_iter().map(|snapshot| json!({ "id": snapshot.id, "bytes": snapshot.bytes })).collect();
json(json!({ "path": path, "versions": versions }))
}
/// `history.read`: the text of one snapshot.
fn history_read(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
let id = u64::try_from(offset_param(params, "id")?).unwrap_or(0);
let text = session.read_snapshot(&path, id).map_err(|error| command_error(&error))?;
json(json!({ "path": path, "id": id, "text": text }))
}
/// `note.delete`: moves a note to the trash.
fn note_delete(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
+1
View File
@@ -107,6 +107,7 @@ pub use crate::session::Session;
pub use crate::settings_store::{load, load_at, load_checked, load_checked_at, save, save_at};
pub use crate::slash::{SlashEntry, filter_entries, trigger_ok};
pub use crate::spec::{CommandResult, CommandSpec};
pub use crate::vault::snapshots::Snapshot;
pub use notescraft_vault::{FileCount, LARGE_FOLDER_FILES, count_files};
#[cfg(test)]
+3
View File
@@ -274,6 +274,9 @@ impl crate::session::Session {
if let Some(buffer) = self.buffers.get_mut(&path) {
buffer.mark_saved();
}
if crate::session::is_markdown(&path) {
self.snapshot_note(&path, &text);
}
Ok(written)
}
}
+1
View File
@@ -13,6 +13,7 @@ pub(crate) mod notes;
pub(crate) mod pdf_preview;
pub(crate) mod properties;
pub(crate) mod session;
pub(crate) mod snapshots;
pub(crate) mod trash;
pub mod workspaces;
pub use find::switcher;
+131
View File
@@ -0,0 +1,131 @@
//! Local snapshots of a note: every save that changed the text, at most one every few minutes, the newest few
//! kept under `.notescraft/history/` (what Obsidian calls File recovery, for a vault with or without git).
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Error, Result};
use crate::vault::session::Session;
/// Snapshots kept per note; older ones are deleted as new ones arrive.
pub const KEEP: usize = 20;
/// Shortest time between two snapshots of one note, in milliseconds.
pub const MIN_GAP_MS: u64 = 5 * 60 * 1000;
/// Largest note that is snapshotted, in bytes.
pub const MAX_BYTES: usize = 2 * 1024 * 1024;
/// One kept version of a note.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Snapshot {
/// When it was taken, in Unix milliseconds; also its id.
pub id: u64,
/// Its size in bytes.
pub bytes: u64,
}
/// A short stable name for a note path: the folder its snapshots live in.
fn folder_of(path: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in path.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!(".notescraft/history/{hash:016x}")
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|since| u64::try_from(since.as_millis()).ok()).unwrap_or(0)
}
impl Session {
/// The snapshots of the note `path`, newest first.
#[must_use]
pub fn note_snapshots(&self, path: &str) -> Vec<Snapshot> {
let Ok(entries) = std::fs::read_dir(self.vault.root().join(folder_of(path))) else { return Vec::new() };
let mut found: Vec<Snapshot> = entries
.flatten()
.filter_map(|entry| {
let id = entry.file_name().to_str()?.strip_suffix(".md")?.parse::<u64>().ok()?;
Some(Snapshot { id, bytes: entry.metadata().ok()?.len() })
})
.collect();
found.sort_by_key(|snapshot| std::cmp::Reverse(snapshot.id));
found
}
/// The text of the snapshot `id` of the note `path`.
///
/// # Errors
///
/// [`Error::NotFound`] when there is no such snapshot, [`Error::Vault`] when it is not UTF-8.
pub fn read_snapshot(&self, path: &str, id: u64) -> Result<String> {
let file = self.vault.root().join(format!("{}/{id}.md", folder_of(path)));
let bytes = std::fs::read(file).map_err(|_| Error::NotFound(format!("no snapshot {id} of `{path}`")))?;
String::from_utf8(bytes).map_err(|error| Error::Vault(error.to_string()))
}
/// Keeps `text` as the newest snapshot of `path` when it differs from the last one and the last one is older than
/// [`MIN_GAP_MS`]; trims the older ones to [`KEEP`]. Never fails the save it follows: a problem here is only
/// a missing snapshot.
pub(crate) fn snapshot_note(&mut self, path: &str, text: &str) {
self.snapshot_note_at(path, text, now_ms());
}
/// [`Session::snapshot_note`] with the time given, for tests.
pub(crate) fn snapshot_note_at(&mut self, path: &str, text: &str, now: u64) {
if text.len() > MAX_BYTES || self.vault.is_read_only() {
return;
}
let known = self.note_snapshots(path);
if let Some(newest) = known.first()
&& (now.saturating_sub(newest.id) < MIN_GAP_MS || self.read_snapshot(path, newest.id).is_ok_and(|last| last == text))
{
return;
}
let folder = folder_of(path);
if self.vault.write_private(&format!("{folder}/{now}.md"), text.as_bytes()).is_err() {
return;
}
for old in known.iter().skip(KEEP.saturating_sub(1)) {
let _ = std::fs::remove_file(self.vault.root().join(format!("{folder}/{}.md", old.id)));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(name: &str) -> Option<(Session, std::path::PathBuf)> {
let root = std::env::temp_dir().join(format!("nc-snap-{name}-{}", std::process::id()));
std::fs::create_dir_all(&root).ok()?;
Some((Session::open(&root).ok()?, root))
}
#[test]
fn a_changed_text_is_kept_once_per_gap_and_the_oldest_are_trimmed() {
let Some((mut session, root)) = session("keep") else { return };
session.snapshot_note_at("a.md", "one", 1_000_000);
session.snapshot_note_at("a.md", "two", 1_000_000 + MIN_GAP_MS - 1);
assert_eq!(session.note_snapshots("a.md").len(), 1, "too soon after the last one");
session.snapshot_note_at("a.md", "one", 1_000_000 + MIN_GAP_MS);
assert_eq!(session.note_snapshots("a.md").len(), 1, "the same text again is not a version");
session.snapshot_note_at("a.md", "two", 1_000_000 + MIN_GAP_MS);
let found = session.note_snapshots("a.md");
assert_eq!(found.iter().map(|snapshot| snapshot.id).collect::<Vec<_>>(), [1_000_000 + MIN_GAP_MS, 1_000_000], "newest first");
assert_eq!(session.read_snapshot("a.md", 1_000_000).ok().as_deref(), Some("one"));
for step in 2..40_u64 {
session.snapshot_note_at("a.md", &format!("text {step}"), 1_000_000 + step * MIN_GAP_MS);
}
assert_eq!(session.note_snapshots("a.md").len(), KEEP);
assert!(session.note_snapshots("b.md").is_empty(), "another note has none");
assert!(session.read_snapshot("a.md", 5).is_err());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn folders_differ_by_path_and_stay_inside_the_private_folder() {
assert_ne!(folder_of("a.md"), folder_of("b.md"));
assert!(folder_of("../../escape").starts_with(".notescraft/history/"));
assert!(!folder_of("../../escape").contains(".."));
}
}
+2
View File
@@ -35,6 +35,8 @@ const CORE_IDS: &[&str] = &[
"note.create",
"note.delete",
"note.extract",
"history.list",
"history.read",
"trash.empty",
"workspace.delete",
"workspace.list",
+24
View File
@@ -43,3 +43,27 @@ fn hostile_names_and_oversized_layouts_are_refused() {
assert!(registry.run("workspace.save", &mut session, &json!({"name": "big", "layout": big})).is_err());
assert_eq!(registry.run("workspace.list", &mut session, &json!({})).expect("list")["workspaces"], json!([]));
}
#[test]
fn saving_a_note_keeps_a_local_snapshot_a_command_lists_and_reads() {
let vault = temp_vault("snapshots");
let config = temp_config("snapshots");
std::fs::write(vault.path().join("n.md"), "first\n").expect("seed");
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open(vault.path()).expect("open");
session.use_settings_dir(config.path());
session.open_buffer("n.md").expect("buffer");
session.buffer_mut("n.md").expect("buffer").edit(0..5, "second").expect("edit");
session.save_buffer("n.md").expect("save");
session.buffer_mut("n.md").expect("buffer").edit(0..6, "third").expect("edit");
session.save_buffer("n.md").expect("save again at once");
let listed = registry.run("history.list", &mut session, &json!({"path": "n.md"})).expect("list");
let versions = listed["versions"].as_array().expect("array");
assert_eq!(versions.len(), 1, "the second save came inside the gap");
let id = versions[0]["id"].as_u64().expect("id");
let read = registry.run("history.read", &mut session, &json!({"path": "n.md", "id": id})).expect("read");
assert_eq!(read["text"], "second\n");
assert!(registry.run("history.read", &mut session, &json!({"path": "n.md", "id": 1})).is_err());
assert_eq!(registry.run("history.list", &mut session, &json!({"path": "other.md"})).expect("list")["versions"], json!([]));
}
+8
View File
@@ -102,6 +102,7 @@ no-link-here = "No link at cursor"
no-fold-here = "No fold at cursor"
restored = "Restored"
workspace-saved = "Workspace saved:"
version-restored = "Version restored; undo brings the text back"
workspace-loaded = "Workspace loaded:"
whiteboard-exported = "Exported whiteboard to"
@@ -1200,3 +1201,10 @@ just-now = "just now"
minutes-ago = "{{count}} min ago"
hours-ago = "{{count}} h ago"
days-ago = "{{count}} d ago"
[ui.versions-view]
title = "Versions of"
none = "No versions yet"
none-help = "A version is kept when you save a changed note, at most every few minutes"
compare = "Compare"
restore = "Restore"
+1
View File
@@ -58,6 +58,7 @@ pub use views::note_view;
pub use views::properties_view;
pub use views::settings_view;
pub use views::trash_view;
pub use views::versions_view;
pub use views::whiteboard_view;
pub use widgets::autocomplete;
pub use widgets::block_menu;
@@ -141,6 +141,11 @@ impl NotesApp {
tab.view = crate::center::ViewMode::Visual;
}
}
if std::mem::take(&mut self.state.versions_request)
&& let Some(path) = self.tabs.active_path().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note).map(str::to_owned)
{
self.open_note(&crate::tabs::versions_tab(&path));
}
if std::mem::take(&mut self.state.workspace_save_request) {
self.menus.prompt = Some((
crate::context_menu::MenuAction { kind: crate::context_menu::MenuKind::SaveWorkspace, path: String::new(), folder: false },
+54 -36
View File
@@ -60,6 +60,59 @@ impl History {
/// A command body over the UI state.
type Handler = fn(&mut UiState, &serde_json::Value) -> notescraft_engine::CommandResult;
/// The View menu's commands about what the vault keeps: the trash, note versions and workspaces.
fn register_library_commands(registry: &mut Registry<UiState>) -> Result<(), notescraft_engine::Error> {
let mut trash = CommandSpec::new(
"view.trash",
"Open the trash",
"Lists what was deleted from the vault, with Restore and Empty",
|_| true,
|state: &mut UiState, _| {
state.trash_request = true;
Ok(serde_json::Value::Null)
},
);
trash.menu_path = vec![String::from("View"), String::from("Open the trash")];
registry.register(trash)?;
let mut versions = CommandSpec::new(
"view.note_versions",
"Note versions",
"Lists the local snapshots of the active note, with Compare and Restore",
|_| true,
|state: &mut UiState, _| {
state.versions_request = true;
Ok(serde_json::Value::Null)
},
);
versions.menu_path = vec![String::from("View"), String::from("Note versions")];
registry.register(versions)?;
let mut save_workspace = CommandSpec::new(
"view.save_workspace",
"Save workspace",
"Saves the open tabs and panel sizes under a name",
|_| true,
|state: &mut UiState, _| {
state.workspace_save_request = true;
Ok(serde_json::Value::Null)
},
);
save_workspace.menu_path = vec![String::from("View"), String::from("Save workspace")];
registry.register(save_workspace)?;
let mut load_workspace = CommandSpec::new(
"view.load_workspace",
"Load workspace",
"Puts a saved set of tabs and panel sizes back",
|_| true,
|state: &mut UiState, _| {
state.workspace_load_request = true;
Ok(serde_json::Value::Null)
},
);
load_workspace.menu_path = vec![String::from("View"), String::from("Load workspace")];
registry.register(load_workspace)?;
Ok(())
}
/// The View menu's commands: split, stacked tabs, pop-out, performance, reading mode, trash and workspaces.
fn register_view_commands(registry: &mut Registry<UiState>) -> Result<(), notescraft_engine::Error> {
let mut split = CommandSpec::new(
@@ -122,42 +175,7 @@ fn register_view_commands(registry: &mut Registry<UiState>) -> Result<(), notesc
);
reading.menu_path = vec![String::from("View"), String::from("Reading mode")];
registry.register(reading)?;
let mut trash = CommandSpec::new(
"view.trash",
"Open the trash",
"Lists what was deleted from the vault, with Restore and Empty",
|_| true,
|state: &mut UiState, _| {
state.trash_request = true;
Ok(serde_json::Value::Null)
},
);
trash.menu_path = vec![String::from("View"), String::from("Open the trash")];
registry.register(trash)?;
let mut save_workspace = CommandSpec::new(
"view.save_workspace",
"Save workspace",
"Saves the open tabs and panel sizes under a name",
|_| true,
|state: &mut UiState, _| {
state.workspace_save_request = true;
Ok(serde_json::Value::Null)
},
);
save_workspace.menu_path = vec![String::from("View"), String::from("Save workspace")];
registry.register(save_workspace)?;
let mut load_workspace = CommandSpec::new(
"view.load_workspace",
"Load workspace",
"Puts a saved set of tabs and panel sizes back",
|_| true,
|state: &mut UiState, _| {
state.workspace_load_request = true;
Ok(serde_json::Value::Null)
},
);
load_workspace.menu_path = vec![String::from("View"), String::from("Load workspace")];
registry.register(load_workspace)?;
register_library_commands(registry)?;
Ok(())
}
@@ -18,6 +18,10 @@ use crate::tree::TreeResponse;
/// The id of the vault root in the folder picker (no folder path is a bare slash).
const ROOT_PICK: &str = "/";
/// The time now in Unix milliseconds.
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|since| u64::try_from(since.as_millis()).ok()).unwrap_or(0)
}
/// Window height, in points, below which the first run folds the Explorer calendar.
const SHORT_WINDOW_HEIGHT: f32 = 720.0;
@@ -496,6 +500,9 @@ impl NotesApp {
if response.toast_dismissed {
let _ = self.toast.take();
}
if let Some((note, action)) = response.versions {
self.handle_versions(&note, action, now);
}
if let Some(action) = response.trash.clone() {
self.handle_trash(action);
}
@@ -533,6 +540,32 @@ impl NotesApp {
}
}
/// Applies what a versions tab asked for: compare a snapshot with the note as it is, or put it back.
fn handle_versions(&mut self, note: &str, action: crate::versions_view::VersionsAction, now: f64) {
use crate::versions_view::VersionsAction;
let id = match action {
VersionsAction::Compare(id) | VersionsAction::Restore(id) => id,
};
let Ok(old) = self.session.read_snapshot(note, id) else { return };
let current = self.session.buffer(note).map(|buffer| buffer.text().to_owned()).or_else(|| self.session.read_note(note).ok()).unwrap_or_default();
match action {
VersionsAction::Compare(_) => match crate::diff_view::compare(&old, &current) {
Ok(diff) => {
let revision = Some(crate::trash_view::ago(now_ms(), id));
self.source.diff = Some(crate::diff_view::DiffSelection { path: note.to_owned(), staged: false, revision, diff, review: false });
}
Err(error) => self.toast = Some(error.to_string()),
},
VersionsAction::Restore(_) => {
self.open_note(note);
self.ensure_active_loaded();
let length = self.editing.editor_of(note).map_or(current.len(), |tab| tab.editor.text.len());
self.replace_source(note, 0..length, &old, now);
self.toast = Some(crate::app::strings::version_restored().to_owned());
}
}
}
/// Applies what the trash tab asked for: put an item back or erase the trash.
fn handle_trash(&mut self, action: crate::trash_view::TrashAction) {
use crate::trash_view::TrashAction;
@@ -44,6 +44,8 @@ pub(in crate::shell::app) struct Views {
pub images_path: Option<String>,
/// The notes the open note embeds, preloaded with the images.
pub embeds: std::sync::Arc<crate::note_view::EmbedNotes>,
/// The versions tab's note, when its list was read, and the list.
versions: Option<(String, std::time::Instant, Vec<notescraft_engine::Snapshot>)>,
/// The themes the Settings tab lists and when they were read.
themes: Option<(std::time::Instant, Vec<crate::settings_view::ThemeInfo>)>,
/// The trash tab's list and when it was read.
@@ -105,6 +107,21 @@ impl Views {
ctx.request_repaint_after(QUERY_REFRESH);
}
/// The versions tab: the snapshots of the note, read again once a second.
fn versions(&mut self, ui: &mut egui::Ui, inputs: &mut CenterInputs<'_>, response: &mut CenterResponse) {
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
let Some(note) = inputs.path.and_then(crate::versions_view::note_of).map(str::to_owned) else { return };
if self.versions.as_ref().is_none_or(|(path, at, _)| *path != note || at.elapsed() > REFRESH) {
self.versions = Some((note.clone(), std::time::Instant::now(), inputs.session.note_snapshots(&note)));
}
ui.ctx().request_repaint_after(REFRESH);
let now_ms =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|since| u64::try_from(since.as_millis()).ok()).unwrap_or(0);
let list = self.versions.as_ref().map_or(&[][..], |(_, _, list)| list.as_slice());
let title = crate::tabs::TabKind::of(&note).title(&note);
response.versions = crate::versions_view::show(ui, inputs.tokens, title, list, now_ms, !inputs.session.is_read_only()).map(|action| (note, action));
}
/// The trash tab: the vault's trash re-read once a second, and again after an action.
fn trash(&mut self, ui: &mut egui::Ui, inputs: &mut CenterInputs<'_>, response: &mut CenterResponse) {
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
@@ -168,6 +185,7 @@ impl Views {
}
TabKind::Calendar => self.calendar(ui, inputs, response),
TabKind::Trash => self.trash(ui, inputs, response),
TabKind::Versions => self.versions(ui, inputs, response),
_ => self.file(ui, inputs, response),
}
}
+4
View File
@@ -76,6 +76,9 @@ pub struct UiState {
/// Set by `view.save_workspace`: ask for a name next frame; not saved.
#[serde(skip)]
pub workspace_save_request: bool,
/// Set by `view.note_versions`: open the versions tab of the active note next frame; not saved.
#[serde(skip)]
pub versions_request: bool,
/// Set by `view.load_workspace`: open the workspace picker next frame; not saved.
#[serde(skip)]
pub workspace_load_request: bool,
@@ -181,6 +184,7 @@ impl UiState {
reading_request: false,
trash_request: false,
workspace_save_request: false,
versions_request: false,
workspace_load_request: false,
sidebar_tab: crate::sidebar_tabs::SidebarTab::Explorer,
right_visible: true,
+1
View File
@@ -45,6 +45,7 @@ pub mod palette;
pub mod perf;
pub mod tabs;
pub mod trash_view;
pub mod versions_view;
#[cfg(test)]
mod tests {
+1
View File
@@ -15,5 +15,6 @@ table! {
whiteboard_exported = "ui.app.whiteboard-exported";
restored = "ui.app.restored";
workspace_saved = "ui.app.workspace-saved";
version_restored = "ui.app.version-restored";
workspace_loaded = "ui.app.workspace-loaded";
}
+9
View File
@@ -0,0 +1,9 @@
//! Copy for the versions tab.
table! {
title = "ui.versions-view.title";
none = "ui.versions-view.none";
none_help = "ui.versions-view.none-help";
compare = "ui.versions-view.compare";
restore = "ui.versions-view.restore";
}
+2
View File
@@ -119,6 +119,8 @@ pub struct CenterResponse {
pub open_folder: Option<String>,
/// What the trash tab asked for.
pub trash: Option<crate::trash_view::TrashAction>,
/// What a versions tab asked for: the note and the action.
pub versions: Option<(String, crate::versions_view::VersionsAction)>,
/// A source edit made in place in the Visual view.
pub note_edit: Option<crate::note_view::NoteEdit>,
/// A change inside an embedded note, for that note's file.
+1
View File
@@ -14,4 +14,5 @@ pub mod note_view;
pub mod properties_view;
pub mod settings_view;
pub mod trash_view;
pub mod versions_view;
pub mod whiteboard_view;
@@ -0,0 +1,100 @@
//! The versions tab of a note: its local snapshots, newest first, each with Compare (the diff to the note as it is
//! now) and Restore.
use egui::{Align2, CornerRadius, FontId, Pos2, Rect, ScrollArea, Sense, Ui, Vec2};
use notescraft_engine::Snapshot;
use crate::{
text::versions_view as strings,
theme::{Tokens, color},
};
/// Outer padding of the page, points.
const PAGE_PADDING: i8 = 16;
/// Height of one row, points.
const ROW_HEIGHT: f32 = 48.0;
/// Space between rows, points.
const ROW_GAP: f32 = 6.0;
/// Size of a button, points.
const BUTTON: Vec2 = Vec2::new(88.0, 30.0);
/// Most rows drawn.
const MAX_ROWS: usize = 100;
/// What the user did in the versions view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionsAction {
/// Show the diff between this snapshot and the note as it is now.
Compare(u64),
/// Replace the note's text with this snapshot (one undoable edit).
Restore(u64),
}
/// The tab identity of the versions of `path`.
#[must_use]
pub fn note_of(tab: &str) -> Option<&str> {
tab.strip_prefix(crate::tabs::VERSIONS_PREFIX)
}
fn button(ui: &mut Ui, tokens: &Tokens, rect: Rect, label: &str, salt: (u64, &str)) -> bool {
let colors = &tokens.theme.colors;
let response = ui.interact(rect, ui.id().with(("versions-button", salt)), Sense::click());
let fill = if response.hovered() { color(colors.selection) } else { color(colors.surface) };
ui.painter().rect_filled(rect, CornerRadius::same(crate::style::radius_u8(tokens.radius_medium())), fill);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, FontId::new(13.0, crate::fonts::semibold()), color(colors.text));
response.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// Draws the versions of the note named `title` at `now_ms`; `writable` is false for a read-only vault.
pub fn show(ui: &mut Ui, tokens: &Tokens, title: &str, versions: &[Snapshot], now_ms: u64, writable: bool) -> Option<VersionsAction> {
let colors = &tokens.theme.colors;
let mut action = None;
egui::Frame::new().inner_margin(egui::Margin::same(PAGE_PADDING)).show(ui, |ui| {
ui.set_width(ui.available_width());
let (head, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 40.0), Sense::hover());
let text = format!("{} {title}", strings::title());
ui.painter().text(Pos2::new(head.left(), head.center().y), Align2::LEFT_CENTER, text, FontId::new(20.0, crate::fonts::semibold()), color(colors.text));
ui.add_space(8.0);
if versions.is_empty() {
ui.add_space(48.0);
ui.vertical_centered(|ui| {
crate::icons::show(ui, "history", 36.0, color(colors.text_muted));
ui.add_space(8.0);
ui.label(egui::RichText::new(strings::none()).size(14.0).color(color(colors.text_muted)));
ui.label(egui::RichText::new(strings::none_help()).size(12.0).color(color(colors.text_muted)));
});
return;
}
ScrollArea::vertical().id_salt("notescraft-versions-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = ROW_GAP;
for version in versions.iter().take(MAX_ROWS) {
let (row, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::hover());
ui.painter().rect_filled(row, CornerRadius::same(crate::style::radius_u8(tokens.radius_medium())), color(colors.surface_2));
let age = crate::trash_view::ago(now_ms, version.id);
ui.painter().text(
Pos2::new(row.left() + 16.0, row.top() + 15.0),
Align2::LEFT_CENTER,
&age,
FontId::new(14.0, crate::fonts::semibold()),
color(colors.text),
);
let size = format!("{} B", version.bytes);
ui.painter().text(
Pos2::new(row.left() + 16.0, row.top() + 33.0),
Align2::LEFT_CENTER,
size,
FontId::proportional(12.0),
color(colors.text_muted),
);
let restore = Rect::from_center_size(Pos2::new(row.right() - 12.0 - BUTTON.x / 2.0, row.center().y), BUTTON);
let compare = restore.translate(Vec2::new(-BUTTON.x - 8.0, 0.0));
if button(ui, tokens, compare, strings::compare(), (version.id, "compare")) {
action = Some(VersionsAction::Compare(version.id));
}
if writable && button(ui, tokens, restore, strings::restore(), (version.id, "restore")) {
action = Some(VersionsAction::Restore(version.id));
}
}
});
});
action
}
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::theme::{Tokens, color};
mod kind;
mod layout;
mod paint;
pub use kind::{CALENDAR_PATH, FOLDER_PREFIX, GRAPH_PATH, SETTINGS_PATH, TRASH_PATH, TabKind, folder_tab};
pub use kind::{CALENDAR_PATH, FOLDER_PREFIX, GRAPH_PATH, SETTINGS_PATH, TRASH_PATH, TabKind, VERSIONS_PREFIX, folder_tab, versions_tab};
use paint::paint_tab;
/// Height of the tab strip, in points.
+14 -1
View File
@@ -6,12 +6,20 @@ pub const GRAPH_PATH: &str = "notescraft://graph";
pub const CALENDAR_PATH: &str = "notescraft://calendar";
/// Identity of the single settings tab.
pub const SETTINGS_PATH: &str = "notescraft://settings";
/// Prefix of the identity of a versions tab; the note path follows.
pub const VERSIONS_PREFIX: &str = "notescraft://versions/";
/// Identity of the single trash tab.
pub const TRASH_PATH: &str = "notescraft://trash";
/// Prefix of the identity of a folder tab; the vault-relative folder path follows (empty: the vault root).
pub const FOLDER_PREFIX: &str = "notescraft://folder/";
/// The identity of the tab that shows the versions of the note `path`.
#[must_use]
pub fn versions_tab(path: &str) -> String {
format!("{VERSIONS_PREFIX}{path}")
}
/// The identity of the tab that shows `folder`.
#[must_use]
pub fn folder_tab(folder: &str) -> String {
@@ -39,6 +47,8 @@ pub enum TabKind {
Folder,
/// What was deleted from the vault, without a backing file.
Trash,
/// The local snapshots of one note, without a backing file.
Versions,
/// Unrecognised file opened as text.
Text,
}
@@ -53,6 +63,7 @@ impl TabKind {
SETTINGS_PATH => Self::Settings,
TRASH_PATH => Self::Trash,
_ if path.starts_with(FOLDER_PREFIX) => Self::Folder,
_ if path.starts_with(VERSIONS_PREFIX) => Self::Versions,
_ => match path.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).as_deref() {
Some("md") => Self::Note,
Some("canvas") => Self::Canvas,
@@ -66,7 +77,7 @@ impl TabKind {
/// Whether this tab owns a vault file.
#[must_use]
pub const fn is_file(self) -> bool {
!matches!(self, Self::Graph | Self::Calendar | Self::Settings | Self::Folder | Self::Trash)
!matches!(self, Self::Graph | Self::Calendar | Self::Settings | Self::Folder | Self::Trash | Self::Versions)
}
/// Title without the folder or extension, or the virtual view's label.
@@ -77,6 +88,7 @@ impl TabKind {
Self::Calendar => "Calendar",
Self::Settings => "Settings",
Self::Trash => "Trash",
Self::Versions => "Versions",
Self::Folder => {
let folder = path.strip_prefix(FOLDER_PREFIX).unwrap_or_default();
folder.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("Vault")
@@ -97,6 +109,7 @@ impl TabKind {
Self::Settings => "settings",
Self::Folder => "folder",
Self::Trash => "trash-2",
Self::Versions => "history",
Self::Canvas => "layout-dashboard",
Self::Whiteboard => "pencil",
Self::Diagram => "git-branch",
+26
View File
@@ -602,3 +602,29 @@ fn a_heading_folds_its_section_in_the_source_view_from_the_margin_chevron() {
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.is_empty()), "a second click unfolds it");
assert_eq!(d.app.editing.tabs.get("f.md").map(|tab| tab.editor.text.as_str()), Some(text), "folding never changes the text");
}
#[test]
fn the_versions_tab_lists_a_snapshot_and_restores_it_over_the_current_text() {
let mut d = driver(&[("n.md", "first\n")]);
d.app.session.open_buffer("n.md").expect("buffer");
d.app.session.buffer_mut("n.md").expect("buffer").edit(0..5, "second").expect("edit");
d.app.session.save_buffer("n.md").expect("save");
d.app.session.close_buffer("n.md", true).expect("close");
d.app.open_note("n.md");
d.settle();
if let Some(tab) = d.app.editing.tabs.get_mut("n.md") {
tab.editor.text = String::from("changed\n");
}
d.app.run_command("view.note_versions", &json!({}));
let texts = d.settle();
assert!(texts.iter().any(|p| p.text.starts_with("Versions of")), "{:?}", names(&texts));
let compare = find(&texts, "Compare").pos + egui::vec2(10.0, 6.0);
d.click(compare);
assert!(d.app.source.diff.is_some(), "Compare opens the diff page");
d.app.source.diff = None;
d.app.run_command("view.note_versions", &json!({}));
let texts = d.settle();
d.click(find(&texts, "Restore").pos + egui::vec2(10.0, 6.0));
d.settle();
assert_eq!(d.app.editing.tabs.get("n.md").map(|tab| tab.editor.text.as_str()), Some("second\n"), "the snapshot is back in the editor");
}
+4 -2
View File
@@ -55,12 +55,14 @@ Confirmed missing by grep: no `query` fence handling, no RTL handling. Pop-out a
## Round 4: basic Obsidian features still missing after round 3 (checked 2026-10-19 against the code; none built)
- [ ] 31. Note history (Obsidian's File recovery): per-note snapshots or git history shown as a list with a diff and Restore. `git.log`/`git.show` exist in the engine; there is no per-note history view and no snapshots for vaults without git.
- [x] 31. (ALREADY BUILT, missed by the round 4 check: Source sidebar tab > History for the open note lists its commits (`git.log` with the path), a click shows the diff to the current text (`panels/source_panel/history.rs`, `diff_view`) and Restore asks first and runs `git.restore_version`; it needs a git repository (`git.init` from the same panel). OPEN as 31b: snapshots for a vault without git (a local version list every few minutes, Obsidian's File recovery)) Note history (Obsidian's File recovery): per-note snapshots or git history shown as a list with a diff and Restore. `git.log`/`git.show` exist in the engine; there is no per-note history view and no snapshots for vaults without git.
Done when: a driven test lists the versions of a note, shows the diff to the current text and restores one as one undoable edit
- [x] 32. (DONE: `view.save_workspace` (name prompt) and `view.load_workspace` (picker, palette kind `Workspaces`) in the View menu; a layout is the tabs in order, the active one, pins, panel widths, panel visibility and the sidebar tab; engine `workspace.list/save/load/delete`, files in `.notescraft/workspaces/`, tests `engine/tests/workspaces.rs` and `a_saved_workspace_brings_the_tabs_and_the_active_one_back_and_skips_notes_that_are_gone`; not saved: the split pane and per-note view mode; no delete control in the UI, only the command) Workspaces: save and load the layout (open tabs, split, sidebar tab, panel widths) by name.
Done when: a test saves a layout, changes it and loads it back
- [x] 33. (DONE: commands `editor.copy_block_link` (adds the `^id` once) and `editor.copy_heading_link`, and "Copy link to this heading" in the Outline row's context menu; `blocks::ensure_block_id`, `FilesConfig::link_text_to` (wiki or Markdown links), tests in `blocks/src/ids.rs` and `copying_a_block_link_adds_the_id_once_and_copying_a_heading_link_needs_no_edit`) Copy a heading or block link from the editor (`[[Note#Heading]]`, `[[Note#^id]]`) and a command to add a block id.
Done when: a test produces the link text for a heading and for a block and adds a missing `^id` once
- [ ] 34. Live Preview editing in the Visual view for inline syntax (the Visual view opens a whole block as source on click; Obsidian edits the rendered line in place).
- [x] 34. (DEFERRED by the owner 2026-10-19: a click keeps opening the whole block as source) Live Preview editing in the Visual view for inline syntax (the Visual view opens a whole block as source on click; Obsidian edits the rendered line in place).
Done when: decided with the owner first (large change to `note_view`)
- [ ] 35. Hand checks that need a real window: task 24 plus the Linux and Windows items in ROADMAP "Linux and Windows feedback".
- [x] 31b. (DONE: every save of a changed note keeps a snapshot at most every 5 minutes, newest 20 in `.notescraft/history/` (`vault/snapshots/`, hooked into `save_buffer`), engine `history.list` and `history.read`; View > Note versions opens a Versions tab listing them with Compare (the diff page) and Restore (one undoable edit); tests in `snapshots`, `engine/tests/workspaces.rs` and `the_versions_tab_lists_a_snapshot_and_restores_it_over_the_current_text`; the Source panel's History still needs git) Local snapshots of a note for a vault without git: saved on write when the text changed and the last one is a few minutes old, newest 20 kept in `.notescraft/history/`, listed with a diff and Restore.
Done when: engine tests for snapshot, list and restore (one undoable edit) pass and the Source panel's History lists them when there is no repository