From 60abff1717d0895d9f501f59c6b0e22390fed2e9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 1 Sep 2026 12:34:35 -0700 Subject: [PATCH] Expose notes over IPC and drop them with the project --- app/src-tauri/src/commands/mod.rs | 1 + app/src-tauri/src/commands/notes_commands.rs | 33 +++++++++++++++++++ .../src/commands/project_commands.rs | 9 +++++ app/src-tauri/src/lib.rs | 4 +++ app/src-tauri/src/storage/notes_store.rs | 11 +++++++ 5 files changed, 58 insertions(+) create mode 100644 app/src-tauri/src/commands/notes_commands.rs diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index cf2fb81..6ba4199 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod help_commands; pub mod inspect_commands; pub mod install_helper_commands; pub mod migration_commands; +pub mod notes_commands; pub mod project_commands; pub mod settings_commands; pub mod settings_export_commands; diff --git a/app/src-tauri/src/commands/notes_commands.rs b/app/src-tauri/src/commands/notes_commands.rs new file mode 100644 index 0000000..82137e0 --- /dev/null +++ b/app/src-tauri/src/commands/notes_commands.rs @@ -0,0 +1,33 @@ +use crate::models::Note; +use crate::storage::notes_store; + +/// Every project's notes, oldest concept first: pinned notes, then most +/// recently edited. +/// +/// Sorted here rather than in the webview so the dock and the tab — two views +/// of the same list — cannot drift into two different orders. +#[tauri::command] +pub async fn list_notes(project_id: String) -> Result, String> { + let mut notes = notes_store::load(&project_id)?; + notes.sort_by(|a, b| { + b.pinned + .cmp(&a.pinned) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + Ok(notes) +} + +/// Insert or replace one note. +/// +/// There is deliberately no whole-list setter. A bulk write is exactly the +/// clobbering this store's per-project file exists to avoid, and every caller +/// here is editing one note. +#[tauri::command] +pub async fn save_note(project_id: String, note: Note) -> Result { + notes_store::upsert(&project_id, note) +} + +#[tauri::command] +pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> { + notes_store::delete(&project_id, ¬e_id) +} diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 4768638..ac93bfe 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -722,6 +722,15 @@ pub async fn remove_project( // holding an entire snapshot image that nothing will ever reference again. crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; + // A project's notes are the one piece of its state that is purely the + // user's prose, so removal takes them with it rather than leaving an + // orphan file keyed by an id nothing will ever look up again. Logged and + // not propagated: an orphaned notes file is harmless, and a project that + // cannot be removed is not. + if let Err(e) = crate::storage::notes_store::clear(&project_id) { + log::warn!("Could not remove notes for project {}: {}", project_id, e); + } + // Stop and remove container if it exists. Everything named in `report` // below is what will be unreachable the moment this function drops the // project record — see [`ProjectRemovalReport`] and diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b789ad8..950cf35 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -470,6 +470,10 @@ pub fn run() { commands::project_commands::stop_project_container, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, + // Notes + commands::notes_commands::list_notes, + commands::notes_commands::save_note, + commands::notes_commands::delete_note, // Container base-image migration commands::migration_commands::get_container_staleness, commands::migration_commands::migrate_project_to_base, diff --git a/app/src-tauri/src/storage/notes_store.rs b/app/src-tauri/src/storage/notes_store.rs index 9a5afd0..f61231d 100644 --- a/app/src-tauri/src/storage/notes_store.rs +++ b/app/src-tauri/src/storage/notes_store.rs @@ -150,6 +150,7 @@ fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> } fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> { + let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner()); let path = notes_path_in(dir, project_id); match fs::remove_file(&path) { Ok(()) => Ok(()), @@ -334,4 +335,14 @@ mod tests { clear_in(&dir, "p1").unwrap(); // idempotent std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() { + // `remove_project` must not be able to fail because a project simply + // never had any notes — an orphaned notes file is harmless, a project + // that cannot be removed is not. + let dir = temp_dir("removal"); + assert!(clear_in(&dir, "never-had-notes").is_ok()); + std::fs::remove_dir_all(&dir).ok(); + } }