From 24ffc1b5f4bd435345a186b44946fd4979cbe8e7 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:18:56 -0700 Subject: [PATCH 01/41] fix: close data-loss paths in the note store - Reconcile inline tags on body save and promote explicit adds to manual, so removed #tokens stop resurrecting and chip removals survive the next keystroke. - Back up the database via VACUUM INTO before every migration and refuse databases from a newer schema instead of misreading them. - Recover from a corrupt database by setting it aside as .corrupt-N and starting fresh; corruption is a distinct error class routed to recovery. - Verify the WAL pragma actually applied and skip no-op update patches. - Grow the core suite to cover each of these paths, including a real v1-fixture migration test. --- src-tauri/core/src/domain.rs | 10 +- src-tauri/core/src/error.rs | 33 +++++- src-tauri/core/src/store.rs | 160 +++++++++++++++++++++++++++-- src-tauri/core/tests/store_test.rs | 160 ++++++++++++++++++++++++++++- 4 files changed, 350 insertions(+), 13 deletions(-) diff --git a/src-tauri/core/src/domain.rs b/src-tauri/core/src/domain.rs index 71b7aca..59e214a 100644 --- a/src-tauri/core/src/domain.rs +++ b/src-tauri/core/src/domain.rs @@ -143,7 +143,10 @@ mod tests { #[test] fn hash_mid_word_is_not_a_tag() { - assert_eq!(extract_inline_tags("the C#language a#b"), Vec::::new()); + assert_eq!( + extract_inline_tags("the C#language a#b"), + Vec::::new() + ); } #[test] @@ -175,7 +178,10 @@ mod tests { #[test] fn title_strips_tag_hashes_keeping_words() { - assert_eq!(derive_title("call sam #q3-budget now"), "call sam q3-budget now"); + assert_eq!( + derive_title("call sam #q3-budget now"), + "call sam q3-budget now" + ); } #[test] diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs index e42a3da..ae36909 100644 --- a/src-tauri/core/src/error.rs +++ b/src-tauri/core/src/error.rs @@ -13,6 +13,11 @@ pub enum AppError { Storage(String), #[error("{0}")] Migration(String), + /// A damaged or unreadable database file. Kept distinct from `Storage` so + /// recovery can act on it, but reports the same external error code per + /// API.md §11 (it is a storage failure to callers). + #[error("{0}")] + Corruption(String), } impl AppError { @@ -24,15 +29,32 @@ impl AppError { AppError::Conflict(_) => "CONFLICT", AppError::Storage(_) => "STORAGE_ERROR", AppError::Migration(_) => "MIGRATION_ERROR", + AppError::Corruption(_) => "STORAGE_ERROR", } } + + /// True for a damaged/unreadable database file. `Store::open_or_recover` + /// keys off this to decide a file is safe to set aside and start fresh. + pub fn is_corruption(&self) -> bool { + matches!(self, AppError::Corruption(_)) + } } impl From for AppError { fn from(e: rusqlite::Error) -> Self { - match e { + match &e { rusqlite::Error::QueryReturnedNoRows => AppError::NotFound("not found".into()), - other => AppError::Storage(other.to_string()), + // SQLITE_CORRUPT / SQLITE_NOTADB mean the file itself is unusable, + // not a transient lock or a logical error; mark it recoverable. + rusqlite::Error::SqliteFailure(err, _) + if matches!( + err.code, + rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase + ) => + { + AppError::Corruption(e.to_string()) + } + _ => AppError::Storage(e.to_string()), } } } @@ -50,5 +72,12 @@ mod tests { assert_eq!(AppError::Conflict("x".into()).code(), "CONFLICT"); assert_eq!(AppError::Storage("x".into()).code(), "STORAGE_ERROR"); assert_eq!(AppError::Migration("x".into()).code(), "MIGRATION_ERROR"); + assert_eq!(AppError::Corruption("x".into()).code(), "STORAGE_ERROR"); + } + + #[test] + fn only_corruption_reports_corruption() { + assert!(AppError::Corruption("x".into()).is_corruption()); + assert!(!AppError::Storage("x".into()).is_corruption()); } } diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index fc93d6a..b8c22c1 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -7,10 +7,12 @@ use crate::error::{AppError, Result}; use crate::types::*; use chrono::{SecondsFormat, Utc}; use rusqlite::{params, Connection, OptionalExtension}; -use std::path::Path; +use std::path::{Path, PathBuf}; use uuid::Uuid; -const MIGRATIONS: &[&str] = &[ +/// Ordered schema migrations; user_version tracks how many have run. Public so +/// tests can build fixtures at a historical schema version. +pub const MIGRATIONS: &[&str] = &[ // v1 — initial schema r#" CREATE TABLE notes ( @@ -230,15 +232,43 @@ impl Store { // surfaces to the user as a hard "database is locked" error. conn.busy_timeout(std::time::Duration::from_secs(5)) .map_err(|e| AppError::Storage(format!("cannot set busy timeout: {e}")))?; - conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get::<_, String>(0))?; + // A filesystem that refuses WAL (some network mounts) leaves the + // connection silently in rollback mode, defeating the crash-safety this + // app relies on; treat that as an unusable storage location. + let journal_mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?; + if !journal_mode.eq_ignore_ascii_case("wal") { + return Err(AppError::Storage(format!( + "storage location does not support WAL journaling (got '{journal_mode}')" + ))); + } // NORMAL is the standard, crash-safe pairing with WAL: fsync at // checkpoints rather than on every commit. Safe against app crashes; only // an OS crash or power loss can drop commits still sitting in the WAL. conn.pragma_update(None, "synchronous", "NORMAL") .map_err(|e| AppError::Storage(format!("cannot set synchronous mode: {e}")))?; + // Snapshot an existing library before it is migrated so a failed or + // buggy migration is always recoverable. + Self::backup_before_migration(path, &conn)?; Self::init(conn) } + /// Open the store, recovering from a corrupt database file by setting it + /// aside and starting fresh. The returned bool is true only when recovery + /// happened. Non-corruption failures (permissions, a WAL-hostile mount) + /// propagate unchanged so a transient or fixable problem never discards + /// good data. + pub fn open_or_recover(path: &Path) -> Result<(Self, bool)> { + match Self::open(path) { + Ok(store) => Ok((store, false)), + Err(e) if e.is_corruption() => { + Self::move_corrupt_aside(path)?; + let store = Self::open(path)?; + Ok((store, true)) + } + Err(e) => Err(e), + } + } + /// In-memory store for tests that don't need restart semantics. pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory() @@ -250,7 +280,7 @@ impl Store { conn.pragma_update(None, "foreign_keys", "ON")?; let check: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?; if check != "ok" { - return Err(AppError::Storage(format!( + return Err(AppError::Corruption(format!( "database integrity check failed: {check}" ))); } @@ -263,6 +293,16 @@ impl Store { let current: i64 = self .conn .query_row("PRAGMA user_version", [], |r| r.get(0))?; + // A user_version past the last known migration means this file was + // written by a newer build; its schema is unknown to us, so refuse + // rather than run queries that assume the older shape. + if current > MIGRATIONS.len() as i64 { + return Err(AppError::Migration(format!( + "database schema v{current} was created by a newer version of \ + the app (this build knows up to v{})", + MIGRATIONS.len() + ))); + } for (idx, sql) in MIGRATIONS.iter().enumerate() { let target = (idx + 1) as i64; if target <= current { @@ -282,6 +322,70 @@ impl Store { Ok(()) } + /// Copy an existing library aside before migrating it. Runs only for a file + /// that already carries a schema older than the current one (0 < v < len); + /// a brand-new file has nothing to lose and a current file is not migrated. + /// A backup failure fails the open rather than migrating without a net. + fn backup_before_migration(path: &Path, conn: &Connection) -> Result<()> { + let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + if current <= 0 || current >= MIGRATIONS.len() as i64 { + return Ok(()); + } + let mut backup = path.as_os_str().to_os_string(); + backup.push(format!(".backup-v{current}")); + let backup_path = PathBuf::from(backup); + // VACUUM INTO refuses to overwrite; clear any leftover from a prior + // interrupted attempt first. + if backup_path.exists() { + std::fs::remove_file(&backup_path) + .map_err(|e| AppError::Storage(format!("cannot clear stale backup: {e}")))?; + } + // The path is interpolated as a SQL string literal, so double any single + // quotes it contains. + let escaped = backup_path.to_string_lossy().replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{escaped}'")) + .map_err(|e| match AppError::from(e) { + // A corrupt source keeps its classification so open_or_recover + // can still set the file aside instead of giving up. + AppError::Corruption(msg) => { + AppError::Corruption(format!("pre-migration backup failed: {msg}")) + } + other => AppError::Storage(format!("pre-migration backup failed: {other}")), + })?; + Ok(()) + } + + /// Rename a corrupt database and its WAL/SHM siblings to a free + /// ".corrupt-N" suffix so a fresh store can be created at the same path + /// without clobbering the salvaged file. + fn move_corrupt_aside(path: &Path) -> Result<()> { + let mut n = 1; + let target = loop { + let mut candidate = path.as_os_str().to_os_string(); + candidate.push(format!(".corrupt-{n}")); + let candidate = PathBuf::from(candidate); + if !candidate.exists() { + break candidate; + } + n += 1; + }; + std::fs::rename(path, &target) + .map_err(|e| AppError::Storage(format!("cannot set corrupt database aside: {e}")))?; + // WAL/SHM belong to the corrupt file; move them out of the way too so + // the fresh database starts clean. They may be absent. + for ext in ["-wal", "-shm"] { + let mut sibling = path.as_os_str().to_os_string(); + sibling.push(ext); + let sibling = PathBuf::from(sibling); + if sibling.exists() { + let mut sibling_target = target.as_os_str().to_os_string(); + sibling_target.push(ext); + let _ = std::fs::rename(&sibling, PathBuf::from(sibling_target)); + } + } + Ok(()) + } + fn fetch_note(&self, id: &str) -> Result { self.conn .query_row( @@ -338,7 +442,16 @@ impl Store { pub fn update_note(&mut self, id: &str, patch: UpdateNotePatch) -> Result { // Ensure existence first for a clean NOT_FOUND. - self.fetch_note(id)?; + let existing = self.fetch_note(id)?; + // An empty patch is a no-op: skip the UPDATE so version and updated_at + // are not bumped and recency-sorted lists keep their order. + if patch.title.is_none() + && patch.body.is_none() + && patch.is_pinned.is_none() + && patch.is_archived.is_none() + { + return Ok(existing); + } let title_is_auto: bool = self .conn .query_row( @@ -382,9 +495,33 @@ impl Store { ], )?; if let Some(body) = &patch.body { + // Reconcile inline tags with the new body: attach the tags it now + // mentions, then detach any inline-sourced edge whose #token is + // gone so removing a tag chip is not undone by the next save. + // Manual edges are pinned and never touched by a body edit. + let mut kept_ids: Vec = Vec::new(); for name in domain::extract_inline_tags(body) { let tag = tag_get_or_create(&tx, &name)?; attach_tag(&tx, id, &tag.id, "inline")?; + kept_ids.push(tag.id); + } + if kept_ids.is_empty() { + tx.execute( + "DELETE FROM note_tags WHERE note_id = ?1 AND source = 'inline'", + params![id], + )?; + } else { + let placeholders = vec!["?"; kept_ids.len()].join(", "); + let sql = format!( + "DELETE FROM note_tags WHERE note_id = ? AND source = 'inline' \ + AND tag_id NOT IN ({placeholders})" + ); + let mut args: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(kept_ids.len() + 1); + args.push(&id); + for tag_id in &kept_ids { + args.push(tag_id); + } + tx.execute(&sql, rusqlite::params_from_iter(args))?; } } tx.commit()?; @@ -451,9 +588,8 @@ impl Store { } if let Some(workspace_id) = &filter.workspace_id { - conditions.push( - "id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = ?)".into(), - ); + conditions + .push("id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = ?)".into()); args.push(Box::new(workspace_id.clone())); } @@ -622,6 +758,14 @@ impl Store { self.fetch_note(note_id)?; let tag = tag_get_or_create(&self.conn, name)?; attach_tag(&self.conn, note_id, &tag.id, "manual")?; + // attach_tag is INSERT OR IGNORE, so an edge already present as 'inline' + // keeps that source. An explicit add is a pin, so promote it to + // 'manual' and inline reconciliation will no longer detach it. + self.conn.execute( + "UPDATE note_tags SET source = 'manual' \ + WHERE note_id = ?1 AND tag_id = ?2 AND source = 'inline'", + params![note_id, tag.id], + )?; Ok(tag) } diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index 798cf75..a93d46b 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -1,6 +1,7 @@ //! Integration tests against real SQLite (tempfile / in-memory). //! Synthetic fixtures only, per TEST_PLAN.md §5. +use instantnotes_core::store::MIGRATIONS; use instantnotes_core::types::*; use instantnotes_core::{AppError, Store}; @@ -128,6 +129,100 @@ fn update_body_attaches_new_inline_tags() { assert!(names.contains(&"newtag".to_string())); } +#[test] +fn removing_inline_token_detaches_tag_but_keeps_tag_row() { + let mut s = store(); + let n = create(&mut s, "notes on #alpha and #beta"); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("notes on #beta only".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["beta".to_string()]); + // The tag itself survives, just unused. + let alpha = s + .list_tags() + .unwrap() + .into_iter() + .find(|t| t.tag.name == "alpha") + .expect("alpha tag row should survive detachment"); + assert_eq!(alpha.usage_count, 0); + + // A body with no tokens at all clears every inline edge. + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("no tags anymore".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(s.tags_for_note(&n.id).unwrap().is_empty()); +} + +#[test] +fn manually_added_tag_survives_body_edits() { + let mut s = store(); + let n = create(&mut s, "plain body"); + s.add_tag_to_note(&n.id, "pinned").unwrap(); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("edited body, still no tokens".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["pinned".to_string()]); +} + +#[test] +fn explicit_add_promotes_inline_tag_past_token_removal() { + let mut s = store(); + let n = create(&mut s, "working on #keeper today"); + // The explicit add pins the already-inline tag against body edits. + s.add_tag_to_note(&n.id, "keeper").unwrap(); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("token removed from body".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["keeper".to_string()]); +} + +#[test] +fn empty_patch_does_not_bump_version_or_updated_at() { + let mut s = store(); + let n = create(&mut s, "leave me alone"); + let u = s.update_note(&n.id, UpdateNotePatch::default()).unwrap(); + assert_eq!(u.version, n.version); + assert_eq!(u.updated_at, n.updated_at); +} + #[test] fn auto_derived_title_follows_body_updates() { let mut s = store(); @@ -456,7 +551,9 @@ fn rename_tag_normalizes_and_conflicts_error() { let _b = s.get_or_create_tag("beta").unwrap(); let renamed = s.update_tag(&a.id, Some("#Gamma".into()), None).unwrap(); assert_eq!(renamed.name, "gamma"); - let err = s.update_tag(&renamed.id, Some("beta".into()), None).unwrap_err(); + let err = s + .update_tag(&renamed.id, Some("beta".into()), None) + .unwrap_err(); assert_eq!(err.code(), "CONFLICT"); } @@ -526,6 +623,67 @@ fn notes_survive_reopen() { assert_eq!(names, vec!["idea".to_string()]); } +// ---- migrations / recovery ---- + +#[test] +fn migrate_refuses_user_version_above_known_migrations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("future.db"); + // A file stamped by a newer build: valid but with a schema version this + // build has never heard of. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.pragma_update(None, "user_version", (MIGRATIONS.len() + 1) as i64) + .unwrap(); + } + let err = match Store::open(&path) { + Ok(_) => panic!("open should refuse a future schema version"), + Err(e) => e, + }; + assert_eq!(err.code(), "MIGRATION_ERROR"); +} + +#[test] +fn open_migrates_v1_schema_and_leaves_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + // v1 fixture: only the first migration applied, user_version pinned at 1, + // exactly as an older build would have left the file. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch(MIGRATIONS[0]).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + } + let mut s = Store::open(&path).unwrap(); + // The v2 migration ran: workspaces (added in v2) is usable. + s.get_or_create_workspace("Migrated").unwrap(); + assert_eq!(s.list_workspaces().unwrap().len(), 1); + // And the pre-migration snapshot sits next to the database. + let backup = dir.path().join("legacy.db.backup-v1"); + assert!( + backup.exists(), + "expected pre-migration backup at {backup:?}" + ); +} + +#[test] +fn open_or_recover_sets_corrupt_file_aside_and_starts_fresh() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("garbage.db"); + std::fs::write(&path, b"\x7f not a sqlite database \x00\x01\x02garbage").unwrap(); + assert!( + Store::open(&path).is_err(), + "garbage must not open normally" + ); + + let (mut s, recovered) = Store::open_or_recover(&path).unwrap(); + assert!(recovered); + let n = create(&mut s, "fresh start"); + assert_eq!(s.get_note(&n.id, false).unwrap().id, n.id); + // The unreadable original was set aside, not destroyed. + assert!(dir.path().join("garbage.db.corrupt-1").exists()); +} + // keep AppError import used even if individual asserts change #[allow(dead_code)] fn _uses(_: AppError) {} From b36c28caaf11cf237109c477f234cc0921ae15fa Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:19:22 -0700 Subject: [PATCH 02/41] feat: flush pending edits on quit and recover corrupt databases - Menu, tray, and Dock quits ask the library window to flush queued edits and exit through quit_app; a hung webview is covered by an 800ms fallback so quit can never wedge. - The updater's restart exit code passes through the exit interception untouched, so an installed update is never stranded by the handshake. - Wire Store::open_or_recover at startup and tell the user with a dialog when a corrupt database was set aside. - Record global-shortcut registration failures in managed state, queryable by the frontend. --- src-tauri/src/lib.rs | 189 +++++++++++++++++++++++++++++++++---------- 1 file changed, 148 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5009fde..0516d6a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,10 +5,12 @@ use instantnotes_core::types::*; use instantnotes_core::{AppError, Store}; use serde::Serialize; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tauri::menu::{Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder}; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_opener::OpenerExt; struct AppState { @@ -271,11 +273,7 @@ fn get_setting(state: State<'_, AppState>, key: String) -> CmdResult, - key: String, - value: serde_json::Value, -) -> CmdResult<()> { +fn set_setting(state: State<'_, AppState>, key: String, value: serde_json::Value) -> CmdResult<()> { Ok(locked(&state)?.set_setting(&key, value)?) } @@ -548,6 +546,58 @@ fn open_url(app: AppHandle, url: String) { let _ = app.opener().open_url(&url, None::<&str>); } +// ---- quit handshake ---- +// Body edits are debounced in the webview, so exiting the process directly +// would drop the tail of whatever was just typed. Every quit path (menu, tray, +// Dock) instead emits "app:quit-requested"; the library window flushes its +// pending edits and answers with the quit_app command, which really exits. + +/// True once the frontend flushed and called quit_app, or once the fallback +/// gave up waiting. ExitRequested lets the exit proceed only when this is set, +/// so the flush handshake runs at most once per quit. +static QUIT_READY: AtomicBool = AtomicBool::new(false); + +/// How long a quit waits for the webview flush before exiting anyway. +const QUIT_FLUSH_GRACE_MS: u64 = 800; + +/// Ask the webviews to flush, then exit. The fallback timer exists because +/// quit must not block forever on a dead webview: if the frontend never +/// answers with quit_app, exit anyway after the grace period. +fn request_quit(app: &AppHandle) { + let _ = app.emit("app:quit-requested", ()); + let handle = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(QUIT_FLUSH_GRACE_MS)); + // swap keeps the fallback and quit_app from racing: whichever runs + // first marks the handshake done and the other becomes a no-op. + if !QUIT_READY.swap(true, Ordering::AcqRel) { + handle.exit(0); + } + }); +} + +/// Final leg of the handshake: the library webview has flushed pending edits. +#[tauri::command] +fn quit_app(app: AppHandle) { + QUIT_READY.store(true, Ordering::Release); + app.exit(0); +} + +// ---- shortcut status ---- + +/// Set once at startup when global-shortcut registration failed (another app +/// owns the hotkey). Queryable because the "shortcut:failed" event fires +/// before the library webview has listeners attached, so an event alone +/// would be lost. +struct ShortcutStatus { + failed: Option, +} + +#[tauri::command] +fn get_shortcut_failure(state: State<'_, ShortcutStatus>) -> Option { + state.failed.clone() +} + // ---- app shell ---- pub fn run() { @@ -596,11 +646,25 @@ pub fn run() { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; } - let store = Store::open(&db_path) - .map_err(|e| format!("cannot open store: {e}"))?; + let (store, recovered) = + Store::open_or_recover(&db_path).map_err(|e| format!("cannot open store: {e}"))?; app.manage(AppState { store: Mutex::new(store), }); + if recovered { + // Non-blocking on purpose: setup must finish (single-instance + // handshake, window creation) whether or not the user has + // acknowledged the dialog. + app.dialog() + .message( + "Your notes library could not be read, so a fresh one was \ + started. The unreadable file was kept next to it with a \ + \".corrupt\" suffix in case its contents can be recovered.", + ) + .title("Library recovered") + .kind(MessageDialogKind::Warning) + .show(|_| {}); + } // After an in-place update, refresh the cached app icon once. refresh_icon_cache_if_updated(&dir); @@ -610,13 +674,13 @@ pub fn run() { // (Services, Hide, Hide Others) is a macOS convention with no // Windows/Linux equivalent, so off macOS its Settings and Quit // entries live in the File submenu instead. - let settings_item = MenuItem::with_id( - app, - "settings", - "Settings…", - true, - Some("CmdOrCtrl+,"), - )?; + let settings_item = + MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+,"))?; + // Custom Quit instead of PredefinedMenuItem::quit(): the predefined + // item exits the process directly, skipping the flush handshake, so + // ⌘Q would drop the tail of whatever was being typed. + let quit_item = + MenuItem::with_id(app, "quit", "Quit InstantNotes", true, Some("CmdOrCtrl+Q"))?; #[cfg(target_os = "macos")] let app_submenu = SubmenuBuilder::new(app, "InstantNotes") .about(None) @@ -629,22 +693,12 @@ pub fn run() { .hide_others() .show_all() .separator() - .quit() + .item(&quit_item) .build()?; - let new_note_item = MenuItem::with_id( - app, - "new_note", - "New Note", - true, - Some("CmdOrCtrl+N"), - )?; - let export_item = MenuItem::with_id( - app, - "export_note", - "Export Note As…", - true, - None::<&str>, - )?; + let new_note_item = + MenuItem::with_id(app, "new_note", "New Note", true, Some("CmdOrCtrl+N"))?; + let export_item = + MenuItem::with_id(app, "export_note", "Export Note As…", true, None::<&str>)?; let file_submenu = { let builder = SubmenuBuilder::new(app, "File") .item(&new_note_item) @@ -655,7 +709,7 @@ pub fn run() { .separator() .item(&settings_item) .separator() - .quit(); + .item(&quit_item); builder.build()? }; let edit_submenu = SubmenuBuilder::new(app, "Edit") @@ -689,6 +743,7 @@ pub fn run() { show_library_window(app); let _ = app.emit("menu:export-note", ()); } + "quit" => request_quit(app), _ => {} }); @@ -708,10 +763,14 @@ pub fn run() { let open_library_item = MenuItem::with_id(app, "open_library", "Open Library", true, None::<&str>)?; - let about = - PredefinedMenuItem::about(app, Some("About InstantNotes"), None)?; - let check_updates = - MenuItem::with_id(app, "check_updates", "Check for Updates…", true, None::<&str>)?; + let about = PredefinedMenuItem::about(app, Some("About InstantNotes"), None)?; + let check_updates = MenuItem::with_id( + app, + "check_updates", + "Check for Updates…", + true, + None::<&str>, + )?; let repo = MenuItem::with_id(app, "open_repo", "Repository on GitHub", true, None::<&str>)?; let data_folder = @@ -760,7 +819,9 @@ pub fn run() { let _ = app.opener().open_url(REPO_URL, None::<&str>); } "open_data_dir" => open_data_folder(app), - "quit" => app.exit(0), + // Through the flush handshake, never a direct exit; see + // the quit handshake section. + "quit" => request_quit(app), _ => {} }) .build(app)?; @@ -785,10 +846,31 @@ pub fn run() { } else { Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space) }; - if let Err(e) = app.global_shortcut().register(shortcut) { - // Content-free log per SEC-001; conflict fallback UI is an M4 item. + // Human-readable label for the conflict notice; mirrors the + // registration matrix above and captureShortcut in platform.ts. + let shortcut_label = if cfg!(target_os = "macos") { + if cfg!(debug_assertions) { + "⌥⇧Space" + } else { + "⌥Space" + } + } else if cfg!(debug_assertions) { + "Ctrl+Shift+Alt+Space" + } else { + "Ctrl+Shift+Space" + }; + let shortcut_failure = app.global_shortcut().register(shortcut).err().map(|e| { + // Content-free log per SEC-001; the welcome screen surfaces + // the conflict to the user. eprintln!("global shortcut registration failed: {e}"); + shortcut_label.to_string() + }); + if let Some(label) = &shortcut_failure { + let _ = app.emit("shortcut:failed", label.clone()); } + app.manage(ShortcutStatus { + failed: shortcut_failure, + }); if let Some(library) = app.get_webview_window("library") { #[cfg(not(debug_assertions))] @@ -850,10 +932,32 @@ pub fn run() { export_theme_file, import_theme_file, export_note_file, - open_url + open_url, + quit_app, + get_shortcut_failure ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + // The updater's relaunch (request_restart) drives this exit + // with RESTART_EXIT_CODE and latches restart-on-exit inside + // Tauri. Preventing it would leave that latch set with no + // exit coming, stranding the freshly installed update, so + // the restart passes through untouched; updater.restart() + // flushes pending edits before it ever calls relaunch. + if code == Some(tauri::RESTART_EXIT_CODE) { + return; + } + // Exit paths that bypass the menu and tray (macOS Dock quit): + // hold the exit, run the same flush handshake, and rely on + // the same dead-webview fallback. + if !QUIT_READY.load(Ordering::Acquire) { + api.prevent_exit(); + request_quit(app); + } + } + }); } #[cfg(test)] @@ -873,7 +977,10 @@ mod tests { #[test] fn theme_file_round_trip() { let dir = std::env::temp_dir(); - let path = dir.join(format!("instantnotes-theme-{}.intheme.json", std::process::id())); + let path = dir.join(format!( + "instantnotes-theme-{}.intheme.json", + std::process::id() + )); let path_str = path.to_string_lossy().to_string(); let json = r#"{"id":"x","name":"X","version":1}"#.to_string(); From b1d9aa212b5dc2e1ba063359c20a8254188e62e4 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:19:22 -0700 Subject: [PATCH 03/41] feat: capture panel dismisses on blur and confirms saves - Clicking outside the always-on-top panel dismisses it with the draft preserved, guarded against in-flight saves and self-initiated hides. - A 300ms Saved beat confirms the write before the panel hides, so success reads as more than the window closing. - Cmd+Enter (Ctrl+Enter on Windows and Linux) saves and opens the library. - The quit handshake flushes the debounced draft before the process exits. --- src/routes/capture/+page.svelte | 62 ++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/src/routes/capture/+page.svelte b/src/routes/capture/+page.svelte index 16551a1..a6f2f4c 100644 --- a/src/routes/capture/+page.svelte +++ b/src/routes/capture/+page.svelte @@ -4,22 +4,32 @@ // Esc dismisses preserving the draft (CAP-011). import { onMount } from "svelte"; import { listen } from "@tauri-apps/api/event"; + import { getCurrentWindow } from "@tauri-apps/api/window"; import { createNote, deleteSetting, getSetting, hideCapture, + openLibrary, setSetting, } from "$lib/api/client"; import { debounce } from "$lib/debounce"; + import { modKey } from "$lib/platform"; import { theme } from "$lib/stores/theme.svelte"; const DRAFT_KEY = "capture.draft"; + // One visible beat of "Saved" before the panel hides, so success reads as more + // than the window merely closing. Small on purpose so it adds no real latency. + const SAVED_HINT_MS = 300; let text = $state(""); let saving = $state(false); + let saved = $state(false); let errorMsg = $state(null); let textarea: HTMLTextAreaElement; + // Set while we hide the panel ourselves so the blur that hiding triggers does + // not fire a second dismiss; cleared when focus returns on the next reveal. + let hiding = false; const persistDraft = debounce((value: string) => { void setSetting(DRAFT_KEY, value); @@ -41,9 +51,29 @@ void restoreDraft(); textarea?.focus(); }); + // Dismiss on outside click like Spotlight/Raycast/Things: this panel is + // always-on-top on every Space, so a click elsewhere would otherwise strand + // a floating window. dismiss(false) persists the draft, making this safe. + const unfocus = getCurrentWindow().onFocusChanged(({ payload: focused }) => { + if (focused) { + hiding = false; + return; + } + // Never dismiss mid-save, nor react to the blur our own hide just caused. + if (saving || hiding) return; + void dismiss(false); + }); + // Quit handshake: push a debounced draft write through before the process + // exits, so the draft is not 300ms stale on the next launch. Only the + // library window answers with quit_app. + const unlistenQuit = listen("app:quit-requested", () => { + persistDraft.flush(); + }); textarea?.focus(); return () => { void unlisten.then((fn) => fn()); + void unfocus.then((fn) => fn()); + void unlistenQuit.then((fn) => fn()); }; }); @@ -62,9 +92,14 @@ persistDraft(text); } - async function save() { + async function save(openLibraryAfter = false) { const body = text.trim(); if (!body) { + // Nothing to save; still honor the shortcut's intent to reveal the library. + // Mark the hide first: the library stealing focus fires a blur that would + // otherwise run a second, concurrent dismiss. + hiding = true; + if (openLibraryAfter) await openLibrary(); await dismiss(true); return; } @@ -74,14 +109,25 @@ text = ""; persistDraft.cancel(); void deleteSetting(DRAFT_KEY); - await hideCapture(); + // Hold "Saved" for one beat; the textarea stays disabled via `saving`. + saved = true; + await new Promise((resolve) => setTimeout(resolve, SAVED_HINT_MS)); + if (openLibraryAfter) await openLibrary(); + await hidePanel(); } catch { errorMsg = "Couldn't save — your text is kept here."; } finally { saving = false; + saved = false; } } + // Hide the panel ourselves, marking the hide so the blur it triggers is ignored. + async function hidePanel() { + hiding = true; + await hideCapture(); + } + async function dismiss(clearDraft = false) { persistDraft.flush(); if (clearDraft) { @@ -89,13 +135,14 @@ void deleteSetting(DRAFT_KEY); text = ""; } - await hideCapture(); + await hidePanel(); } function onKeydown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - void save(); + // Cmd/Ctrl+Enter also opens the library after saving (ctrl for win/linux parity). + void save(e.metaKey || e.ctrlKey); } else if (e.key === "Escape") { e.preventDefault(); void dismiss(false); @@ -123,8 +170,11 @@
{#if errorMsg} {errorMsg} + {:else if saved} + Saved {:else} save + {modKey}↵ library ⇧↵ newline esc close {/if} @@ -206,6 +256,10 @@ font-family: var(--font-meta); font-size: 10px; } + .saved { + color: var(--accent-text); + font-weight: 500; + } .error { color: var(--danger); } From 33bd5bc33256daf0db9dc4bbc566c56ffc11bc09 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:19:22 -0700 Subject: [PATCH 04/41] feat: add undo toasts and an in-app confirm dialog - Toast queue with a 5s auto-dismiss that pauses on hover, a visible cap of three, and FIFO eviction; timer logic is pure and tested. - Promise-based confirm store and an alertdialog host: Cancel holds initial focus, Tab is trapped, Escape and the scrim cancel, and focus returns to the invoker on close. - The dialog is a hard keyboard boundary: no key reaches the global shortcuts while it is open. --- src/lib/components/ConfirmDialog.svelte | 133 ++++++++++++++++++++++++ src/lib/components/Toast.svelte | 92 ++++++++++++++++ src/lib/stores/confirm.svelte.ts | 67 ++++++++++++ src/lib/stores/toasts.svelte.test.ts | 110 ++++++++++++++++++++ src/lib/stores/toasts.svelte.ts | 96 +++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 src/lib/components/ConfirmDialog.svelte create mode 100644 src/lib/components/Toast.svelte create mode 100644 src/lib/stores/confirm.svelte.ts create mode 100644 src/lib/stores/toasts.svelte.test.ts create mode 100644 src/lib/stores/toasts.svelte.ts diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..0ad4440 --- /dev/null +++ b/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,133 @@ + + +{#if confirmDialog.request} + {@const req = confirmDialog.request} + +{/if} + + diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..6271d0c --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,92 @@ + + +
+ {#each toasts.items as toast (toast.id)} +
toasts.pause(toast.id)} + onmouseleave={() => toasts.resume(toast.id)} + > + {toast.message} +
+ {#if toast.action} + + {/if} + +
+
+ {/each} +
+ + diff --git a/src/lib/stores/confirm.svelte.ts b/src/lib/stores/confirm.svelte.ts new file mode 100644 index 0000000..8a5d12a --- /dev/null +++ b/src/lib/stores/confirm.svelte.ts @@ -0,0 +1,67 @@ +// Promise-based confirm dialog for irreversible actions, replacing every +// window.confirm in the app. One host (ConfirmDialog.svelte) is mounted once +// in +page.svelte; call sites just `await confirmDialog.ask(...)`. + +export interface ConfirmOptions { + title: string; + body?: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: "danger" | "neutral"; +} + +export interface ConfirmRequest { + title: string; + body: string; + confirmLabel: string; + cancelLabel: string; + tone: "danger" | "neutral"; +} + +class ConfirmDialogStore { + request = $state(null); + + #resolve: ((ok: boolean) => void) | null = null; + // Focus returns here once the dialog closes, so a keyboard user lands back + // where they were rather than at the top of the document. + #invoker: HTMLElement | null = null; + + ask(options: ConfirmOptions): Promise { + // Only one confirm can be open at a time; a second call while one is + // pending settles the first as cancelled rather than stacking dialogs. + this.#settle(false); + this.#invoker = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + return new Promise((resolve) => { + this.#resolve = resolve; + this.request = { + title: options.title, + body: options.body ?? "", + confirmLabel: options.confirmLabel ?? "Confirm", + cancelLabel: options.cancelLabel ?? "Cancel", + tone: options.tone ?? "neutral", + }; + }); + } + + confirm(): void { + this.#settle(true); + } + + cancel(): void { + this.#settle(false); + } + + #settle(result: boolean): void { + if (!this.#resolve) return; + this.request = null; + const resolve = this.#resolve; + const invoker = this.#invoker; + this.#resolve = null; + this.#invoker = null; + resolve(result); + queueMicrotask(() => invoker?.focus()); + } +} + +export const confirmDialog = new ConfirmDialogStore(); diff --git a/src/lib/stores/toasts.svelte.test.ts b/src/lib/stores/toasts.svelte.test.ts new file mode 100644 index 0000000..3ec1f68 --- /dev/null +++ b/src/lib/stores/toasts.svelte.test.ts @@ -0,0 +1,110 @@ +// Queue, timer, and eviction tests for the toast store. No component is +// mounted here: the store's timer/queue logic is pure enough to drive with +// fake timers directly, per the toast host's own design note. +// +// Fresh module per test via vi.resetModules() + dynamic import (mirrors +// library.svelte.test.ts), so the module-level singleton starts clean and +// state never bleeds between tests. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function load() { + const mod = await import("$lib/stores/toasts.svelte"); + return mod.toasts; +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("toasts queue", () => { + it("show() queues a toast that auto-dismisses after ~5s", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + expect(toasts.items.map((t) => t.id)).toEqual([id]); + + await vi.advanceTimersByTimeAsync(4999); + expect(toasts.items).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(toasts.items).toHaveLength(0); + }); + + it("dismiss() removes a toast immediately and cancels its timer", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + toasts.dismiss(id); + expect(toasts.items).toHaveLength(0); + + // If the timer were not cancelled it would try to dismiss an already-gone + // toast; nothing should throw and the list stays empty either way. + await vi.advanceTimersByTimeAsync(5000); + expect(toasts.items).toHaveLength(0); + }); + + it("pausing while hovered holds the timer; resuming continues from where it left off", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + + await vi.advanceTimersByTimeAsync(4000); + toasts.pause(id); + // Paused: well past the original 5s deadline, the toast must still be there. + await vi.advanceTimersByTimeAsync(5000); + expect(toasts.items).toHaveLength(1); + + toasts.resume(id); + // ~1s of the original countdown remained when it was paused. + await vi.advanceTimersByTimeAsync(999); + expect(toasts.items).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(toasts.items).toHaveLength(0); + }); + + it("caps the visible stack at 3 and evicts the oldest (FIFO) past the cap", async () => { + const toasts = await load(); + const a = toasts.show("first"); + const b = toasts.show("second"); + const c = toasts.show("third"); + expect(toasts.items.map((t) => t.id)).toEqual([a, b, c]); + + const d = toasts.show("fourth"); + expect(toasts.items.map((t) => t.id)).toEqual([b, c, d]); + expect(toasts.items).toHaveLength(3); + }); + + it("an evicted toast's timer cannot fire a stray dismiss later", async () => { + const toasts = await load(); + toasts.show("first"); + const b = toasts.show("second"); + const c = toasts.show("third"); + const d = toasts.show("fourth"); + + // Just short of the surviving toasts' own 5s auto-dismiss: if the + // evicted toast's timer were left running it would be a harmless no-op + // at worst, but the stack must still hold exactly [b, c, d] until then. + await vi.advanceTimersByTimeAsync(4999); + expect(toasts.items.map((t) => t.id)).toEqual([b, c, d]); + }); + + it("activate() runs the toast's action and dismisses it", async () => { + const toasts = await load(); + const run = vi.fn(); + const id = toasts.show("Moved to Trash", { label: "Undo", run }); + + toasts.activate(id); + + expect(run).toHaveBeenCalledTimes(1); + expect(toasts.items).toHaveLength(0); + }); + + it("activate() on a toast without an action never throws", async () => { + const toasts = await load(); + const id = toasts.show("Trash emptied"); + expect(() => toasts.activate(id)).not.toThrow(); + expect(toasts.items).toHaveLength(0); + }); +}); diff --git a/src/lib/stores/toasts.svelte.ts b/src/lib/stores/toasts.svelte.ts new file mode 100644 index 0000000..b997d5a --- /dev/null +++ b/src/lib/stores/toasts.svelte.ts @@ -0,0 +1,96 @@ +// Undo-toast queue: reversible actions (soft delete) execute immediately and +// offer a short window to undo, instead of blocking on a confirm dialog. +// Timer/queue logic lives entirely in this class so toasts.svelte.test.ts can +// drive it with fake timers without mounting the host component. + +const AUTO_DISMISS_MS = 5000; +// How many toasts stack at once; a new one past the cap evicts the oldest +// (FIFO) rather than growing the stack or queueing silently. +const MAX_VISIBLE = 3; + +export interface ToastAction { + label: string; + run: () => void; +} + +export interface ToastItem { + readonly id: string; + readonly message: string; + readonly action?: ToastAction; +} + +class ToastStore { + items = $state([]); + + #nextId = 0; + #timers = new Map>(); + // Time left when a toast is paused (hovered), so resuming continues the + // countdown instead of restarting it. + #remaining = new Map(); + #startedAt = new Map(); + + /** Queue a toast; returns its id (dismiss/pause/resume/activate take it). */ + show(message: string, action?: ToastAction): string { + const id = `toast-${++this.#nextId}`; + const next = [...this.items, { id, message, action }]; + if (next.length > MAX_VISIBLE) { + const evicted = next.shift(); + if (evicted) this.#clearTimer(evicted.id); + } + this.items = next; + this.#schedule(id, AUTO_DISMISS_MS); + return id; + } + + /** Run a toast's action, then dismiss it. */ + activate(id: string): void { + const toast = this.items.find((t) => t.id === id); + this.dismiss(id); + toast?.action?.run(); + } + + dismiss(id: string): void { + this.#clearTimer(id); + this.items = this.items.filter((t) => t.id !== id); + } + + /** Hold the auto-dismiss while the toast is hovered. */ + pause(id: string): void { + const timer = this.#timers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + this.#timers.delete(id); + const started = this.#startedAt.get(id) ?? Date.now(); + const remaining = this.#remaining.get(id) ?? AUTO_DISMISS_MS; + this.#remaining.set(id, Math.max(0, remaining - (Date.now() - started))); + } + + /** Resume the auto-dismiss countdown from where it was paused. */ + resume(id: string): void { + if (!this.items.some((t) => t.id === id)) return; + this.#schedule(id, this.#remaining.get(id) ?? AUTO_DISMISS_MS); + } + + #schedule(id: string, ms: number): void { + // Replace, never stack: a leftover timer for the same id would fire the + // old, earlier dismissal and cut the new countdown short. + const existing = this.#timers.get(id); + if (existing !== undefined) clearTimeout(existing); + this.#startedAt.set(id, Date.now()); + this.#remaining.set(id, ms); + this.#timers.set( + id, + setTimeout(() => this.dismiss(id), ms), + ); + } + + #clearTimer(id: string): void { + const timer = this.#timers.get(id); + if (timer !== undefined) clearTimeout(timer); + this.#timers.delete(id); + this.#remaining.delete(id); + this.#startedAt.delete(id); + } +} + +export const toasts = new ToastStore(); From 8a19086ba56b7ee84b18d3359848061d542d0752 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:19:54 -0700 Subject: [PATCH 05/41] feat: honest save states and safe destructive flows in the library - Per-note dirty tracking: a failed write retries once, then shows Not saved while the edit stays queued for every later flush; Saved can no longer lie about unpersisted data. - Monotonic refresh tokens drop stale list and search responses; search keystrokes debounce 150ms with an instant clear. - flushPendingEdits performs exactly one no-retry write per dirty note; retry timers are tracked and cancelled on newer writes, drops, and destroys, so nothing fires after a flush settles. - Soft deletes flush the pending edit first, then offer Undo in a toast; permanent deletes act on ids snapshotted when their dialog opened (destroyNotes), so a drifted selection cannot be destroyed by mistake. - All five window.confirm sites are replaced by the shared dialog, and the global shortcuts ignore keys while it is open. - The updater flushes edits before relaunching; a stolen capture hotkey surfaces as a dismissible welcome-screen notice. --- src/lib/api/client.ts | 9 + src/lib/components/BulkActions.svelte | 18 +- src/lib/components/NoteEditor.svelte | 28 ++- src/lib/components/NoteList.svelte | 11 +- src/lib/components/Sidebar.svelte | 11 +- src/lib/components/WelcomeScreen.svelte | 41 ++++ src/lib/stores/library.svelte.ts | 269 +++++++++++++++++++++--- src/lib/stores/updater.svelte.ts | 6 + src/routes/+page.svelte | 40 +++- 9 files changed, 384 insertions(+), 49 deletions(-) diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 8abc1cd..4afca11 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -112,3 +112,12 @@ export const importThemeFile = (path: string) => // ---- note export ---- export const exportNoteFile = (path: string, contents: string) => call("export_note_file", { path, contents }); + +// ---- app lifecycle ---- +// Answer to "app:quit-requested": pending edits are flushed, exit for real now. +export const quitApp = () => call("quit_app"); +// Label of the capture shortcut when startup registration failed, else null. +// A command rather than an event alone: the failure happens before the library +// webview has listeners attached, so an event would be lost. +export const getShortcutFailure = () => + call("get_shortcut_failure"); diff --git a/src/lib/components/BulkActions.svelte b/src/lib/components/BulkActions.svelte index f29b174..79e9119 100644 --- a/src/lib/components/BulkActions.svelte +++ b/src/lib/components/BulkActions.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 1d01dce..8884a73 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -3,6 +3,7 @@ import FormatToolbar from "$lib/components/FormatToolbar.svelte"; import { library } from "$lib/stores/library.svelte"; import { editorPrefs } from "$lib/stores/editor.svelte"; + import { confirmDialog } from "$lib/stores/confirm.svelte"; import { formatDate, wordCount } from "$lib/format"; import type { FormatKind } from "$lib/markdown-format"; import { NO_MARKS, type ActiveMarks } from "$lib/markdown-active"; @@ -25,9 +26,17 @@ } async function confirmDestroy() { - if (window.confirm("Permanently delete this note? This cannot be undone.")) { - await library.destroySelected(); - } + // Snapshot the id when the dialog opens: the selection could otherwise + // drift while it is up, and the confirm must act on the note it named. + const id = library.selected?.id; + if (!id) return; + const ok = await confirmDialog.ask({ + title: "Delete this note permanently?", + body: "This action cannot be undone.", + confirmLabel: "Delete Forever", + tone: "danger", + }); + if (ok) await library.destroyNotes([id]); } @@ -119,8 +128,12 @@ />
- - {#if library.saving}Saving…{:else}Saved · {formatDate(library.selected.updatedAt)}{/if} + + {#if library.saveState === "saving"}Saving…{:else if library.saveState === "failed"}Not saved{:else}Saved · {formatDate(library.selected.updatedAt)}{/if} {#if library.error} {library.error} @@ -176,6 +189,11 @@ color: var(--text-secondary); font-style: italic; } + /* Retries exhausted; the edit stays queued and flushes keep attempting it. */ + .save-state.failed { + color: var(--danger); + font-weight: 500; + } .save-dot { width: 6px; height: 6px; diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 8308bfe..042c4e0 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -2,6 +2,7 @@ import { library, type StatusFilter } from "$lib/stores/library.svelte"; import { formatDate, preview } from "$lib/format"; import { captureShortcut, modKey } from "$lib/platform"; + import { confirmDialog } from "$lib/stores/confirm.svelte"; const statusFilters: { id: StatusFilter; label: string }[] = [ { id: "active", label: "Active" }, @@ -20,9 +21,13 @@ } async function confirmEmptyTrash() { - if (window.confirm("Permanently delete all notes in the Trash? This cannot be undone.")) { - await library.emptyTrash(); - } + const ok = await confirmDialog.ask({ + title: "Empty the Trash?", + body: "All notes in Trash will be permanently deleted. This action cannot be undone.", + confirmLabel: "Empty Trash", + tone: "danger", + }); + if (ok) await library.emptyTrash(); } diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index c685bd9..881d031 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/WelcomeScreen.svelte b/src/lib/components/WelcomeScreen.svelte index e154a03..258bb19 100644 --- a/src/lib/components/WelcomeScreen.svelte +++ b/src/lib/components/WelcomeScreen.svelte @@ -1,10 +1,27 @@
@@ -42,6 +59,15 @@

Select a note, or press {captureShortcut} anywhere to capture.

Press {modKey}K for commands and themes.

+ {#if shortcutConflict && !conflictDismissed} +

+ The capture shortcut {shortcutConflict} could not be registered; + another app likely owns it. + +

+ {/if} {#if library.error}

{library.error}

{/if}
@@ -65,6 +91,21 @@ font-size: 12px; opacity: 0.8; } + /* Same warning orange as the beta badge; a conflict is a caution, not an error. */ + .shortcut-notice { + font-size: 12px; + color: #e8923a; + } + .notice-dismiss { + margin-left: 4px; + border: 1px solid #e8923a; + border-radius: 99px; + padding: 0 7px; + color: #e8923a; + background: rgba(232, 146, 58, 0.08); + font-size: 10px; + cursor: pointer; + } /* Orange marks beta builds. */ .version-badge { display: inline-block; diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 49d39ca..8dcb8bf 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -34,12 +34,24 @@ import type { import { debounce } from "$lib/debounce"; import { friendlyMessage } from "$lib/errors"; import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; +import { toasts } from "$lib/stores/toasts.svelte"; import { listen } from "@tauri-apps/api/event"; // Archived and trash live behind a list filter in All Notes, not as // top-level sections (two-section library: All Notes and Workspaces). export type StatusFilter = "active" | "archived" | "trash"; +// One quiet retry this long after a failed body save; most failures (a +// competing writer briefly holding the database lock) clear well within it. +const SAVE_RETRY_MS = 2000; + +// Debounce for search-text refreshes only, so a query runs per pause rather +// than per keystroke; filter clicks and change events stay immediate. +const SEARCH_DEBOUNCE_MS = 150; + +/** Selected-note save status for the editor status bar. */ +export type SaveState = "saved" | "saving" | "failed"; + class LibraryStore { statusFilter = $state("active"); activeWorkspaceId = $state(null); @@ -57,28 +69,49 @@ class LibraryStore { // bulk-actions panel. multiSelected = $state>(new Set()); error = $state(null); - loading = $state(false); - // True while a body edit is queued or in flight, for the save indicator. - saving = $state(false); + + // Bodies not yet confirmed persisted, by note id. An entry is only removed + // by a successful write, so a failed save stays queued for the next flush + // (note switch, blur, quit) instead of being silently dropped. Reassigned + // on change, like multiSelected, so the status bar tracks it reactively. + #unsaved = $state(new Map()); + // Note ids whose save failed even after the retry; drives "Not saved". + #failed = $state(new Set()); + // Scheduled 2s retry per note id, so a newer write, a drop, or a flush can + // cancel it before it fires a stray write behind the caller's back. + #retryTimers = new Map>(); #anchorId: string | null = null; + #initialized = false; #refreshDebounced = debounce(() => void this.refresh(), 50); + #searchRefresh = debounce(() => void this.refresh(), SEARCH_DEBOUNCE_MS); #saveBody = debounce((id: string, body: string) => { - void this.#applyUpdate(id, { body }).finally(() => { - this.saving = false; - }); + void this.#persistBody(id, body, true); }, 400); + /** Save status of the selected note, for the editor status bar. */ + get saveState(): SaveState { + const id = this.selected?.id; + if (!id || !this.#unsaved.has(id)) return "saved"; + return this.#failed.has(id) ? "failed" : "saving"; + } + async init(): Promise { + if (this.#initialized) return; + this.#initialized = true; + // Listeners before the initial fetches: a change event arriving during + // startup must trigger a re-query, not be dropped. + await Promise.all([ + listen("notes:changed", () => this.#refreshDebounced()), + listen("tags:changed", () => void this.refreshTags()), + listen("workspaces:changed", () => void this.refreshWorkspaces()), + ]); await Promise.all([ this.refresh(), this.refreshTags(), this.refreshWorkspaces(), ]); - await listen("notes:changed", () => this.#refreshDebounced()); - await listen("tags:changed", () => void this.refreshTags()); - await listen("workspaces:changed", () => void this.refreshWorkspaces()); } #filter(): NoteFilter { @@ -90,17 +123,28 @@ class LibraryStore { return f; } + // Monotonic refresh token: queries answer out of order (search per pause, + // list per filter click), so a response only lands while it is still the + // newest request; a slow earlier reply can never clobber a later one. + #refreshToken = 0; + async refresh(): Promise { + const token = ++this.#refreshToken; try { const text = this.searchText.trim(); if (text) { - this.searchResults = await searchNotes(text); + const results = await searchNotes(text); + if (token !== this.#refreshToken) return; + this.searchResults = results; } else { + const notes = await listNotes(this.#filter()); + if (token !== this.#refreshToken) return; this.searchResults = null; - this.notes = await listNotes(this.#filter()); + this.notes = notes; } this.error = null; } catch (e) { + if (token !== this.#refreshToken) return; this.#fail(e); } } @@ -160,7 +204,14 @@ class LibraryStore { this.multiSelected = this.selected ? new Set([this.selected.id]) : new Set(); this.#anchorId = this.selected?.id ?? null; this.#lastRangeEnd = this.#anchorId; - void this.refresh(); + if (text.trim()) { + this.#searchRefresh(); + } else { + // Clearing must feel instant: drop any pending keystroke debounce and + // go straight back to the list. + this.#searchRefresh.cancel(); + void this.refresh(); + } } async select(id: string): Promise { @@ -174,7 +225,11 @@ class LibraryStore { // Flush any pending edit of the previous note before switching. this.#saveBody.flush(); try { - this.selected = await getNote(id, true); + const note = await getNote(id, true); + // A queued edit (debounced or awaiting retry) is newer than what disk + // returned; showing the disk body would fork the note's history. + const queued = this.#unsaved.get(id); + this.selected = queued !== undefined ? { ...note, body: queued } : note; [this.selectedTags, this.selectedWorkspaces] = await Promise.all([ tagsForNote(id), workspacesForNote(id), @@ -278,9 +333,21 @@ class LibraryStore { } async bulkDelete(): Promise { + const ids = [...this.multiSelected]; + // Trash is reversible and Undo promises fidelity: persist any pending + // edit first, so a restored note holds the user's last keystrokes. this.#saveBody.cancel(); + await this.#flushIds(ids); + this.#dropQueued(...ids); await this.#bulk((id) => softDeleteNote(id).then(() => {})); this.clearMultiSelect(); + if (ids.length > 0) { + const label = ids.length === 1 ? "1 note" : `${ids.length} notes`; + toasts.show(`${label} moved to Trash`, { + label: "Undo", + run: () => void this.#undoSoftDelete(ids), + }); + } } async bulkRestore(): Promise { @@ -289,17 +356,43 @@ class LibraryStore { } async bulkDestroy(): Promise { - await this.#bulk((id) => permanentlyDeleteNote(id, true)); + await this.destroyNotes([...this.multiSelected]); + } + + /** + * Permanently delete an explicit set of notes. Ids are an argument rather + * than a read of the live selection so confirm dialogs can snapshot them + * at ask time: the selection must not be able to drift between the dialog + * opening and the user confirming. + */ + async destroyNotes(ids: string[]): Promise { + if (ids.length === 0) return; + // Destroyed notes must also forget their queued edits, or the retry and + // every later flush re-attempts a write against a row that no longer + // exists and surfaces NOT_FOUND forever. + this.#saveBody.cancel(); + this.#dropQueued(...ids); + try { + for (const id of ids) { + await permanentlyDeleteNote(id, true); + } + this.error = null; + } catch (e) { + this.#fail(e); + } this.clearMultiSelect(); } async emptyTrash(): Promise { try { const trashed = await listNotes({ isDeleted: true }); + this.#saveBody.cancel(); + this.#dropQueued(...trashed.map((n) => n.id)); for (const note of trashed) { await permanentlyDeleteNote(note.id, true); } this.clearMultiSelect(); + if (trashed.length > 0) toasts.show("Trash emptied"); } catch (e) { this.#fail(e); } @@ -383,9 +476,10 @@ class LibraryStore { editBody(body: string): void { if (!this.selected) return; - // Optimistic local state; persistence is debounced. + // Optimistic local state; persistence is debounced. The note is dirty + // from this moment until a write of this (or a newer) body succeeds. this.selected.body = body; - this.saving = true; + this.#unsaved = new Map(this.#unsaved).set(this.selected.id, body); this.#saveBody(this.selected.id, body); } @@ -412,11 +506,19 @@ class LibraryStore { async deleteSelected(): Promise { if (!this.selected) return; + const id = this.selected.id; + // Trash is reversible and Undo promises fidelity: persist any pending + // edit first, so a restored note holds the user's last keystrokes. this.#saveBody.cancel(); - this.saving = false; + await this.#flushIds([id]); + this.#dropQueued(id); try { - await softDeleteNote(this.selected.id); + await softDeleteNote(id); this.clearMultiSelect(); + toasts.show("Moved to Trash", { + label: "Undo", + run: () => void this.#undoSoftDelete([id]), + }); } catch (e) { this.#fail(e); } @@ -433,12 +535,7 @@ class LibraryStore { async destroySelected(): Promise { if (!this.selected) return; - try { - await permanentlyDeleteNote(this.selected.id, true); - this.clearMultiSelect(); - } catch (e) { - this.#fail(e); - } + await this.destroyNotes([this.selected.id]); } async addTag(name: string): Promise { @@ -461,8 +558,128 @@ class LibraryStore { } } - flushPendingEdits(): void { - this.#saveBody.flush(); + /** + * Persist every queued edit now (note switch, window blur, export, quit). + * Resolves once the writes have settled; anything that still fails stays + * queued for the next flush. + * + * Cancels the debounce outright rather than flushing through it: flushing + * would run the retry-enabled path, which schedules its own 2s retry on + * failure and can fire a stray write after this call has already + * resolved. #unsaved already holds the latest body for every queued note + * (editBody sets it synchronously, ahead of the debounce), so a single + * no-retry persist below covers the just-typed edit too, with exactly one + * write attempt per note. + */ + async flushPendingEdits(): Promise { + this.#saveBody.cancel(); + await Promise.all( + [...this.#unsaved.entries()].map(([id, body]) => + this.#persistBody(id, body, false), + ), + ); + } + + /** + * Persist queued edits for specific ids now, no retry. Used ahead of a + * soft delete: the note survives in the trash, so the last keystrokes + * must land before the row leaves the list (Undo depends on them). + */ + async #flushIds(ids: string[]): Promise { + await Promise.all( + ids + .filter((id) => this.#unsaved.has(id)) + .map((id) => this.#persistBody(id, this.#unsaved.get(id) as string, false)), + ); + } + + /** + * Write one note body. A failure retries once after a short backoff (state + * stays "saving", so the UI never claims "Saved" over unpersisted data); + * a second failure flips the note to "failed" while keeping the edit in + * #unsaved so a later flush still attempts it. + */ + async #persistBody(id: string, body: string, canRetry: boolean): Promise { + // Any write attempt for this id, whether from the debounce, a retry, or + // a flush, supersedes an outstanding scheduled retry for the same id. + this.#clearRetryTimer(id); + try { + const updated = await updateNote(id, { body }); + // Confirmed on disk. Clear the queue entry unless a newer edit + // superseded the body this write carried. + if (this.#unsaved.get(id) === body) { + const unsaved = new Map(this.#unsaved); + unsaved.delete(id); + this.#unsaved = unsaved; + } + if (this.#failed.has(id)) { + const failed = new Set(this.#failed); + failed.delete(id); + this.#failed = failed; + } + if (this.selected?.id === id) { + // Keep local body if user kept typing past this save. + const localBody = this.selected.body; + this.selected = { ...updated, body: localBody }; + this.selectedTags = await tagsForNote(id); + } + this.error = null; + } catch (e) { + if (canRetry) { + // One quiet retry: most failures (a competing writer briefly holding + // the database lock) clear well within the backoff. Tracked so a + // drop or a flush can cancel it before it fires. + const timer = setTimeout(() => { + this.#retryTimers.delete(id); + const latest = this.#unsaved.get(id); + if (latest !== undefined) void this.#persistBody(id, latest, false); + }, SAVE_RETRY_MS); + this.#retryTimers.set(id, timer); + } else { + this.#failed = new Set(this.#failed).add(id); + this.#fail(e); + } + } + } + + #clearRetryTimer(id: string): void { + const timer = this.#retryTimers.get(id); + if (timer !== undefined) { + clearTimeout(timer); + this.#retryTimers.delete(id); + } + } + + /** Forget queued edits for notes that are being discarded. */ + #dropQueued(...ids: string[]): void { + const unsaved = new Map(this.#unsaved); + const failed = new Set(this.#failed); + for (const id of ids) { + unsaved.delete(id); + failed.delete(id); + this.#clearRetryTimer(id); + } + this.#unsaved = unsaved; + this.#failed = failed; + } + + /** + * Undo for a soft delete: restore each id, then refresh. A note destroyed + * in the meantime (or otherwise gone) fails quietly into a plain toast + * instead of throwing back into the caller (the toast's action handler). + */ + async #undoSoftDelete(ids: string[]): Promise { + const results = await Promise.allSettled(ids.map((id) => restoreNote(id))); + await this.refresh(); + const failedCount = results.filter((r) => r.status === "rejected").length; + if (failedCount === 0) return; + const message = + failedCount === ids.length + ? ids.length === 1 + ? "Couldn't restore. It may already be gone." + : "Couldn't restore. The notes may already be gone." + : `Couldn't restore ${failedCount} of ${ids.length} notes.`; + toasts.show(message); } async #applyUpdate( diff --git a/src/lib/stores/updater.svelte.ts b/src/lib/stores/updater.svelte.ts index 0abcb53..01ef4f1 100644 --- a/src/lib/stores/updater.svelte.ts +++ b/src/lib/stores/updater.svelte.ts @@ -8,6 +8,7 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { getSetting, setSetting, deleteSetting } from "$lib/api/client"; +import { library } from "$lib/stores/library.svelte"; import { snoozeDeadline, type SnoozeKind } from "$lib/updater-snooze"; export type { SnoozeKind } from "$lib/updater-snooze"; @@ -126,6 +127,11 @@ class UpdaterStore { async restart() { if (this.status !== "ready") return; + // Flush here, before relaunch: the Rust quit interceptor deliberately + // lets the restart's exit request pass untouched (holding it would + // strand the freshly installed update), so this is the only place the + // pending edits can be saved on the update path. + await library.flushPendingEdits(); await relaunch(); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 14c87ee..d2845e5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,7 +6,7 @@ import { getVersion } from "@tauri-apps/api/app"; import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; - import { exportNoteFile } from "$lib/api/client"; + import { exportNoteFile, quitApp } from "$lib/api/client"; import Sidebar from "$lib/components/Sidebar.svelte"; import NoteList from "$lib/components/NoteList.svelte"; import NoteEditor from "$lib/components/NoteEditor.svelte"; @@ -15,10 +15,13 @@ import CommandPalette from "$lib/components/CommandPalette.svelte"; import UpdatePanel from "$lib/components/UpdatePanel.svelte"; import SettingsView from "$lib/components/SettingsView.svelte"; + import ConfirmDialog from "$lib/components/ConfirmDialog.svelte"; + import Toast from "$lib/components/Toast.svelte"; import { library } from "$lib/stores/library.svelte"; import { updater } from "$lib/stores/updater.svelte"; import { editorPrefs } from "$lib/stores/editor.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; + import { confirmDialog } from "$lib/stores/confirm.svelte"; let appVersion = $state(""); let paletteOpen = $state(false); @@ -55,7 +58,15 @@ void exportSelectedNote(); }).then((un) => (unlistenExport = un)); - const flush = () => library.flushPendingEdits(); + // Quit handshake: persist the debounced edit, then tell Rust to exit for + // real. If this webview is hung the Rust-side fallback exits anyway. + let unlistenQuit: (() => void) | undefined; + void listen("app:quit-requested", async () => { + await library.flushPendingEdits(); + await quitApp(); + }).then((un) => (unlistenQuit = un)); + + const flush = () => void library.flushPendingEdits(); window.addEventListener("blur", flush); window.addEventListener("keydown", onKeydown); return () => { @@ -64,6 +75,7 @@ unlistenSettings?.(); unlistenNewNote?.(); unlistenExport?.(); + unlistenQuit?.(); window.removeEventListener("blur", flush); window.removeEventListener("keydown", onKeydown); }; @@ -77,6 +89,10 @@ } function onKeydown(e: KeyboardEvent) { + // The confirm dialog stops propagation itself, but that only covers keys + // dispatched through it; this guard catches the rest (focus on body after + // an invoker unmounted) so nothing moves under an open modal. + if (confirmDialog.request) return; const mod = e.metaKey || e.ctrlKey; // ⌘K toggles the command palette from anywhere, including input fields. if (mod && e.key === "k") { @@ -169,11 +185,19 @@ } async function confirmBulkDestroy() { - const n = library.multiSelected.size; - const what = n === 1 ? "this note" : `these ${n} notes`; - if (window.confirm(`Permanently delete ${what}? This cannot be undone.`)) { - await library.bulkDestroy(); - } + // Snapshot the ids when the dialog opens: the selection could otherwise + // drift while it is up (menu events, cross-window refreshes) and the + // confirm would destroy whatever is selected at resolve time instead. + const ids = [...library.multiSelected]; + if (ids.length === 0) return; + const what = ids.length === 1 ? "this note" : `these ${ids.length} notes`; + const ok = await confirmDialog.ask({ + title: `Delete ${what} permanently?`, + body: "This action cannot be undone.", + confirmLabel: "Delete Forever", + tone: "danger", + }); + if (ok) await library.destroyNotes(ids); } @@ -197,6 +221,8 @@ + + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 881d031..0b158b7 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1,8 +1,19 @@ +{#if tagMenu} + {@const menuTag = tagMenu.tag} + (renamingTagId = menuTag.id) }, + { label: "Delete Tag", danger: true, run: () => void confirmDeleteTag(menuTag) }, + ]} + onclose={() => (tagMenu = null)} + /> +{/if} + diff --git a/src/lib/tag-name.test.ts b/src/lib/tag-name.test.ts new file mode 100644 index 0000000..934f176 --- /dev/null +++ b/src/lib/tag-name.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { normalizeTagInput } from "./tag-name"; + +describe("normalizeTagInput", () => { + it("trims surrounding whitespace", () => { + expect(normalizeTagInput(" project ")).toBe("project"); + }); + + it("strips a single leading hash", () => { + expect(normalizeTagInput("#idea")).toBe("idea"); + }); + + it("strips a leading hash followed by whitespace", () => { + expect(normalizeTagInput("# idea")).toBe("idea"); + }); + + it("rejects an empty result", () => { + expect(normalizeTagInput("")).toBeNull(); + expect(normalizeTagInput(" ")).toBeNull(); + expect(normalizeTagInput("#")).toBeNull(); + expect(normalizeTagInput("# ")).toBeNull(); + }); + + it("leaves an inner hash untouched", () => { + expect(normalizeTagInput("c#lang")).toBe("c#lang"); + }); + + it("only strips one leading hash; the server strips the rest", () => { + expect(normalizeTagInput("##idea")).toBe("#idea"); + }); +}); diff --git a/src/lib/tag-name.ts b/src/lib/tag-name.ts new file mode 100644 index 0000000..e683b26 --- /dev/null +++ b/src/lib/tag-name.ts @@ -0,0 +1,12 @@ +// Client-side pre-check for a tag rename input, before it is sent to the +// backend. `store::update_tag` (via `domain::normalize_tag_name`) is the +// source of truth for the canonical form: lowercase, collapsed whitespace, +// all leading '#' stripped. This only trims, strips one leading '#', and +// rejects an empty result, so the UI can refuse an obviously-empty commit +// without a round trip; the server-normalized name comes back on refresh. +export function normalizeTagInput(raw: string): string | null { + const trimmed = raw.trim(); + const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + const result = withoutHash.trim(); + return result.length > 0 ? result : null; +} From 81b083be4e84317850a9af964cd7f2605429db59 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:20:16 -0700 Subject: [PATCH 09/41] test: cover the save queue, race tokens, and quit flush - Twenty behavioral tests driven by fake timers and hand-resolved promises: save lifecycle, the single retry, exactly-once flush, destroy paths dropping queued edits, write-before-trash ordering for Undo, refresh race tokens, search debounce, and init ordering. - vitest now runs through the sveltekit plugin so Svelte 5 runes compile in .svelte.ts modules under test. --- src/lib/stores/library.svelte.test.ts | 519 ++++++++++++++++++++++++++ vitest.config.ts | 5 + 2 files changed, 524 insertions(+) create mode 100644 src/lib/stores/library.svelte.test.ts diff --git a/src/lib/stores/library.svelte.test.ts b/src/lib/stores/library.svelte.test.ts new file mode 100644 index 0000000..37e2167 --- /dev/null +++ b/src/lib/stores/library.svelte.test.ts @@ -0,0 +1,519 @@ +// Save queue, race token, and quit-flush tests for the library store. These +// behaviors shipped untested (July 2026 frontend audit) and each test below +// is built to fail if the guarded behavior regresses. +// +// $lib/api/client and @tauri-apps/api/event are mocked; timers are fake +// throughout so debounce/retry timing is deterministic. Because `library` is +// a module-level singleton, every test loads a fresh copy of the module via +// vi.resetModules() + dynamic import so state never bleeds between tests. +// (vi.mock's factory itself is not re-run by resetModules, so the imported +// mock functions below keep stable identity across the whole file; only the +// store's own module -- and therefore its state -- is fresh per test.) + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ApiError, + getNote, + listNotes, + listTags, + listWorkspaces, + permanentlyDeleteNote, + searchNotes, + softDeleteNote, + tagsForNote, + updateNote, + workspacesForNote, +} from "$lib/api/client"; +import { listen } from "@tauri-apps/api/event"; +import type { Note, SearchResult } from "$lib/api/types"; + +vi.mock("$lib/api/client", () => { + class ApiError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.name = "ApiError"; + this.code = code; + } + } + return { + ApiError, + createNote: vi.fn(), + getNote: vi.fn(), + updateNote: vi.fn(), + softDeleteNote: vi.fn(), + restoreNote: vi.fn(), + permanentlyDeleteNote: vi.fn(), + listNotes: vi.fn(), + searchNotes: vi.fn(), + listTags: vi.fn(), + listWorkspaces: vi.fn(), + getOrCreateWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + addNoteToWorkspace: vi.fn(), + removeNoteFromWorkspace: vi.fn(), + workspacesForNote: vi.fn(), + addTagToNote: vi.fn(), + removeTagFromNote: vi.fn(), + tagsForNote: vi.fn(), + }; +}); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +const mockGetNote = vi.mocked(getNote); +const mockUpdateNote = vi.mocked(updateNote); +const mockListNotes = vi.mocked(listNotes); +const mockSearchNotes = vi.mocked(searchNotes); +const mockListTags = vi.mocked(listTags); +const mockListWorkspaces = vi.mocked(listWorkspaces); +const mockTagsForNote = vi.mocked(tagsForNote); +const mockWorkspacesForNote = vi.mocked(workspacesForNote); +const mockPermanentlyDeleteNote = vi.mocked(permanentlyDeleteNote); +const mockSoftDeleteNote = vi.mocked(softDeleteNote); +const mockListen = vi.mocked(listen); + +function mkNote(id: string, overrides: Partial = {}): Note { + return { + id, + title: `Note ${id}`, + body: "", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + isPinned: false, + isArchived: false, + isDeleted: false, + syncState: "local_only", + version: 1, + ...overrides, + }; +} + +function mkSearchResult(id: string): SearchResult { + return { + noteId: id, + title: `Note ${id}`, + excerpt: "", + score: 1, + updatedAt: "2026-01-01T00:00:00Z", + }; +} + +/** A promise plus its resolve/reject, so races can be driven by hand. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Fresh module graph so the singleton store instance starts clean. */ +async function load() { + const mod = await import("$lib/stores/library.svelte"); + return mod.library; +} + +async function selectNote( + library: Awaited>, + id: string, + overrides: Partial = {}, +) { + mockGetNote.mockResolvedValueOnce(mkNote(id, overrides)); + await library.select(id); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + + mockGetNote.mockReset(); + mockUpdateNote.mockReset(); + mockListNotes.mockReset().mockResolvedValue([]); + mockSearchNotes.mockReset().mockResolvedValue([]); + mockListTags.mockReset().mockResolvedValue([]); + mockListWorkspaces.mockReset().mockResolvedValue([]); + mockTagsForNote.mockReset().mockResolvedValue([]); + mockWorkspacesForNote.mockReset().mockResolvedValue([]); + mockPermanentlyDeleteNote.mockReset(); + mockSoftDeleteNote.mockReset(); + mockListen.mockReset().mockResolvedValue(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("save queue", () => { + it("queues the edit and reports saving until the debounced write settles", async () => { + const library = await load(); + await selectNote(library, "n1"); + + const write = deferred(); + mockUpdateNote.mockReturnValueOnce(write.promise); + + library.editBody("new body"); + // #unsaved is set synchronously in editBody, before the debounce fires. + expect(library.saveState).toBe("saving"); + expect(mockUpdateNote).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "new body" }); + // Write still in flight: must not claim "saved" over unpersisted data. + expect(library.saveState).toBe("saving"); + + write.resolve(mkNote("n1", { body: "new body" })); + await vi.advanceTimersByTimeAsync(0); + expect(library.saveState).toBe("saved"); + }); + + it("retries once after ~2s and clears saving/failed state on a successful retry", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote + .mockRejectedValueOnce(new ApiError("STORAGE_ERROR", "locked")) + .mockResolvedValueOnce(mkNote("n1", { body: "retry body" })); + + library.editBody("retry body"); + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + // First failure alone must not surface as "failed" -- it's still + // waiting out the retry backoff. + expect(library.saveState).toBe("saving"); + expect(library.error).toBeNull(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(2); + expect(library.saveState).toBe("saved"); + }); + + it("moves the note to failed after a second consecutive failure", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "disk full")); + + library.editBody("doomed"); + await vi.advanceTimersByTimeAsync(400); + expect(library.saveState).toBe("saving"); + expect(library.error).toBeNull(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(2); + expect(library.saveState).toBe("failed"); + expect(library.error).toBe("Your note couldn't be saved. Please try again."); + }); +}); + +describe("flushPendingEdits", () => { + it("flushes the debounce immediately, without waiting for the 400ms window", async () => { + const library = await load(); + await selectNote(library, "n1"); + + const write = deferred(); + mockUpdateNote.mockReturnValueOnce(write.promise); + + library.editBody("flush me"); + const flushed = library.flushPendingEdits(); + // The write must fire synchronously off the flush, not off the timer. + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "flush me" }); + + write.resolve(mkNote("n1", { body: "flush me" })); + await flushed; + + expect(library.saveState).toBe("saved"); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("leaves a failed note in #failed rather than dropping the edit", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + + library.editBody("will fail"); + await library.flushPendingEdits(); + + // Queued edit survives the failure: saveState is "failed", not "saved", + // and a later flush would still have something to retry (proven by the + // note staying selected/dirty rather than the state resetting to idle). + expect(library.saveState).toBe("failed"); + expect(library.error).toBe("Your note couldn't be saved. Please try again."); + }); + + it("fixed: a flush of one dirty note performs exactly one write attempt, and no timer survives once it resolves", async () => { + // flushPendingEdits used to call #saveBody.flush(), which ran the + // retry-enabled callback (canRetry=true) and scheduled a hidden 2s + // setTimeout retry on failure, *in addition to* flushPendingEdits' own + // explicit no-retry re-attempt -- two immediate writes plus a stray + // third write after the flush had already resolved. flushPendingEdits + // now cancels the debounce outright and performs its own single + // no-retry persist, so a failing flush writes exactly once and leaves + // no retry timer behind. + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + + library.editBody("stray retry"); + await library.flushPendingEdits(); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + expect(library.saveState).toBe("failed"); + + // No retry timer was scheduled by the no-retry flush path, so nothing + // fires after the old 2s retry window elapses. + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); +}); + +describe("destroy paths drop queued edits (regression: fixed 2026-07-08)", () => { + it("bulkDestroy cancels the pending debounce so no write is ever attempted", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockPermanentlyDeleteNote.mockResolvedValue(undefined); + + library.editBody("about to be destroyed"); + await library.bulkDestroy(); + + // Past debounce (400ms) and past the retry window (2000ms): if the + // queued edit were not dropped, updateNote would fire here. + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockPermanentlyDeleteNote).toHaveBeenCalledWith("n1", true); + }); + + it("emptyTrash cancels queued edits for every trashed note before destroying them", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockListNotes.mockResolvedValueOnce([mkNote("n1"), mkNote("n2")]); + mockPermanentlyDeleteNote.mockResolvedValue(undefined); + + library.editBody("in the trash"); + await library.emptyTrash(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockPermanentlyDeleteNote).toHaveBeenCalledWith("n1", true); + expect(mockPermanentlyDeleteNote).toHaveBeenCalledWith("n2", true); + }); + + it("destroySelected cancels the open note's queued edit", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockPermanentlyDeleteNote.mockResolvedValue(undefined); + + library.editBody("open note, about to be destroyed"); + await library.destroySelected(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockPermanentlyDeleteNote).toHaveBeenCalledWith("n1", true); + }); + + it("control: without a destroy, the same queued edit does reach updateNote", async () => { + // Sanity check for the three tests above: proves the fake-timer harness + // really does drive the debounce through to a write when nothing + // intervenes, so "updateNote not called" above is a meaningful signal + // and not an artifact of timers never firing. + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "kept" })); + + library.editBody("kept"); + await vi.advanceTimersByTimeAsync(400); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "kept" }); + }); +}); + +describe("soft delete flushes queued edits (Undo restores the last keystrokes)", () => { + it("deleteSelected persists the pending edit before trashing the note", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "last keystrokes" })); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + + library.editBody("last keystrokes"); + await library.deleteSelected(); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "last keystrokes" }); + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + // The write must land before the trash, or a restore loses the edit. + const write = mockUpdateNote.mock.invocationCallOrder[0]; + const trash = mockSoftDeleteNote.mock.invocationCallOrder[0]; + expect(write).toBeLessThan(trash); + // Nothing further fires later: no leftover debounce, no retry timer. + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("bulkDelete persists pending edits for the selection before trashing", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "unsaved bulk edit" })); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + + library.editBody("unsaved bulk edit"); + await library.bulkDelete(); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "unsaved bulk edit" }); + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("a failed pre-trash flush still trashes the note and surfaces the error", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "disk full")); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + + library.editBody("doomed edit"); + await library.deleteSelected(); + + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + expect(library.error).toBeTruthy(); + // The failed edit is dropped with the trashed note; no retry fires later. + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); +}); + +describe("refresh race token", () => { + it("a slow older list refresh cannot clobber a newer one", async () => { + const library = await load(); + const older = deferred(); + const newer = deferred(); + mockListNotes.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + + const p1 = library.refresh(); + const p2 = library.refresh(); + + newer.resolve([mkNote("newer")]); + await p2; + expect(library.notes.map((n) => n.id)).toEqual(["newer"]); + + older.resolve([mkNote("older")]); + await p1; + // The stale response must be discarded, not applied after the fact. + expect(library.notes.map((n) => n.id)).toEqual(["newer"]); + }); + + it("a slow older search refresh cannot clobber a newer one", async () => { + const library = await load(); + library.setSearch("q"); + await vi.advanceTimersByTimeAsync(0); // let the immediate search-text set land; refresh below is called directly + + const older = deferred(); + const newer = deferred(); + mockSearchNotes.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + + const p1 = library.refresh(); + const p2 = library.refresh(); + + newer.resolve([mkSearchResult("newer")]); + await p2; + expect(library.searchResults?.map((r) => r.noteId)).toEqual(["newer"]); + + older.resolve([mkSearchResult("older")]); + await p1; + expect(library.searchResults?.map((r) => r.noteId)).toEqual(["newer"]); + }); +}); + +describe("search debounce", () => { + it("collapses rapid setSearch calls into a single query for the final text", async () => { + const library = await load(); + + library.setSearch("a"); + await vi.advanceTimersByTimeAsync(50); + library.setSearch("ab"); + await vi.advanceTimersByTimeAsync(50); + library.setSearch("abc"); + expect(mockSearchNotes).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(150); + expect(mockSearchNotes).toHaveBeenCalledTimes(1); + expect(mockSearchNotes).toHaveBeenCalledWith("abc"); + }); + + it("clearing the search text cancels the debounce and refreshes immediately", async () => { + const library = await load(); + + library.setSearch("something"); + expect(mockSearchNotes).not.toHaveBeenCalled(); + + library.setSearch(""); + // Immediate: listNotes fires synchronously off the clear, not gated by + // the 150ms debounce. + expect(mockListNotes).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(200); + // The canceled debounce must never fire the stale query. + expect(mockSearchNotes).not.toHaveBeenCalled(); + }); +}); + +describe("init ordering", () => { + it("registers listeners before the first fetch and waits for them to resolve", async () => { + const library = await load(); + const gate = deferred<() => void>(); + mockListen.mockImplementation(() => gate.promise); + + const initPromise = library.init(); + + expect(mockListen.mock.calls.map((c) => c[0])).toEqual([ + "notes:changed", + "tags:changed", + "workspaces:changed", + ]); + expect(mockListNotes).not.toHaveBeenCalled(); + expect(mockListTags).not.toHaveBeenCalled(); + expect(mockListWorkspaces).not.toHaveBeenCalled(); + + gate.resolve(() => {}); + await initPromise; + + expect(mockListNotes).toHaveBeenCalledTimes(1); + expect(mockListTags).toHaveBeenCalledTimes(1); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("guards against double invocation: a second concurrent call does no extra work", async () => { + const library = await load(); + + const p1 = library.init(); + const p2 = library.init(); + await Promise.all([p1, p2]); + + expect(mockListen).toHaveBeenCalledTimes(3); + expect(mockListNotes).toHaveBeenCalledTimes(1); + expect(mockListTags).toHaveBeenCalledTimes(1); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("wires the notes:changed listener to a debounced refresh", async () => { + const library = await load(); + await library.init(); + mockListNotes.mockClear(); + + const handler = mockListen.mock.calls.find((c) => c[0] === "notes:changed")?.[1] as + | (() => void) + | undefined; + expect(handler).toBeTypeOf("function"); + handler!(); + + expect(mockListNotes).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(50); + expect(mockListNotes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f670132..d9133ee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,11 @@ +import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // sveltekit() is required so *.svelte.ts files compile their runes + // ($state, etc, otherwise a bare "$state is not defined" at test time) + // and so the $lib alias resolves the way it does in the real app. + plugins: [sveltekit()], test: { include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], environment: "node", From 237f4fc404c32dec9010981ad34c79ebf820e156 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:13:24 -0700 Subject: [PATCH 10/41] fix: bump package-lock.json together with the app version - The bump script now rewrites both version fields in package-lock.json, anchored on the package name so dependency versions are never touched. - Adds a regression test and fixes the existing drift (the lockfile still said 0.5.2 while the app shipped 0.7.0). --- package-lock.json | 4 ++-- scripts/bump-version.mjs | 23 +++++++++++++++++++---- scripts/bump-version.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 555bb19..9ac23e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instantnotes", - "version": "0.5.2", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instantnotes", - "version": "0.5.2", + "version": "0.7.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6", diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 75e7486..6541683 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -4,9 +4,9 @@ // // npm run bump 0.6.0 // -// Updates: package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and -// the instantnotes entry in src-tauri/Cargo.lock. The core crate -// (src-tauri/core) versions independently and is intentionally left untouched. +// Updates: package.json, package-lock.json, src-tauri/tauri.conf.json, +// src-tauri/Cargo.toml, and the instantnotes entry in src-tauri/Cargo.lock. The +// core crate (src-tauri/core) versions independently and is left untouched. // Prints the next steps (commit, tag, push); it does not git-commit for you. import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -34,6 +34,19 @@ export function bumpPackageVersion(text, name, next) { return text.replace(re, `$1${next}$2`); } +/** + * Rewrite both instantnotes version fields in package-lock.json: the root + * `"version"` and the `packages[""].version` mirror of it. Both sit directly + * after `"name": "instantnotes"`, so anchoring on that leaves every dependency's + * `"version"` untouched. The global flag catches both entries in one pass. + */ +export function bumpLockVersion(text, next) { + return text.replace( + /("name":\s*"instantnotes",\s*"version":\s*")\d+\.\d+\.\d+(")/g, + `$1${next}$2`, + ); +} + /** Read the current top-level version from a JSON document. */ function readJsonVersion(text) { return text.match(/"version"\s*:\s*"(\d+\.\d+\.\d+)"/)?.[1] ?? null; @@ -57,6 +70,7 @@ function main(argv) { const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const pkgPath = join(root, "package.json"); + const pkgLockPath = join(root, "package-lock.json"); const confPath = join(root, "src-tauri", "tauri.conf.json"); const cargoPath = join(root, "src-tauri", "Cargo.toml"); const lockPath = join(root, "src-tauri", "Cargo.lock"); @@ -76,12 +90,13 @@ function main(argv) { } rewriteFile(pkgPath, (t) => bumpJsonVersion(t, next)); + rewriteFile(pkgLockPath, (t) => bumpLockVersion(t, next)); rewriteFile(confPath, (t) => bumpJsonVersion(t, next)); rewriteFile(cargoPath, (t) => bumpPackageVersion(t, "instantnotes", next)); rewriteFile(lockPath, (t) => bumpPackageVersion(t, "instantnotes", next)); console.log(`bumped ${current} -> ${next} in:`); - console.log(" package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock"); + console.log(" package.json, package-lock.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock"); console.log(""); console.log("next:"); console.log(` 1. add a "## [${next}]" section to CHANGELOG.md`); diff --git a/scripts/bump-version.test.ts b/scripts/bump-version.test.ts index cd341d6..cf30f8e 100644 --- a/scripts/bump-version.test.ts +++ b/scripts/bump-version.test.ts @@ -3,6 +3,7 @@ import { isValidVersion, bumpJsonVersion, bumpPackageVersion, + bumpLockVersion, } from "./bump-version.mjs"; describe("isValidVersion", () => { @@ -64,3 +65,31 @@ describe("bumpPackageVersion", () => { expect(out).toContain('name = "instantnotes"\nversion = "0.6.0"'); }); }); + +describe("bumpLockVersion", () => { + it("rewrites both the root and packages[\"\"] version, leaving dependency versions alone", () => { + const lock = [ + "{", + ' "name": "instantnotes",', + ' "version": "0.5.2",', + ' "lockfileVersion": 3,', + ' "packages": {', + ' "": {', + ' "name": "instantnotes",', + ' "version": "0.5.2",', + ' "license": "MIT"', + " },", + ' "node_modules/svelte": {', + ' "version": "5.0.0"', + " }", + " }", + "}", + ].join("\n"); + const out = bumpLockVersion(lock, "0.6.0"); + // Both instantnotes version fields move. + expect(out.match(/"version": "0\.6\.0"/g)).toHaveLength(2); + expect(out).not.toContain('"version": "0.5.2"'); + // The dependency's version is untouched. + expect(out).toContain('"version": "5.0.0"'); + }); +}); From 2829cb5d1859d3ba2479be5685a91caa170ae583 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:13:24 -0700 Subject: [PATCH 11/41] ci: pin the toolchain, gate releases on tests, and lint every build - dtolnay/rust-toolchain pinned to 1.91.1 with clippy and rustfmt components declared explicitly; the action installs a minimal profile, so without them the lint gate breaks the day runner defaults advance. - A tag push now runs svelte-check, vitest, and cargo test before tauri-action gets to build anything. - clippy -D warnings and cargo fmt --check gate every PR on all three platforms; weekly grouped dependabot for npm, cargo, and actions. --- .github/dependabot.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/build.yml | 14 +++++++++++++- .github/workflows/release.yml | 12 +++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4602347 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +# Weekly dependency updates, one grouped PR per ecosystem so the review load is a +# single PR a week rather than one per package. Grouping every update into the +# same PR keeps the three lockfiles (package-lock.json, src-tauri/Cargo.lock, +# workflow SHAs) moving in lockstep with CI. +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + groups: + npm: + patterns: ["*"] + + - package-ecosystem: cargo + directory: "/src-tauri" + schedule: + interval: weekly + groups: + cargo: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5da4f30..18f0c61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,13 @@ jobs: node-version: 22 cache: npm - - uses: dtolnay/rust-toolchain@stable + # Must match rust-toolchain.toml; @stable would export RUSTUP_TOOLCHAIN and + # override the pin, building CI on a different compiler than local. + # components must be listed explicitly: the action installs a minimal + # profile, and the clippy/fmt steps below die without them. + - uses: dtolnay/rust-toolchain@1.91.1 + with: + components: clippy, rustfmt - uses: swatinem/rust-cache@v2 with: @@ -44,4 +50,10 @@ jobs: - run: cargo test --workspace --manifest-path src-tauri/Cargo.toml + # Lint gate: deny clippy warnings and enforce rustfmt so style and common + # bug patterns can't merge. Same src-tauri workspace as the cargo test above. + - run: cargo clippy --workspace --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings + + - run: cargo fmt --all --check --manifest-path src-tauri/Cargo.toml + - run: npm run tauri -- build --no-bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f296663..96c8786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,9 @@ jobs: node-version: 22 cache: npm - - uses: dtolnay/rust-toolchain@stable + # Must match rust-toolchain.toml; @stable would export RUSTUP_TOOLCHAIN and + # override the pin, building CI on a different compiler than local. + - uses: dtolnay/rust-toolchain@1.91.1 - uses: swatinem/rust-cache@v2 with: @@ -46,6 +48,14 @@ jobs: - run: npm ci + # A tag push ships to real users, so run the same quality gate as build.yml + # before spending time on the signed bundle: a red release must never draft. + - run: npm run check + + - run: npm test + + - run: cargo test --workspace --manifest-path src-tauri/Cargo.toml + # Release notes = the CHANGELOG section for this tag. tauri-action writes # this into both the GitHub release body and latest.json's "notes", so the # in-app updater shows real "What's new" text, not install boilerplate. From 960fa6d17d76f5279a7ea025c79657f6979c7f7b Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:13:24 -0700 Subject: [PATCH 12/41] chore: remove stale ignore entries and fix docs drift - Drop .gitignore entries that claimed tracked lockfiles were ignored. - make-update-manifest.sh warns loudly that it writes a darwin-only manifest, so a multi-platform release cannot use it by accident. - README documents the real dev command. --- .gitignore | 4 ---- README.md | 4 +++- scripts/make-update-manifest.sh | 13 +++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index b23f159..d5e79b0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,6 @@ src-tauri/gen/ # SvelteKit generated files .svelte-kit/ -# Lock files (generated, noisy in diffs) -package-lock.json -src-tauri/Cargo.lock - # generated update manifest (scripts/make-update-manifest.sh) /latest.json diff --git a/README.md b/README.md index bcdf0ce..2f5f578 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ To build from source instead, see [Development](#development). ```sh npm install -npm run tauri dev +npm run tauri:dev ``` +Use `npm run tauri:dev`, not `npm run tauri dev`: the app hides to the tray on close, so a plain re-run resurrects the old instance with a webview still pointing at a dead Vite HMR socket (edits never show); the wrapper kills any prior instance first so every run is genuinely fresh. + This builds the Rust core, starts the Vite dev server, and launches the app. Frontend changes hot-reload instantly; Rust changes trigger an incremental rebuild and app restart. ### Test diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index 4e61a38..32493ea 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -1,12 +1,25 @@ #!/usr/bin/env bash # Build latest.json for the in-app updater from a signed release build. # +# ============================ macOS-ONLY FALLBACK ============================= +# This writes a latest.json with ONLY the darwin-aarch64 platform. Since 0.7.0 +# ships Windows and Linux too, publishing this manifest to a real release would +# strand every non-macOS install (their updater reads a manifest that omits +# their platform). Use it only for a local, macOS-only test build. The real +# multi-platform manifest is assembled by .github/workflows/release.yml, which +# merges all three platforms; ship releases with a tag push, not this script. +# ============================================================================= +# # Run after `npm run tauri build` (with TAURI_SIGNING_PRIVATE_KEY_PATH set so # the .sig exists), then upload latest.json AND InstantNotes.app.tar.gz to the # GitHub release. The app polls: # https://github.com/Jam-Sw/InstantNotes/releases/latest/download/latest.json set -euo pipefail +echo "warning: this writes a macOS-only (darwin-aarch64) latest.json. Do NOT use" >&2 +echo "it to publish a multi-platform release; it would break Windows and Linux" >&2 +echo "updaters. For real releases push a v* tag and let release.yml build." >&2 + REPO="Jam-Sw/InstantNotes" BUNDLE_DIR="src-tauri/target/release/bundle/macos" ARCHIVE="$BUNDLE_DIR/InstantNotes.app.tar.gz" From ff5ef9658e1f22db3040fc065da337645eeaebdb Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:14:15 -0700 Subject: [PATCH 13/41] chore: bump version to 0.8.0 Release notes for the work so far live under Unreleased in the changelog; retitle that section to 0.8.0 when the release is cut, after the remaining planned updates land on this branch. --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3888f..176d7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,37 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Search results highlight what matched, in both the note title and the + excerpt. +- The command palette opens notes: press the palette shortcut and see your + five most recent notes, or type to search everything without leaving the + keyboard. +- Rename or delete a tag right in the sidebar: right-click it (or press + Shift+F10) for options, or double-click the tag to rename it in place. + +### Changed +- Deleting is calmer: moving notes to the Trash shows an Undo toast instead + of interrupting you, and permanent deletions ask in a proper in-app dialog + instead of a system popup. +- The quick capture panel closes when you click elsewhere (your draft is + kept), shows a brief "Saved" confirmation, and Cmd+Enter (Ctrl+Enter on + Windows) saves and opens the library. + +### Fixed +- Quitting can no longer lose your last moments of typing: every quit path + saves pending edits first, and a note whose save failed says "Not saved" + instead of pretending otherwise. +- Removing a #tag from a note's text now actually removes that tag from the + note. +- If the notes database is ever corrupted, the app sets it aside and starts + fresh instead of failing to launch, and tells you what happened. The + database is also backed up automatically before any update that changes + its format. +- Typing quickly in search can no longer show results for an older query. +- If another app owns the capture shortcut, the welcome screen now says so + instead of the shortcut silently doing nothing. + ## [0.7.0] - 2026-07-06 ### Added diff --git a/package-lock.json b/package-lock.json index 9ac23e8..a2f99ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instantnotes", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instantnotes", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6", diff --git a/package.json b/package.json index c34b7ed..74f323a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantnotes", - "version": "0.7.0", + "version": "0.8.0", "description": "InstantNotes — instant capture, organized knowledge", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b7cbe08..b722c77 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "instantnotes" -version = "0.7.0" +version = "0.8.0" dependencies = [ "instantnotes-core", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 333d1a8..dd90966 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "instantnotes" -version = "0.7.0" +version = "0.8.0" description = "InstantNotes — instant capture, organized knowledge" edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 316583e..c5f81f1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "InstantNotes", - "version": "0.7.0", + "version": "0.8.0", "identifier": "com.instantnotes.app", "build": { "beforeDevCommand": "npm run dev", From cee1f189f03f693dfb8071325cdd2c32dff71380 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:15:26 -0700 Subject: [PATCH 14/41] docs: mark Linux support in 0.7.0 as an early preview The 0.7.0 notes claimed full Linux support; in reality the AppImage is untested and may not work properly. Both the GitHub release draft and the changelog now say so, with the stated focus on macOS and Windows first. --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 176d7d6..a87189c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,11 +43,14 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.7.0] - 2026-07-06 ### Added -- Windows and Linux support: InstantNotes now ships an installer for Windows - (x64) and an AppImage for Linux (x64) alongside the macOS build, all with - in-app updates. On Windows and Linux the capture panel is summoned with - `Ctrl+Shift+Space`, Settings and Quit live in the File menu, and shortcut - labels show `Ctrl+` combinations instead of mac glyphs. +- Windows support: InstantNotes now ships an installer for Windows (x64) + alongside the macOS build, both with in-app updates. On Windows the capture + panel is summoned with `Ctrl+Shift+Space`, Settings and Quit live in the + File menu, and shortcut labels show `Ctrl+` combinations instead of mac + glyphs. +- Linux (early preview): an AppImage (x64) is included, but Linux support is + still under development and may not work properly yet. The current focus is + getting InstantNotes fully functional on macOS and Windows first. - Settings is now a small wiki: a landing grid of category cards opening focused sub-pages with a breadcrumb back, including the new Contexting page. - Contexting: a template that shapes what "Copy note as context" hands to From 905de589be89c606e45a77a2a76f8b43832696d4 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:09:21 -0700 Subject: [PATCH 15/41] spec: add spaces design for the workspace management rework Workspaces become Spaces: rows adopt the Tags interaction contract (context menu, double-click rename, no resting chrome), delete becomes immediate with an undo toast, and a space gains scoped tag chips so one place can be sliced by the tags captured into it. --- .../specs/2026-07-10-spaces-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-spaces-design.md diff --git a/docs/superpowers/specs/2026-07-10-spaces-design.md b/docs/superpowers/specs/2026-07-10-spaces-design.md new file mode 100644 index 0000000..f0b31d0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-spaces-design.md @@ -0,0 +1,102 @@ +# Spaces Design (Workspace Management Rework) + +**Date:** 2026-07-10 +**Branch target:** `feat/spaces` +**Status:** Approved - pending implementation plan +**Interactive demo:** https://claude.ai/code/artifact/0b62b62a-1682-440f-a16d-e464b9197686 (Direction 2.1) + +--- + +## Concept + +A **space** is an intentional place you go to work on one thing. A **tag** is emergent: it is born at capture time ("fix xyz bug #bug", hotkey, done) and closes the open loop with zero ceremony. Both stay one-click destinations in the sidebar. + +The razor: if you would go there to add something, it is a space. If you would look for it across places, it is a tag. + +Consequences of the definition: + +- Empty spaces are legitimate (intent before content) and always stay visible. Tags with zero uses stay hidden, as today. +- Deleting a space never touches notes, so deletion is routine and low-stakes: immediate, with Undo, no confirm dialog. +- Rename is a routine verb and must be reachable. +- At the graph and local-AI stages, a space is the natural context boundary (subgraph, retrieval scope). Nothing in this design needs undoing then. + +**Vocabulary:** the UI says "Space / Spaces" everywhere. The backend, schema, and command names keep the `workspace` naming (`note_workspaces`, `rename_workspace`, and so on). Renaming storage internals is churn with zero user value. + +--- + +## Summary + +1. Space rows adopt the exact Tags interaction contract: no resting chrome, context menu (Rename / Delete), double-click inline rename. The hover-reveal delete button is removed. +2. Delete becomes immediate + undo toast, replacing the confirm dialog. Undo restores the space and its memberships. +3. Inside a space, the note list header gains tag chips scoped to that space's own tags. Tapping one filters within the space. This is the new capability: one "To do" space sliced by #school / #car / #errand instead of parallel todo spaces. +4. UI wording sweep from "workspace" to "space". + +--- + +## Interaction design + +| Action | Behavior | +|---|---| +| Switch | Click a row. Click the active row again to return to All Notes (unchanged). | +| Create | The quiet "New space..." input at the bottom of the section (unchanged position). Enter commits; an existing name switches to that space (current `getOrCreateWorkspace` behavior). | +| Rename | Double-click the row, or context menu > Rename Space. Enter commits, Escape cancels, blur cancels. Empty or duplicate names keep edit mode and show the inline error + shake, exactly like `TagRow`. Backed by the already-tested `rename_workspace` (duplicate guard exists). | +| Delete | Context menu > Delete Space (danger item). Immediate. Toast: `Deleted "Movies" - notes are kept` with an Undo action. No confirm dialog. | +| Context menu | Right-click or Shift+F10, via the existing `ContextMenu.svelte`. Items: Rename Space, Delete Space. | + +### Scoped tag chips + +- Shown only when a space is active, and only for tags present on that space's notes. No chips in All Notes (the rail already covers global tag access) and none when the space has no tagged notes. +- Tap toggles the filter within the space; the note count in the header reflects the filtered list. +- Entering or leaving a space, or switching spaces, clears the scoped tag. +- Rail tags keep today's semantics: global, one click, exclusive with spaces. + +--- + +## Store changes (`library.svelte.ts`) + +| Change | Detail | +|---|---| +| New state `scopedTagId` | Composes with `activeWorkspaceId`: `#filter()` sets both `workspaceId` and `tagIds` when a space and a scoped tag are active. `selectWorkspace()` resets it. Global `setTagFilter()` keeps its current semantics (clears the workspace). | +| `removeWorkspace()` | Snapshot the space's member note ids, delete, then `toasts.show` with an Undo action. Undo recreates the space by name and re-adds memberships, mirroring `#undoSoftDelete` (failures land in a plain toast, never throw into the toast handler). | +| Rename path | New `renameWorkspace()` following the Sidebar `renameTag` shape: call the client, refresh, map `ApiError` to an inline `{ ok, message }` result. | +| Active-space rename | `workspaces:changed` already refreshes the list; the implementation must also refresh `selectedWorkspaces` when a note is open so editor membership chips pick up the new name. | +| Delete active space | Lands in All Notes (`refreshWorkspaces` already handles the space disappearing; keep the explicit reset in `removeWorkspace` too). | + +--- + +## Backend + +- `rename_workspace` exists and is unit-tested (Rust + TS client, duplicate-name guard). This design finally wires it to the UI. +- **Undo fidelity item (verify during implementation):** the membership snapshot must include archived and trashed member notes. If `list_notes({ workspaceId })` excludes them by default, extend `delete_workspace` to return the member note ids (Rust + TS client + tests) instead of snapshotting client-side. +- Undo recreates the space via `get_or_create_workspace`, so it gets a new id. Acceptable: nothing persists space ids across sessions, and the active selection was already reset. + +--- + +## Files changed + +| File | Change | +|---|---| +| `src/lib/components/SpaceRow.svelte` | **New.** Sibling of `TagRow.svelte` with the same contract (select, dblclick rename, inline error, context menu hook). A light sibling, not a premature abstraction over two consumers. | +| `src/lib/components/Sidebar.svelte` | Space rows via `SpaceRow`; remove hover-x markup and CSS; remove the confirm-dialog delete path; menu state for spaces; wording. | +| `src/lib/components/NoteList.svelte` | Scoped tag chip row below the toolbar when a space is active. The status-filter pills already hide there (`NoteList.svelte:46`), so the chips take that slot rather than adding a new band. Also the "No notes in this workspace yet" empty-state wording. | +| `src/lib/stores/library.svelte.ts` | `scopedTagId`, `renameWorkspace()`, undo-delete `removeWorkspace()`. | +| `src/lib/components/NoteEditor.svelte` | Wording: "Add to space...", membership chip labels. | +| `src-tauri/src/lib.rs` | Only if the undo fidelity item requires `delete_workspace` to return member ids. | +| Command palette / misc strings | Sweep any "workspace" UI strings found in `commands.ts`, `palette-sections.ts`, hints, tooltips. | + +--- + +## Testing + +- Store tests: undo restores memberships (including archived/trashed members), delete of the active space resets to All Notes, scoped tag composes with the space filter and clears on space switch. +- Component behavior: rename commit / cancel / duplicate error parity with `TagRow`; menu opens via right-click and Shift+F10. +- Rust tests only if `delete_workspace` changes shape. +- Existing `rename_workspace` tests already cover the duplicate guard. + +--- + +## Out of scope + +- Reordering spaces, bulk operations, a manage modal, an Unfiled view (revisit at the graph stage). +- Any schema or backend renaming from `workspace` to `space`. +- Per-note membership UI in the editor (unchanged apart from wording). From 949f06b2ed21ae4d48765eddf73543d59ef9c86b Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:34:10 -0700 Subject: [PATCH 16/41] feat: turn workspaces into spaces with undo delete and scoped tag chips Space rows adopt the tag rows' contract: context menu and double-click rename replace the hover delete button, wired to the already-tested rename_workspace command. Deleting a space is immediate with an Undo toast that restores every membership, so delete_workspace now returns the member note ids (a re-list would miss archived and trashed members). Inside a space the note list grows tag chips scoped to that space's own notes, backed by a new list_workspace_tags query. --- src-tauri/core/src/store.rs | 46 ++++- src-tauri/core/tests/store_test.rs | 74 ++++++++ src-tauri/src/lib.rs | 19 +- src/lib/api/client.test.ts | 19 +- src/lib/api/client.ts | 12 +- src/lib/components/NoteEditor.svelte | 4 +- src/lib/components/NoteList.svelte | 47 ++++- src/lib/components/Sidebar.svelte | 117 +++++-------- src/lib/components/SpaceRow.svelte | 208 ++++++++++++++++++++++ src/lib/components/TagRow.svelte | 5 + src/lib/stores/library.svelte.test.ts | 240 ++++++++++++++++++++++++-- src/lib/stores/library.svelte.ts | 155 ++++++++++++++++- 12 files changed, 834 insertions(+), 112 deletions(-) create mode 100644 src/lib/components/SpaceRow.svelte diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index b6f56fd..6dc24f9 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -882,14 +882,46 @@ impl Store { } /// Removes the workspace and its memberships; notes are untouched. - pub fn delete_workspace(&mut self, id: &str) -> Result<()> { - let affected = self - .conn + /// Returns the member note ids so the caller can offer an undo that + /// re-adds every membership: a post-hoc `list_notes` snapshot can't, + /// because its default filter hides archived and trashed members. + pub fn delete_workspace(&mut self, id: &str) -> Result> { + self.fetch_workspace(id)?; + let member_ids = { + let mut stmt = self + .conn + .prepare("SELECT note_id FROM note_workspaces WHERE workspace_id = ?1")?; + let rows = stmt.query_map(params![id], |r| r.get(0))?; + rows.collect::>>()? + }; + self.conn .execute("DELETE FROM workspaces WHERE id = ?1", params![id])?; - if affected == 0 { - return Err(AppError::NotFound(format!("workspace {id} not found"))); - } - Ok(()) + Ok(member_ids) + } + + /// Tags carried by a workspace's visible notes, with counts scoped to + /// the workspace (the note list's tag chips). Archived and trashed + /// members don't contribute: a chip must never filter the visible + /// list down to zero matches for a tag the user can't see. + pub fn list_workspace_tags(&self, workspace_id: &str) -> Result> { + self.fetch_workspace(workspace_id)?; + let mut stmt = self.conn.prepare( + "SELECT t.id, t.name, t.color, t.created_at, t.updated_at, \ + COUNT(*) AS usage_count \ + FROM tags t \ + JOIN note_tags nt ON nt.tag_id = t.id \ + JOIN note_workspaces nw ON nw.note_id = nt.note_id \ + JOIN notes n ON n.id = nt.note_id \ + WHERE nw.workspace_id = ?1 AND n.is_deleted = 0 AND n.is_archived = 0 \ + GROUP BY t.id ORDER BY t.name", + )?; + let rows = stmt.query_map(params![workspace_id], |row| { + Ok(TagWithCount { + tag: row_to_tag(row)?, + usage_count: row.get(5)?, + }) + })?; + Ok(rows.collect::>>()?) } /// Collect a note into a workspace (idempotent). diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index 1d5d5cd..dc9586f 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -833,6 +833,80 @@ fn delete_workspace_keeps_notes() { assert!(matches!(err, AppError::NotFound(_))); } +#[test] +fn delete_workspace_returns_every_member_id_for_undo() { + let mut s = store(); + let ws = s.get_or_create_workspace("Disbanded").unwrap(); + let live = create(&mut s, "live member"); + let archived = create(&mut s, "archived member"); + let trashed = create(&mut s, "trashed member"); + for n in [&live, &archived, &trashed] { + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + } + s.update_note( + &archived.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + s.soft_delete_note(&trashed.id).unwrap(); + + let mut member_ids = s.delete_workspace(&ws.id).unwrap(); + member_ids.sort(); + let mut expected = vec![live.id.clone(), archived.id.clone(), trashed.id.clone()]; + expected.sort(); + assert_eq!(member_ids, expected); + + // The returned ids are enough to rebuild the space with full fidelity. + let again = s.get_or_create_workspace("Disbanded").unwrap(); + for id in &member_ids { + s.add_note_to_workspace(id, &again.id).unwrap(); + } + assert_eq!(s.workspaces_for_note(&trashed.id).unwrap().len(), 1); + assert_eq!(s.workspaces_for_note(&archived.id).unwrap().len(), 1); +} + +#[test] +fn list_workspace_tags_scopes_counts_to_visible_members() { + let mut s = store(); + let ws = s.get_or_create_workspace("To do").unwrap(); + let school = create(&mut s, "essay draft #school"); + let car = create(&mut s, "oil change #car"); + let gone = create(&mut s, "old chore #car"); + let _outside = create(&mut s, "unrelated #school"); + for n in [&school, &car, &gone] { + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + } + s.soft_delete_note(&gone.id).unwrap(); + + let tags = s.list_workspace_tags(&ws.id).unwrap(); + let summary: Vec<(&str, i64)> = tags + .iter() + .map(|t| (t.tag.name.as_str(), t.usage_count)) + .collect(); + // #car counts one member (the trashed one is invisible); the note + // outside the workspace never contributes to #school. + assert_eq!(summary, vec![("car", 1), ("school", 1)]); + + // Archived members drop out of the chips too. + s.update_note( + &car.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + let tags = s.list_workspace_tags(&ws.id).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag.name, "school"); + + let err = s.list_workspace_tags("missing-ws").unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); +} + #[test] fn workspace_membership_roundtrip() { let mut s = store(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0516d6a..057fdf3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -227,11 +227,23 @@ fn rename_workspace( } #[tauri::command(async)] -fn delete_workspace(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult<()> { - locked(&state)?.delete_workspace(&id)?; +fn delete_workspace( + state: State<'_, AppState>, + app: AppHandle, + id: String, +) -> CmdResult> { + let member_note_ids = locked(&state)?.delete_workspace(&id)?; emit_workspaces_changed(&app); emit_notes_changed(&app); - Ok(()) + Ok(member_note_ids) +} + +#[tauri::command(async)] +fn list_workspace_tags( + state: State<'_, AppState>, + workspace_id: String, +) -> CmdResult> { + Ok(locked(&state)?.list_workspace_tags(&workspace_id)?) } #[tauri::command(async)] @@ -919,6 +931,7 @@ pub fn run() { get_or_create_workspace, rename_workspace, delete_workspace, + list_workspace_tags, add_note_to_workspace, remove_note_from_workspace, workspaces_for_note, diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts index b368bde..f6ec4fa 100644 --- a/src/lib/api/client.test.ts +++ b/src/lib/api/client.test.ts @@ -12,6 +12,7 @@ import { ApiError, getOrCreateWorkspace, listWorkspaces, + listWorkspaceTags, removeNoteFromWorkspace, renameWorkspace, deleteWorkspace, @@ -46,12 +47,20 @@ describe("workspace client wrappers", () => { }); }); - it("deleteWorkspace passes the id", async () => { - invoke.mockResolvedValue(undefined); - await deleteWorkspace("w1"); + it("deleteWorkspace passes the id and returns the member note ids", async () => { + invoke.mockResolvedValue(["n1", "n2"]); + await expect(deleteWorkspace("w1")).resolves.toEqual(["n1", "n2"]); expect(invoke).toHaveBeenCalledWith("delete_workspace", { id: "w1" }); }); + it("listWorkspaceTags passes the workspaceId", async () => { + invoke.mockResolvedValue([]); + await expect(listWorkspaceTags("w1")).resolves.toEqual([]); + expect(invoke).toHaveBeenCalledWith("list_workspace_tags", { + workspaceId: "w1", + }); + }); + it("membership wrappers pass noteId and workspaceId", async () => { invoke.mockResolvedValue(undefined); await addNoteToWorkspace("n1", "w1"); @@ -69,7 +78,9 @@ describe("workspace client wrappers", () => { it("workspacesForNote passes the noteId", async () => { invoke.mockResolvedValue([]); await workspacesForNote("n1"); - expect(invoke).toHaveBeenCalledWith("workspaces_for_note", { noteId: "n1" }); + expect(invoke).toHaveBeenCalledWith("workspaces_for_note", { + noteId: "n1", + }); }); it("maps structured backend errors to ApiError", async () => { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 4afca11..db91fbb 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -23,7 +23,10 @@ export class ApiError extends Error { } } -async function call(cmd: string, args?: Record): Promise { +async function call( + cmd: string, + args?: Record, +): Promise { try { return await invoke(cmd, args); } catch (e) { @@ -72,8 +75,13 @@ export const getOrCreateWorkspace = (name: string) => call("get_or_create_workspace", { name }); export const renameWorkspace = (id: string, name: string) => call("rename_workspace", { id, name }); +// Returns the member note ids (including archived and trashed members) so +// the caller can offer an undo that restores every membership. export const deleteWorkspace = (id: string) => - call("delete_workspace", { id }); + call("delete_workspace", { id }); +// Tags on the workspace's visible notes, counts scoped to the workspace. +export const listWorkspaceTags = (workspaceId: string) => + call("list_workspace_tags", { workspaceId }); export const addNoteToWorkspace = (noteId: string, workspaceId: string) => call("add_note_to_workspace", { noteId, workspaceId }); export const removeNoteFromWorkspace = (noteId: string, workspaceId: string) => diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 8884a73..75a4640 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -94,7 +94,7 @@ {ws.name} @@ -103,7 +103,7 @@
diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index bd111fc..b867898 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -43,6 +43,22 @@ /> + {#if library.activeWorkspaceId && !library.searchResults && library.workspaceTags.length > 0} + +
+ {#each library.workspaceTags as tag (tag.id)} + + {/each} +
+ {/if} {#if !library.activeWorkspaceId && !library.activeTagId && !library.searchResults}
{#each statusFilters as f (f.id)} @@ -94,8 +110,10 @@ {:else}
- {#if library.activeWorkspaceId} - No notes in this workspace yet. Open a note and add it here. + {#if library.activeWorkspaceId && library.scopedTagId} + No notes with this tag in this space. + {:else if library.activeWorkspaceId} + Nothing here yet. New notes land in this space while you're in it. {:else if library.statusFilter === "trash"} Trash is empty. {:else if library.statusFilter === "archived"} @@ -150,6 +168,31 @@ padding: 6px 10px; border-bottom: 1px solid var(--border); } + /* Occupies the status-filter's slot: the pills hide inside a space. */ + .space-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + } + .space-tag-chip { + padding: 2px 10px; + border: 1px solid var(--border); + border-radius: 99px; + font-size: 11px; + font-family: var(--font-meta); + color: var(--text-secondary); + } + .space-tag-chip:hover { + background: var(--bg-hover); + } + .space-tag-chip.on { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-text); + font-weight: 500; + } .filter-pill { padding: 2px 10px; border-radius: 99px; diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 0b158b7..2abc6ac 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -4,31 +4,33 @@ import { friendlyMessage } from "$lib/errors"; import { confirmDialog } from "$lib/stores/confirm.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; + import SpaceRow from "$lib/components/SpaceRow.svelte"; import TagRow from "$lib/components/TagRow.svelte"; import ContextMenu from "$lib/components/ContextMenu.svelte"; - import type { TagWithCount } from "$lib/api/types"; + import type { TagWithCount, WorkspaceWithCount } from "$lib/api/types"; - let newWorkspaceInput = $state(""); + let newSpaceInput = $state(""); + let spacesHeader = $state(); let tagsHeader = $state(); - // Tag management lives behind a context menu (right-click / Shift+F10) and - // double-click-to-rename, so tag rows carry no resting chrome at all. + // Space and tag management live behind a context menu (right-click / + // Shift+F10) and double-click-to-rename, so rows carry no resting chrome. + let renamingSpaceId = $state(null); + let spaceMenu = $state<{ x: number; y: number; ws: WorkspaceWithCount } | null>(null); let renamingTagId = $state(null); let tagMenu = $state<{ x: number; y: number; tag: TagWithCount } | null>(null); - async function submitNewWorkspace(e: Event) { + async function submitNewSpace(e: Event) { e.preventDefault(); - await library.createWorkspace(newWorkspaceInput); - newWorkspaceInput = ""; + await library.createWorkspace(newSpaceInput); + newSpaceInput = ""; } - async function confirmDeleteWorkspace(id: string, name: string) { - const ok = await confirmDialog.ask({ - title: `Delete workspace "${name}"?`, - body: "Its notes are kept; only the workspace is removed.", - confirmLabel: "Delete Workspace", - tone: "danger", - }); - if (ok) await library.removeWorkspace(id); + // Deleting a space never touches notes, so it goes straight through with + // an Undo toast (shown by the store) instead of a confirm dialog. + async function deleteSpace(ws: WorkspaceWithCount): Promise { + await library.removeWorkspace(ws.id); + // The row that held focus is gone; land somewhere stable nearby. + queueMicrotask(() => spacesHeader?.focus()); } // The tag keeps its id across a rename, so an active filter on it stays @@ -90,35 +92,28 @@ All Notes -
Workspaces
+
Spaces
@@ -142,6 +137,19 @@ +{#if spaceMenu} + {@const menuWs = spaceMenu.ws} + (renamingSpaceId = menuWs.id) }, + { label: "Delete Space", danger: true, run: () => void deleteSpace(menuWs) }, + ]} + onclose={() => (spaceMenu = null)} + /> +{/if} + {#if tagMenu} {@const menuTag = tagMenu.tag} + // One space in the sidebar: click switches, double-click renames in place, + // right-click (or Shift+F10) opens the row's context menu. Same contract + // as TagRow, and the same rule: no resting chrome, a row is just the space. + import type { WorkspaceWithCount } from "$lib/api/types"; + + let { + workspace, + active, + editing, + onSelect, + onStartRename, + onRename, + onDoneRename, + onMenu, + }: { + workspace: WorkspaceWithCount; + active: boolean; + editing: boolean; + onSelect: () => void; + onStartRename: () => void; + onRename: (name: string) => Promise<{ ok: true } | { ok: false; message: string }>; + onDoneRename: () => void; + onMenu: (x: number, y: number) => void; + } = $props(); + + let editValue = $state(""); + let editError = $state(null); + let selectButton = $state(); + let inputEl = $state(); + let wasEditing = false; + + const noteLabel = $derived( + `${workspace.noteCount} note${workspace.noteCount === 1 ? "" : "s"}`, + ); + + // Seed and focus the input when the parent puts this row into edit mode; + // hand focus back to the row itself when editing ends. + $effect(() => { + if (editing && !wasEditing) { + editValue = workspace.name; + editError = null; + queueMicrotask(() => { + inputEl?.focus(); + inputEl?.select(); + }); + } else if (!editing && wasEditing) { + queueMicrotask(() => selectButton?.focus()); + } + wasEditing = editing; + }); + + async function commitRename() { + if (!editing) return; + // The backend normalizes to a trimmed name; mirror it here so "same + // name with spaces" is a no-op instead of a round-trip. + const normalized = editValue.trim(); + if (!normalized) { + editError = "Space name can't be empty"; + editValue = workspace.name; + return; + } + if (normalized === workspace.name) { + onDoneRename(); + return; + } + const result = await onRename(normalized); + if (!editing) return; // blurred away while the request was in flight + if (result.ok) { + onDoneRename(); + } else { + editError = result.message; + editValue = workspace.name; + } + } + + function onRenameKeydown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + void commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + onDoneRename(); + } + } + + function onContextMenu(e: MouseEvent) { + e.preventDefault(); + onMenu(e.clientX, e.clientY); + } + + // Shift+F10 is the keyboard's right-click. + function onRowKeydown(e: KeyboardEvent) { + if (e.shiftKey && e.key === "F10") { + e.preventDefault(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + onMenu(rect.left + 12, rect.bottom - 2); + } + } + + +{#if editing} +
+ +
+ {#if editError} + + {/if} +{:else} + +{/if} + + diff --git a/src/lib/components/TagRow.svelte b/src/lib/components/TagRow.svelte index dc6af5f..be654b0 100644 --- a/src/lib/components/TagRow.svelte +++ b/src/lib/components/TagRow.svelte @@ -195,6 +195,11 @@ transform: translateX(3px); } } + @media (prefers-reduced-motion: reduce) { + .tag-rename-input.invalid { + animation: none; + } + } .tag-error { margin: 2px 0 4px; padding: 0 10px; diff --git a/src/lib/stores/library.svelte.test.ts b/src/lib/stores/library.svelte.test.ts index 37e2167..5003a0b 100644 --- a/src/lib/stores/library.svelte.test.ts +++ b/src/lib/stores/library.svelte.test.ts @@ -12,12 +12,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + addNoteToWorkspace, ApiError, + deleteWorkspace, getNote, + getOrCreateWorkspace, listNotes, listTags, listWorkspaces, + listWorkspaceTags, permanentlyDeleteNote, + renameWorkspace, searchNotes, softDeleteNote, tagsForNote, @@ -25,7 +30,12 @@ import { workspacesForNote, } from "$lib/api/client"; import { listen } from "@tauri-apps/api/event"; -import type { Note, SearchResult } from "$lib/api/types"; +import type { + Note, + SearchResult, + TagWithCount, + WorkspaceWithCount, +} from "$lib/api/types"; vi.mock("$lib/api/client", () => { class ApiError extends Error { @@ -49,7 +59,9 @@ vi.mock("$lib/api/client", () => { listTags: vi.fn(), listWorkspaces: vi.fn(), getOrCreateWorkspace: vi.fn(), + renameWorkspace: vi.fn(), deleteWorkspace: vi.fn(), + listWorkspaceTags: vi.fn(), addNoteToWorkspace: vi.fn(), removeNoteFromWorkspace: vi.fn(), workspacesForNote: vi.fn(), @@ -73,6 +85,11 @@ const mockTagsForNote = vi.mocked(tagsForNote); const mockWorkspacesForNote = vi.mocked(workspacesForNote); const mockPermanentlyDeleteNote = vi.mocked(permanentlyDeleteNote); const mockSoftDeleteNote = vi.mocked(softDeleteNote); +const mockDeleteWorkspace = vi.mocked(deleteWorkspace); +const mockRenameWorkspace = vi.mocked(renameWorkspace); +const mockGetOrCreateWorkspace = vi.mocked(getOrCreateWorkspace); +const mockAddNoteToWorkspace = vi.mocked(addNoteToWorkspace); +const mockListWorkspaceTags = vi.mocked(listWorkspaceTags); const mockListen = vi.mocked(listen); function mkNote(id: string, overrides: Partial = {}): Note { @@ -141,6 +158,11 @@ beforeEach(() => { mockWorkspacesForNote.mockReset().mockResolvedValue([]); mockPermanentlyDeleteNote.mockReset(); mockSoftDeleteNote.mockReset(); + mockDeleteWorkspace.mockReset(); + mockRenameWorkspace.mockReset(); + mockGetOrCreateWorkspace.mockReset(); + mockAddNoteToWorkspace.mockReset(); + mockListWorkspaceTags.mockReset().mockResolvedValue([]); mockListen.mockReset().mockResolvedValue(() => {}); }); @@ -196,7 +218,9 @@ describe("save queue", () => { const library = await load(); await selectNote(library, "n1"); - mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "disk full")); + mockUpdateNote.mockRejectedValue( + new ApiError("STORAGE_ERROR", "disk full"), + ); library.editBody("doomed"); await vi.advanceTimersByTimeAsync(400); @@ -206,7 +230,9 @@ describe("save queue", () => { await vi.advanceTimersByTimeAsync(2000); expect(mockUpdateNote).toHaveBeenCalledTimes(2); expect(library.saveState).toBe("failed"); - expect(library.error).toBe("Your note couldn't be saved. Please try again."); + expect(library.error).toBe( + "Your note couldn't be saved. Please try again.", + ); }); }); @@ -243,7 +269,9 @@ describe("flushPendingEdits", () => { // and a later flush would still have something to retry (proven by the // note staying selected/dirty rather than the state resetting to idle). expect(library.saveState).toBe("failed"); - expect(library.error).toBe("Your note couldn't be saved. Please try again."); + expect(library.error).toBe( + "Your note couldn't be saved. Please try again.", + ); }); it("fixed: a flush of one dirty note performs exactly one write attempt, and no timer survives once it resolves", async () => { @@ -345,7 +373,9 @@ describe("soft delete flushes queued edits (Undo restores the last keystrokes)", library.editBody("last keystrokes"); await library.deleteSelected(); - expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "last keystrokes" }); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "last keystrokes", + }); expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); // The write must land before the trash, or a restore loses the edit. const write = mockUpdateNote.mock.invocationCallOrder[0]; @@ -359,13 +389,17 @@ describe("soft delete flushes queued edits (Undo restores the last keystrokes)", it("bulkDelete persists pending edits for the selection before trashing", async () => { const library = await load(); await selectNote(library, "n1"); - mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "unsaved bulk edit" })); + mockUpdateNote.mockResolvedValue( + mkNote("n1", { body: "unsaved bulk edit" }), + ); mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); library.editBody("unsaved bulk edit"); await library.bulkDelete(); - expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "unsaved bulk edit" }); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "unsaved bulk edit", + }); expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); await vi.advanceTimersByTimeAsync(3000); expect(mockUpdateNote).toHaveBeenCalledTimes(1); @@ -374,7 +408,9 @@ describe("soft delete flushes queued edits (Undo restores the last keystrokes)", it("a failed pre-trash flush still trashes the note and surfaces the error", async () => { const library = await load(); await selectNote(library, "n1"); - mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "disk full")); + mockUpdateNote.mockRejectedValue( + new ApiError("STORAGE_ERROR", "disk full"), + ); mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); library.editBody("doomed edit"); @@ -393,7 +429,9 @@ describe("refresh race token", () => { const library = await load(); const older = deferred(); const newer = deferred(); - mockListNotes.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + mockListNotes + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); const p1 = library.refresh(); const p2 = library.refresh(); @@ -415,7 +453,9 @@ describe("refresh race token", () => { const older = deferred(); const newer = deferred(); - mockSearchNotes.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + mockSearchNotes + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); const p1 = library.refresh(); const p2 = library.refresh(); @@ -506,9 +546,9 @@ describe("init ordering", () => { await library.init(); mockListNotes.mockClear(); - const handler = mockListen.mock.calls.find((c) => c[0] === "notes:changed")?.[1] as - | (() => void) - | undefined; + const handler = mockListen.mock.calls.find( + (c) => c[0] === "notes:changed", + )?.[1] as (() => void) | undefined; expect(handler).toBeTypeOf("function"); handler!(); @@ -517,3 +557,177 @@ describe("init ordering", () => { expect(mockListNotes).toHaveBeenCalledTimes(1); }); }); + +// ---- Spaces rework: scoped tag chips + undo-able workspace delete ---- + +function mkTagWithCount(id: string, name: string): TagWithCount { + return { + id, + name, + color: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + usageCount: 1, + }; +} + +function mkWorkspace(id: string, name: string): WorkspaceWithCount { + return { + id, + name, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + noteCount: 0, + }; +} + +describe("scoped tag filter (chips inside a workspace)", () => { + it("composes with the workspace filter, toggles off, and resets on switch", async () => { + const library = await load(); + mockListWorkspaceTags.mockResolvedValue([ + mkTagWithCount("t-school", "school"), + ]); + + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(mockListNotes).toHaveBeenLastCalledWith({ + workspaceId: "ws1", + tagIds: ["t-school"], + }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + library.selectWorkspace("ws2"); + expect(library.scopedTagId).toBeNull(); + }); + + it("is inert outside a workspace and never leaks into the global tag filter", async () => { + const library = await load(); + library.toggleScopedTag("t-anything"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + + mockListWorkspaceTags.mockResolvedValue([mkTagWithCount("t-x", "x")]); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + library.toggleScopedTag("t-x"); + await vi.advanceTimersByTimeAsync(0); + + library.setTagFilter("t-global"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ tagIds: ["t-global"] }); + }); + + it("drops a scoped tag that vanished from the workspace's visible notes", async () => { + const library = await load(); + mockListWorkspaceTags.mockResolvedValue([ + mkTagWithCount("t-school", "school"), + ]); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBe("t-school"); + + // The last #school note was edited away; the chip data comes back empty. + mockListWorkspaceTags.mockResolvedValue([]); + await library.refresh(); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + }); +}); + +describe("workspace delete with undo", () => { + it("deletes immediately and the toast's Undo re-adds every member id", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Movies")]); + await library.refreshWorkspaces(); + mockDeleteWorkspace.mockResolvedValue(["n1", "n2", "n3"]); + + await library.removeWorkspace("ws1"); + expect(mockDeleteWorkspace).toHaveBeenCalledWith("ws1"); + + const { toasts } = await import("$lib/stores/toasts.svelte"); + expect(toasts.items).toHaveLength(1); + expect(toasts.items[0].message).toBe('Deleted "Movies" - notes are kept'); + expect(toasts.items[0].action?.label).toBe("Undo"); + + mockGetOrCreateWorkspace.mockResolvedValue(mkWorkspace("ws-new", "Movies")); + mockAddNoteToWorkspace.mockResolvedValue(undefined); + toasts.activate(toasts.items[0].id); + await vi.advanceTimersByTimeAsync(0); + + expect(mockGetOrCreateWorkspace).toHaveBeenCalledWith("Movies"); + for (const id of ["n1", "n2", "n3"]) { + expect(mockAddNoteToWorkspace).toHaveBeenCalledWith(id, "ws-new"); + } + }); + + it("deleting the active workspace lands the view in All Notes", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Doomed")]); + await library.refreshWorkspaces(); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + + mockListWorkspaces.mockResolvedValue([]); + mockDeleteWorkspace.mockResolvedValue([]); + await library.removeWorkspace("ws1"); + expect(library.activeWorkspaceId).toBeNull(); + expect(library.scopedTagId).toBeNull(); + }); + + it("a partial undo (a member was destroyed meanwhile) reports what it restored", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Movies")]); + await library.refreshWorkspaces(); + mockDeleteWorkspace.mockResolvedValue(["n1", "n2"]); + await library.removeWorkspace("ws1"); + + const { toasts } = await import("$lib/stores/toasts.svelte"); + mockGetOrCreateWorkspace.mockResolvedValue(mkWorkspace("ws-new", "Movies")); + mockAddNoteToWorkspace + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new ApiError("NOT_FOUND", "note n2 not found")); + toasts.activate(toasts.items[0].id); + await vi.advanceTimersByTimeAsync(0); + + expect(toasts.items).toHaveLength(1); + expect(toasts.items[0].message).toBe( + 'Restored "Movies" without 1 of 2 notes.', + ); + }); +}); + +describe("workspace rename", () => { + it("returns ok and refreshes the list on success", async () => { + const library = await load(); + mockRenameWorkspace.mockResolvedValue(mkWorkspace("ws1", "Gamma")); + mockListWorkspaces.mockClear(); + const result = await library.renameWorkspace("ws1", "Gamma"); + expect(result).toEqual({ ok: true }); + expect(mockRenameWorkspace).toHaveBeenCalledWith("ws1", "Gamma"); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("surfaces a duplicate name as an inline error, not a thrown error", async () => { + const library = await load(); + mockRenameWorkspace.mockRejectedValue(new ApiError("CONFLICT", "exists")); + const result = await library.renameWorkspace("ws1", "Beta"); + expect(result).toEqual({ + ok: false, + message: "That name is already in use.", + }); + }); +}); diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 8dcb8bf..2a08fde 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -12,9 +12,11 @@ import { listNotes, listTags, listWorkspaces, + listWorkspaceTags, permanentlyDeleteNote, removeNoteFromWorkspace, removeTagFromNote, + renameWorkspace, restoreNote, searchNotes, softDeleteNote, @@ -56,6 +58,12 @@ class LibraryStore { statusFilter = $state("active"); activeWorkspaceId = $state(null); activeTagId = $state(null); + // Tag filter applied within the active workspace (the note list's chip + // row). Composes with activeWorkspaceId; the global activeTagId replaces + // the workspace instead. + scopedTagId = $state(null); + // Tags carried by the active workspace's visible notes; drives the chips. + workspaceTags = $state([]); searchText = $state(""); notes = $state([]); searchResults = $state(null); @@ -118,7 +126,10 @@ class LibraryStore { const f: NoteFilter = {}; if (this.statusFilter === "archived") f.isArchived = true; if (this.statusFilter === "trash") f.isDeleted = true; - if (this.activeWorkspaceId) f.workspaceId = this.activeWorkspaceId; + if (this.activeWorkspaceId) { + f.workspaceId = this.activeWorkspaceId; + if (this.scopedTagId) f.tagIds = [this.scopedTagId]; + } if (this.activeTagId) f.tagIds = [this.activeTagId]; return f; } @@ -143,6 +154,13 @@ class LibraryStore { this.notes = notes; } this.error = null; + // Chips ride along on every refresh: notes:changed also fires when a + // note's inline tags change, which is exactly when they go stale. + if (this.activeWorkspaceId) { + void this.#refreshWorkspaceTags(); + } else if (this.workspaceTags.length > 0) { + this.workspaceTags = []; + } } catch (e) { if (token !== this.#refreshToken) return; this.#fail(e); @@ -182,6 +200,8 @@ class LibraryStore { /** Show All Notes (null) or one workspace's collected notes. */ selectWorkspace(workspaceId: string | null): void { this.activeWorkspaceId = workspaceId; + this.scopedTagId = null; + this.workspaceTags = []; this.statusFilter = "active"; this.activeTagId = null; this.searchText = ""; @@ -192,16 +212,56 @@ class LibraryStore { setTagFilter(tagId: string | null): void { this.activeTagId = tagId; this.activeWorkspaceId = null; + this.scopedTagId = null; + this.workspaceTags = []; this.statusFilter = "active"; this.searchText = ""; this.clearMultiSelect(); void this.refresh(); } + /** Toggle a chip: filter the active workspace's list by one of its tags. */ + toggleScopedTag(tagId: string): void { + if (!this.activeWorkspaceId) return; + this.scopedTagId = this.scopedTagId === tagId ? null : tagId; + this.clearMultiSelect(); + void this.refresh(); + } + + /** + * Re-query the chip row for the active workspace. The scoped tag is + * dropped when it no longer exists on the workspace's visible notes: a + * chip that vanished must not keep filtering the list. + */ + async #refreshWorkspaceTags(): Promise { + const id = this.activeWorkspaceId; + if (!id) { + this.workspaceTags = []; + return; + } + try { + const tags = await listWorkspaceTags(id); + if (this.activeWorkspaceId !== id) return; // switched away mid-flight + this.workspaceTags = tags; + if (this.scopedTagId && !tags.some((t) => t.id === this.scopedTagId)) { + this.scopedTagId = null; + void this.refresh(); + } + } catch (e) { + if (this.activeWorkspaceId !== id) return; + this.workspaceTags = []; + // The workspace can be deleted between the list refresh and this + // query; refreshWorkspaces resets the selection, nothing to surface. + if (!(e instanceof ApiError && e.code === "NOT_FOUND")) this.#fail(e); + } + } + setSearch(text: string): void { this.searchText = text; // Reset the multi-selection but keep the open note in the editor. - this.multiSelected = this.selected ? new Set([this.selected.id]) : new Set(); + this.multiSelected = this.selected + ? new Set([this.selected.id]) + : new Set(); this.#anchorId = this.selected?.id ?? null; this.#lastRangeEnd = this.#anchorId; if (text.trim()) { @@ -280,8 +340,7 @@ class LibraryStore { * Returns the id the selection moved to so the view can reveal it. */ async moveSelection(delta: number, extend = false): Promise { - const current = - this.#lastRangeEnd ?? this.selected?.id ?? this.#anchorId; + const current = this.#lastRangeEnd ?? this.selected?.id ?? this.#anchorId; const next = stepId(this.visibleIds, current, delta); if (!next) return null; if (extend) { @@ -415,7 +474,9 @@ class LibraryStore { ? this.tags.find((t) => t.id === this.activeTagId) : null; - const note = await createNote(activeTag ? { tags: [activeTag.name] } : {}); + const note = await createNote( + activeTag ? { tags: [activeTag.name] } : {}, + ); // A note born inside a workspace joins it; the view stays put. if (this.activeWorkspaceId) { await addNoteToWorkspace(note.id, this.activeWorkspaceId); @@ -442,16 +503,86 @@ class LibraryStore { } } - /** Delete a workspace; its notes are kept. */ + /** + * Delete a workspace; its notes are kept. Immediate, with an Undo toast: + * the operation never destroys note data, so it earns the reversible-action + * treatment instead of a confirm dialog. + */ async removeWorkspace(id: string): Promise { + const ws = this.workspaces.find((w) => w.id === id); try { - await deleteWorkspace(id); + const memberIds = await deleteWorkspace(id); if (this.activeWorkspaceId === id) this.selectWorkspace(null); + // An open note's membership chips may have shown this workspace. + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + await this.refreshWorkspaces(); + if (ws) { + toasts.show(`Deleted "${ws.name}" - notes are kept`, { + label: "Undo", + run: () => void this.#undoWorkspaceDelete(ws.name, memberIds), + }); + } } catch (e) { this.#fail(e); } } + /** + * Undo for a workspace delete: recreate it by name and re-add every + * member. The ids come from the backend at delete time so archived and + * trashed members are restored too; a member destroyed in the meantime + * fails quietly into a plain toast rather than throwing back into the + * toast's action handler. + */ + async #undoWorkspaceDelete(name: string, memberIds: string[]): Promise { + try { + const ws = await getOrCreateWorkspace(name); + const results = await Promise.allSettled( + memberIds.map((noteId) => addNoteToWorkspace(noteId, ws.id)), + ); + await this.refreshWorkspaces(); + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + const failedCount = results.filter((r) => r.status === "rejected").length; + if (failedCount > 0) { + toasts.show( + `Restored "${name}" without ${failedCount} of ${memberIds.length} notes.`, + ); + } + } catch { + toasts.show(`Couldn't restore "${name}".`); + } + } + + /** + * Rename a workspace. Returns an inline-error shape rather than throwing + * so the row's edit state can show a duplicate-name rejection in place, + * mirroring the Sidebar's tag rename. + */ + async renameWorkspace( + id: string, + name: string, + ): Promise<{ ok: true } | { ok: false; message: string }> { + try { + await renameWorkspace(id, name); + await this.refreshWorkspaces(); + // An open note's membership chips may display the old name. + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + return { ok: true }; + } catch (e) { + const message = + e instanceof ApiError + ? friendlyMessage(e.code, e.message) + : friendlyMessage(""); + return { ok: false, message }; + } + } + /** Collect the open note into a workspace by name (created if missing). */ async addSelectedToWorkspace(name: string): Promise { if (!this.selected || !name.trim()) return; @@ -589,7 +720,9 @@ class LibraryStore { await Promise.all( ids .filter((id) => this.#unsaved.has(id)) - .map((id) => this.#persistBody(id, this.#unsaved.get(id) as string, false)), + .map((id) => + this.#persistBody(id, this.#unsaved.get(id) as string, false), + ), ); } @@ -599,7 +732,11 @@ class LibraryStore { * a second failure flips the note to "failed" while keeping the edit in * #unsaved so a later flush still attempts it. */ - async #persistBody(id: string, body: string, canRetry: boolean): Promise { + async #persistBody( + id: string, + body: string, + canRetry: boolean, + ): Promise { // Any write attempt for this id, whether from the debounce, a retry, or // a flush, supersedes an outstanding scheduled retry for the same id. this.#clearRetryTimer(id); From 694799273f20cae349b000387547b441091324d1 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:45:36 -0700 Subject: [PATCH 17/41] docs: write the open-loop product thesis into the project context Capture is discharge, trust is release, resurfacing is closure. Names the target as powerful rather than simple, and the anti-goal of ever becoming a task manager. --- openspec/project.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openspec/project.md b/openspec/project.md index c984a25..900a07d 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -3,6 +3,17 @@ ## Purpose InstantNotes is a macOS desktop notes app built around fast capture and a focused library. The app should let a user save a thought from anywhere, then organize and retrieve it without forcing a folder system. +## Product Thesis +InstantNotes exists to close open loops (the Zeigarnik effect: unfinished intentions stay resident in the head until they are parked in a trusted system). Every stage of the roadmap serves one of three promises: + +1. **Capture is discharge.** Writing the thought down must cost less than carrying it. The capture path stays under the reflex threshold; a feature that adds a decision at capture time is rejected on those grounds. +2. **Trust is release.** The mind only lets go if retrieval is guaranteed. Data solidity work (save queues, undo fidelity, race protection) is this promise stated in engineering. +3. **Resurfacing is closure.** A parked loop must come back at the right moment, concrete enough to act on. Notes already carry the data for this (`updatedAt`, `lastOpenedAt`, spaces, tags); the graph and local-AI stages build on it. + +The target is **powerful, not simple**: simplicity through frictionless apparent complexity. The app helps, never hinders, never confuses, never overcomplicates. A growing library must not read as clutter; it should feel satisfying and be useful by default. The success metric is inverted from engagement: the system works when the user stops re-checking it. + +Anti-goal: InstantNotes is not a task manager. Dates, checkboxes, and notifications belong to other tools; our thirds of the loop are trusted parking, clarifying, and resurfacing. New features are tested against one question: does this close loops or create them? + ## Tech Stack - Tauri 2 desktop shell - Rust core for persistence, search, and command handling From 59dd70c274aeb46e23c6e9ecbf4c4ea463358e69 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:09:20 -0700 Subject: [PATCH 18/41] feat: resurface never-opened captures in a Revisit view A note captured through the hotkey and never opened again is an open loop; after three days it surfaces in a quiet Revisit entry under All Notes, hidden entirely at zero. Opening a note releases it on the spot, so the list burns down as you triage. Built on the existing last_opened_at column via two new NoteFilter fields. --- src-tauri/core/src/store.rs | 12 +++++ src-tauri/core/src/types.rs | 5 ++ src-tauri/core/tests/store_test.rs | 52 ++++++++++++++++++++ src/lib/api/types.ts | 4 ++ src/lib/components/NoteList.svelte | 6 ++- src/lib/components/Sidebar.svelte | 25 +++++++++- src/lib/stores/library.svelte.test.ts | 57 ++++++++++++++++++++-- src/lib/stores/library.svelte.ts | 69 ++++++++++++++++++++++++++- 8 files changed, 223 insertions(+), 7 deletions(-) diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index 6dc24f9..bc43c29 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -587,6 +587,18 @@ impl Store { args.push(Box::new(i64::from(pinned))); } + if filter.never_opened == Some(true) { + conditions.push("last_opened_at IS NULL".into()); + } + + if let Some(created_before) = &filter.created_before { + // Timestamps are stored as UTC ISO-8601, so string comparison is + // chronological; differing sub-second precision only moves the + // boundary within a second, which no caller depends on. + conditions.push("created_at < ?".into()); + args.push(Box::new(created_before.clone())); + } + if let Some(workspace_id) = &filter.workspace_id { conditions .push("id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = ?)".into()); diff --git a/src-tauri/core/src/types.rs b/src-tauri/core/src/types.rs index f182657..bce5cfa 100644 --- a/src-tauri/core/src/types.rs +++ b/src-tauri/core/src/types.rs @@ -83,6 +83,11 @@ pub struct NoteFilter { pub is_pinned: Option, pub is_archived: Option, pub is_deleted: Option, + /// Only notes never opened in the library (capture-born, untriaged). + /// Drives the Revisit view; opening a note releases it from the filter. + pub never_opened: Option, + /// Only notes created strictly before this ISO-8601 timestamp. + pub created_before: Option, pub sort_by: Option, pub sort_order: Option, pub limit: Option, diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index dc9586f..c986f5d 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -775,6 +775,58 @@ fn open_or_recover_sets_corrupt_file_aside_and_starts_fresh() { #[allow(dead_code)] fn _uses(_: AppError) {} +// ---- revisit filter (never opened + created before) ---- + +#[test] +fn never_opened_filter_releases_notes_once_touched() { + let mut s = store(); + let seen = create(&mut s, "capture that got read"); + let unseen = create(&mut s, "capture still waiting"); + // Opening with touch stamps last_opened_at and releases the note. + s.get_note(&seen.id, true).unwrap(); + + let filter = NoteFilter { + never_opened: Some(true), + ..Default::default() + }; + let loops = s.list_notes(filter).unwrap(); + let ids: Vec<_> = loops.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids, vec![unseen.id.as_str()]); + + // A plain get without touch must NOT release it. + s.get_note(&unseen.id, false).unwrap(); + let filter = NoteFilter { + never_opened: Some(true), + ..Default::default() + }; + assert_eq!(s.list_notes(filter).unwrap().len(), 1); +} + +#[test] +fn created_before_filter_is_a_strict_cutoff() { + let mut s = store(); + let n = create(&mut s, "old enough"); + let far_future = "2099-01-01T00:00:00Z".to_string(); + let far_past = "2000-01-01T00:00:00Z".to_string(); + + let hits = s + .list_notes(NoteFilter { + created_before: Some(far_future), + ..Default::default() + }) + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, n.id); + + let hits = s + .list_notes(NoteFilter { + created_before: Some(far_past), + ..Default::default() + }) + .unwrap(); + assert!(hits.is_empty()); +} + // ---- workspaces ---- #[test] diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 7d6dea4..be30d90 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -67,6 +67,10 @@ export interface NoteFilter { isPinned?: boolean; isArchived?: boolean; isDeleted?: boolean; + /** Only notes never opened in the library (capture-born, untriaged). */ + neverOpened?: boolean; + /** Only notes created strictly before this ISO-8601 timestamp. */ + createdBefore?: string; sortBy?: "updatedAt" | "createdAt" | "lastOpenedAt" | "title"; sortOrder?: "asc" | "desc"; limit?: number; diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index b867898..645de06 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -59,7 +59,7 @@ {/each}
{/if} - {#if !library.activeWorkspaceId && !library.activeTagId && !library.searchResults} + {#if !library.activeWorkspaceId && !library.activeTagId && !library.revisitMode && !library.searchResults}
{#each statusFilters as f (f.id)} + + {#if library.revisitCount > 0 || library.revisitMode} + + {/if}
Spaces
{formatDate(note.updatedAt)}
- {:else} + {/snippet} + {#if library.notes.length === 0}
{#if library.revisitMode} All caught up. Every capture has been seen. @@ -124,7 +134,18 @@ No notes yet. Press {captureShortcut} anywhere to capture your first thought. {/if}
- {/each} + {:else if groups} + {#each groups as group (group.label)} +
{group.label}
+ {#each group.notes as note (note.id)} + {@render noteRow(note)} + {/each} + {/each} + {:else} + {#each library.notes as note (note.id)} + {@render noteRow(note)} + {/each} + {/if} {/if}
@@ -220,6 +241,19 @@ min-height: 0; overflow-y: auto; } + .group-header { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 16px 4px; + background: var(--bg); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-tertiary); + font-family: var(--font-meta); + } .note-row { display: block; width: 100%; diff --git a/src/lib/note-groups.test.ts b/src/lib/note-groups.test.ts new file mode 100644 index 0000000..00966f1 --- /dev/null +++ b/src/lib/note-groups.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { groupNotes } from "./note-groups"; +import type { Note } from "$lib/api/types"; + +// Fixed local clock: mid-afternoon so day boundaries are unambiguous. +const NOW = new Date(2026, 6, 10, 15, 0, 0); // 2026-07-10 15:00 local + +function mkNote(id: string, updatedAt: Date, overrides: Partial = {}): Note { + return { + id, + title: `Note ${id}`, + body: "", + createdAt: updatedAt.toISOString(), + updatedAt: updatedAt.toISOString(), + isPinned: false, + isArchived: false, + isDeleted: false, + syncState: "local_only", + version: 1, + ...overrides, + }; +} + +function local(y: number, mo: number, d: number, h = 12): Date { + return new Date(y, mo - 1, d, h); +} + +describe("groupNotes", () => { + it("buckets across every boundary in list order", () => { + const notes = [ + mkNote("today", local(2026, 7, 10, 9)), + mkNote("yesterday", local(2026, 7, 9, 23)), + mkNote("week", local(2026, 7, 4)), + mkNote("month", local(2026, 6, 15)), + mkNote("this-year", local(2026, 3, 2)), + mkNote("last-year", local(2025, 11, 30)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual([ + "Today", + "Yesterday", + "Previous 7 Days", + "Previous 30 Days", + "March", + "2025", + ]); + expect(groups.map((g) => g.notes.length)).toEqual([1, 1, 1, 1, 1, 1]); + }); + + it("merges adjacent notes into one section", () => { + const notes = [ + mkNote("a", local(2026, 7, 10, 9)), + mkNote("b", local(2026, 7, 10, 8)), + mkNote("c", local(2026, 7, 9, 20)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => [g.label, g.notes.length])).toEqual([ + ["Today", 2], + ["Yesterday", 1], + ]); + }); + + it("floats pinned notes into their own leading section", () => { + const notes = [ + mkNote("pinned-old", local(2025, 2, 1), { isPinned: true }), + mkNote("recent", local(2026, 7, 10, 9)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Pinned", "Today"]); + }); + + it("ignores a leftover pin flag in the trash so recency order holds", () => { + const notes = [ + mkNote("t1", local(2026, 7, 10, 9), { isDeleted: true }), + mkNote("t2", local(2026, 7, 9, 9), { isDeleted: true, isPinned: true }), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday"]); + }); + + it("treats one minute past local midnight as Today, and just before as Yesterday", () => { + const notes = [ + mkNote("after", new Date(2026, 6, 10, 0, 1)), + mkNote("before", new Date(2026, 6, 9, 23, 59)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday"]); + }); + + it("returns no groups for an empty list", () => { + expect(groupNotes([], NOW)).toEqual([]); + }); +}); diff --git a/src/lib/note-groups.ts b/src/lib/note-groups.ts new file mode 100644 index 0000000..4ba9d6a --- /dev/null +++ b/src/lib/note-groups.ts @@ -0,0 +1,50 @@ +// Time-bucketed sections for the note list (the macOS-native grouping: +// Pinned, Today, Yesterday, Previous 7 Days, Previous 30 Days, month names +// for this year, then plain years). Pure and clock-injected so tests pin +// every boundary. + +import type { Note } from "$lib/api/types"; + +export interface NoteGroup { + label: string; + notes: Note[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function bucketLabel(note: Note, now: Date): string { + // Pinned floats as its own section, except in the trash, where the list + // is plain recency order and a leftover pin flag must not reorder it. + if (note.isPinned && !note.isDeleted) return "Pinned"; + + const t = new Date(note.updatedAt); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + if (t.getTime() >= startOfToday.getTime()) return "Today"; + if (t.getTime() >= startOfToday.getTime() - DAY_MS) return "Yesterday"; + if (t.getTime() >= startOfToday.getTime() - 7 * DAY_MS) return "Previous 7 Days"; + if (t.getTime() >= startOfToday.getTime() - 30 * DAY_MS) return "Previous 30 Days"; + if (t.getFullYear() === now.getFullYear()) { + return t.toLocaleString(undefined, { month: "long" }); + } + return String(t.getFullYear()); +} + +/** + * Group a note list into labeled sections, preserving the incoming order. + * Adjacent notes with the same label share a section; the caller's sort + * (pinned first, then updatedAt desc) makes the labels monotonic, so each + * label appears exactly once. + */ +export function groupNotes(notes: Note[], now: Date): NoteGroup[] { + const groups: NoteGroup[] = []; + for (const note of notes) { + const label = bucketLabel(note, now); + const last = groups[groups.length - 1]; + if (last && last.label === label) { + last.notes.push(note); + } else { + groups.push({ label, notes: [note] }); + } + } + return groups; +} From 25e9aa7804624bcda7da3ab0afe44fc8d0f4655c Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:54:33 -0700 Subject: [PATCH 20/41] Update README.md shorter messaging, simplified install, development notes, trimmed outdated platform & release details. --- README.md | 82 ++++++++++++++----------------------------------------- 1 file changed, 21 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 2f5f578..0458642 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ # InstantNotes -Instant notes for macOS, Windows, and Linux. Capture, organize, and search your thoughts. - -InstantNotes is a desktop notes app built around fast capture and a focused library. Save a thought from anywhere with a global shortcut, then organize and retrieve it without being forced into a folder system. +Instantly externalize your writing. Capture and locate your thoughts. ## Features -- **Instant capture**: a lightweight capture panel summoned from the system tray or via a global hotkey (`Option+Space` on macOS, `Ctrl+Shift+Space` on Windows and Linux), with drafts preserved if dismissed -- **Focused library**: a two-section sidebar (All Notes and Workspaces) over a note list and editor, with pinned notes floated to the top and a status filter for archived and trashed notes -- **Workspaces**: named collections that group related notes; a note can live in many workspaces, and deleting a workspace never deletes its notes -- **Full-text search**: SQLite FTS5 search over titles and bodies with ranked results, using plain-language queries with no search syntax to learn -- **Command palette**: a `Cmd+K` (`Ctrl+K`) palette for running actions and switching themes, with arrow-key navigation and recents; search reaches into sub-menus (typing a theme name jumps straight to it), and the Themes sub-menu applies each theme live so you can preview as you arrow through -- **Tags, not folders**: lightweight labels, including tags extracted from `#inline` text -- **Local and private**: all data stored locally in SQLite; note content never appears in logs or diagnostics +- **Quick Capture:** Global hotkey (`Opt` / `Ctrl+Shift+Space`) with drafts. +- **Command Palette:** `Cmd/Ctrl+P` for actions, search, and themes. +- **Flexible Organization:** Group notes with Workspaces and `#inline` tags instead of strict folders. +- **100% Local & Private:** Everything lives in a local SQLite database. Zero telemetry. ## Installation - -Download the latest build for your platform from the [releases page](../../releases). The builds are unsigned, so each OS asks for a one-time confirmation on first launch; the in-app updater applies later versions without any of it. +> The builds are unsigned, You will likely be prompted on first launch. +Download the latest build for your platform from the [releases page](../../releases). ### macOS (Apple Silicon) -Download the `.dmg`, open it, and drag InstantNotes to Applications. The app is not notarized, so macOS blocks the first launch with an "Apple could not verify" message. Clear the quarantine flag and it opens normally from then on: +Download the `.dmg`, open it, and drag InstantNotes to Applications. +The app is not notarized, macOS blocks the first launch with an "Apple could not verify" message. +Clear the quarantine flag and it opens normally from then on: ```sh xattr -d com.apple.quarantine /Applications/InstantNotes.app @@ -30,30 +27,19 @@ Alternatively, after the blocked first launch, open System Settings, go to Priva ### Windows (x64) -Download and run the `-setup.exe` installer. SmartScreen flags the unsigned build: click "More info", then "Run anyway". - -### Linux (x64) - -Download the `.AppImage`, make it executable, and run it: - -```sh -chmod +x InstantNotes_*.AppImage -./InstantNotes_*.AppImage -``` - -The app lives in the system tray; on desktops without tray support (such as stock GNOME, which needs the AppIndicator extension), use the in-window File menu to quit and the library window to work. - -To build from source instead, see [Development](#development). +Download release then run installer. +You will get a SmartScreen flags due to the unsigned build: + 1. To proceed --> click "More info", then "Run anyway". ## Development +To build from source instead ### Prerequisites -- macOS, Windows, or Linux +- macOS, Windows - [Rust](https://rustup.rs/) via rustup (the version is pinned by `rust-toolchain.toml`) - Node.js 22+ -- Linux only: the [Tauri system dependencies](https://v2.tauri.app/start/prerequisites/#linux) (webkit2gtk 4.1 and friends) -- Windows only: the Visual Studio Build Tools with the C++ workload +- Windows: the Visual Studio Build Tools with the C++ workload ### Run the app @@ -62,10 +48,6 @@ npm install npm run tauri:dev ``` -Use `npm run tauri:dev`, not `npm run tauri dev`: the app hides to the tray on close, so a plain re-run resurrects the old instance with a webview still pointing at a dead Vite HMR socket (edits never show); the wrapper kills any prior instance first so every run is genuinely fresh. - -This builds the Rust core, starts the Vite dev server, and launches the app. Frontend changes hot-reload instantly; Rust changes trigger an incremental rebuild and app restart. - ### Test ```sh @@ -84,7 +66,10 @@ Produces the platform's bundles under `src-tauri/target/release/bundle/`: an `.a ### Release (with self-update) -The app checks GitHub Releases for updates on launch and every 6 hours, via `latest.json` attached to the latest release. Each release's "What's new" text - shown in the in-app update panel and on the GitHub release - comes from the matching `CHANGELOG.md` section. Releases are built, signed, and published by CI: +The app checks GitHub Releases for updates on launch and every 6 hours, via `latest.json` attached to the latest release. + +Release's are shown on update panel +Releases are built, signed, and published by CI: ```sh # 1. Add a "## [X.Y.Z]" section to CHANGELOG.md describing the release. @@ -99,33 +84,8 @@ git tag vX.Y.Z && git push origin vX.Y.Z `npm run bump` updates package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and src-tauri/Cargo.lock together (the `instantnotes-core` crate versions independently). Publishing the smoke-tested draft is the deliberate ship gate; the draft stays invisible to the in-app updater until then. -CI signs the updater artifact with the minisign key stored in the repo secrets `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and the app verifies downloads against the matching public key in `tauri.conf.json`. If the secret is ever lost, generate a new keypair with `npm run tauri signer generate`, update both the secret and the pubkey, and ship one manual release so installs can cross over. - -For a fully local release without CI (macOS-only fallback: `make-update-manifest.sh` writes just the `darwin-aarch64` entry), build with `TAURI_SIGNING_PRIVATE_KEY` set, run `./scripts/make-update-manifest.sh`, and upload the dmg, `InstantNotes.app.tar.gz`, and `latest.json` with `gh release create`. Release downloads must be publicly reachable for the in-app check to work. - -### Project structure - -``` -src/ Svelte 5 frontend (library window, capture panel) -src-tauri/ Rust core and Tauri 2 shell -openspec/ Product specification and project conventions -static/ Static assets -``` - -### Tech stack - -| Layer | Technology | -|---|---| -| Desktop shell | Tauri 2 | -| Core (persistence, search, commands) | Rust | -| Storage and search | SQLite + FTS5 | -| UI | Svelte 5 + TypeScript | -| Editor | CodeMirror 6 | -| Testing | cargo test, Vitest, svelte-check | - -### Architecture +CI signs the updater artifact with the minisign key stored in the repo secrets `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and the app verifies downloads against the matching public key in `tauri.conf.json`. -A local Rust core sits behind a thin desktop shell. Rust owns business rules and persistence; TypeScript owns view state and typed IPC calls. The library window, capture panel, and future settings window communicate through typed commands and change events. UI code calls the API client rather than invoking Tauri commands directly. Product requirements and conventions live in [`openspec/`](openspec/): From fc005387931f792cb3bf7b6d122f9c20ca568bfa Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:56:32 -0700 Subject: [PATCH 21/41] feat: measure capture reveal-to-ready latency as a product number The shell stamps the moment it starts revealing the capture panel; the webview reports back after the textarea is focused and painted, and the delta lands in a rolling window (never note content). About shows the median as Capture readiness, and a perf smoke test guards the capture write path against order-of-magnitude regressions. --- src-tauri/core/tests/store_test.rs | 20 +++++ src-tauri/src/lib.rs | 113 ++++++++++++++++++++++++- src/lib/api/client.ts | 9 ++ src/lib/api/types.ts | 13 +-- src/lib/components/SettingsView.svelte | 29 ++++++- src/routes/capture/+page.svelte | 4 + 6 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index c986f5d..16e42e3 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -775,6 +775,26 @@ fn open_or_recover_sets_corrupt_file_aside_and_starts_fresh() { #[allow(dead_code)] fn _uses(_: AppError) {} +// ---- capture write-path perf smoke ---- + +#[test] +fn create_note_stays_fast_enough_for_capture() { + // An order-of-magnitude regression net for the capture write path, not a + // benchmark: the bound is generous so CI runners never flake, but an + // accidental full-table rescan or per-insert reindex would blow through it. + let mut s = store(); + for i in 0..200 { + create(&mut s, &format!("warmup note {i} #tag{}", i % 7)); + } + let start = std::time::Instant::now(); + create(&mut s, "capture perf probe #loop"); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_millis(250), + "single capture write took {elapsed:?}" + ); +} + // ---- revisit filter (never opened + created before) ---- #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 057fdf3..3ff14c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,51 @@ struct AppState { store: Mutex, } +// ---- capture latency metrics ---- +// "Capture is discharge" only holds if the panel is ready before the thought +// decays, so reveal-to-input-ready is tracked as a first-class number. The +// anchor is the moment the shell starts revealing the window: the earliest +// point we control (the OS delivers no timestamp for the hotkey press). +// Note content is never involved here. + +/// Rolling window; enough for a stable median, small enough to forget history. +const CAPTURE_SAMPLE_CAP: usize = 50; + +#[derive(Default)] +struct CaptureMetrics { + inner: Mutex, +} + +#[derive(Default)] +struct CaptureMetricsInner { + shown_at: Option, + samples_ms: Vec, +} + +#[derive(Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +struct CaptureLatencySummary { + last_ms: Option, + median_ms: Option, + samples: usize, +} + +fn push_capture_sample(samples: &mut Vec, ms: u64) { + samples.push(ms); + if samples.len() > CAPTURE_SAMPLE_CAP { + samples.remove(0); + } +} + +fn median_ms(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + Some(sorted[sorted.len() / 2]) +} + /// Serializable error per API.md §3.6 / §11. #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] @@ -277,6 +322,38 @@ fn workspaces_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult Ok(locked(&state)?.workspaces_for_note(¬e_id)?) } +// ---- capture latency commands ---- + +/// Called by the capture webview once its textarea has focus after a +/// reveal (post-paint). Consumes the pending stamp so a stray call can +/// never double-record; returns the measured reveal-to-ready milliseconds. +#[tauri::command] +fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult> { + let mut inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + let Some(shown) = inner.shown_at.take() else { + return Ok(None); + }; + let ms = shown.elapsed().as_millis() as u64; + push_capture_sample(&mut inner.samples_ms, ms); + Ok(Some(ms)) +} + +#[tauri::command] +fn get_capture_latency(metrics: State<'_, CaptureMetrics>) -> CmdResult { + let inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + Ok(CaptureLatencySummary { + last_ms: inner.samples_ms.last().copied(), + median_ms: median_ms(&inner.samples_ms), + samples: inner.samples_ms.len(), + }) +} + // ---- settings commands ---- #[tauri::command(async)] @@ -520,6 +597,12 @@ fn refresh_macos_icon(bundle: std::path::PathBuf) { fn show_capture_window(app: &AppHandle) { if let Some(w) = app.get_webview_window("capture") { + // Stamp before any window work so the sample covers the whole reveal. + if let Some(metrics) = app.try_state::() { + if let Ok(mut inner) = metrics.inner.lock() { + inner.shown_at = Some(std::time::Instant::now()); + } + } let _ = w.center(); let _ = w.show(); let _ = w.set_focus(); @@ -663,6 +746,7 @@ pub fn run() { app.manage(AppState { store: Mutex::new(store), }); + app.manage(CaptureMetrics::default()); if recovered { // Non-blocking on purpose: setup must finish (single-instance // handshake, window creation) whether or not the user has @@ -947,6 +1031,8 @@ pub fn run() { export_note_file, open_url, quit_app, + capture_input_ready, + get_capture_latency, get_shortcut_failure ]) .build(tauri::generate_context!()) @@ -975,7 +1061,32 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{export_theme_file, icon_refresh_needed, import_theme_file}; + use super::{ + export_theme_file, icon_refresh_needed, import_theme_file, median_ms, push_capture_sample, + CAPTURE_SAMPLE_CAP, + }; + + #[test] + fn capture_samples_roll_over_at_the_cap() { + let mut samples = Vec::new(); + for ms in 0..(CAPTURE_SAMPLE_CAP as u64 + 10) { + push_capture_sample(&mut samples, ms); + } + assert_eq!(samples.len(), CAPTURE_SAMPLE_CAP); + // Oldest entries were evicted; the newest survives. + assert_eq!(samples.first().copied(), Some(10)); + assert_eq!(samples.last().copied(), Some(CAPTURE_SAMPLE_CAP as u64 + 9)); + } + + #[test] + fn median_is_none_when_empty_and_stable_against_outliers() { + assert_eq!(median_ms(&[]), None); + assert_eq!(median_ms(&[40]), Some(40)); + // One slow cold start must not drag the reported number. + assert_eq!(median_ms(&[35, 38, 40, 42, 900]), Some(40)); + // Input order is irrelevant. + assert_eq!(median_ms(&[900, 40, 35, 42, 38]), Some(40)); + } #[test] fn icon_refresh_when_version_changed_or_unknown() { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index db91fbb..4f20025 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { + CaptureLatencySummary, CreateNoteInput, Note, NoteFilter, @@ -89,6 +90,14 @@ export const removeNoteFromWorkspace = (noteId: string, workspaceId: string) => export const workspacesForNote = (noteId: string) => call("workspaces_for_note", { noteId }); +// ---- capture latency ---- +// Reports that the capture textarea is focused and painted; the backend +// turns the pending reveal stamp into one latency sample. +export const captureInputReady = () => + call("capture_input_ready"); +export const getCaptureLatency = () => + call("get_capture_latency"); + // ---- settings ---- export const getSetting = (key: string) => call("get_setting", { key }); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index be30d90..7ef289d 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -2,11 +2,7 @@ // Field names are camelCase over the wire (serde). export type SyncState = - | "local_only" - | "pending_sync" - | "synced" - | "conflict" - | "sync_error"; + "local_only" | "pending_sync" | "synced" | "conflict" | "sync_error"; export interface Note { id: string; @@ -89,3 +85,10 @@ export interface AppErrorPayload { code: string; message: string; } + +/** Reveal-to-input-ready timing for the capture panel (no note content). */ +export interface CaptureLatencySummary { + lastMs: number | null; + medianMs: number | null; + samples: number; +} diff --git a/src/lib/components/SettingsView.svelte b/src/lib/components/SettingsView.svelte index 832b6f8..b9e94d9 100644 --- a/src/lib/components/SettingsView.svelte +++ b/src/lib/components/SettingsView.svelte @@ -3,12 +3,12 @@ // cards, each opening a focused sub-page with a breadcrumb back to the grid. // Escape steps back to the grid first, then closes the whole view. import { onMount } from "svelte"; - import { openUrl } from "$lib/api/client"; + import { getCaptureLatency, openUrl } from "$lib/api/client"; import { modKey } from "$lib/platform"; import { contexting } from "$lib/stores/contexting.svelte"; import { renderTemplate, TEMPLATE_VARS } from "$lib/contexting-format"; import { library } from "$lib/stores/library.svelte"; - import type { Note, Tag } from "$lib/api/types"; + import type { CaptureLatencySummary, Note, Tag } from "$lib/api/types"; let { appVersion, @@ -36,6 +36,17 @@ }; const SAMPLE_TAGS: Pick[] = [{ name: "example" }]; + // Reveal-to-ready timing for the capture panel; the number that keeps the + // "capture is discharge" promise honest. Re-fetched each time About opens. + let captureLatency = $state(null); + $effect(() => { + if (page === "about") { + getCaptureLatency() + .then((summary) => (captureLatency = summary)) + .catch(() => (captureLatency = null)); + } + }); + const preview = $derived.by(() => { const note = library.selected; const tags = note ? library.selectedTags : SAMPLE_TAGS; @@ -106,6 +117,20 @@ macOS · Apple Silicon
+
+ Capture readiness + + {#if captureLatency && captureLatency.medianMs !== null} + {captureLatency.medianMs} ms + {:else} + Measured on first capture + {/if} + +
+
Source +
+ {:else if page === "links"} + {/if} {/if} @@ -426,4 +535,127 @@ font-size: 12px; line-height: 1.5; } + + /* links */ + .links-pane { + max-width: 560px; + } + .links-pane h2 { + margin: 0 0 6px; + font-size: 18px; + font-weight: 600; + } + .link-sample { + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-sidebar); + font-size: 14px; + line-height: 1.6; + color: var(--text); + } + /* The sample link wears the same treatments the editor theme applies, so + what is shown here is what a note shows. */ + .sample-link { + color: var(--accent); + font-size: inherit; + padding: 0; + } + .sample-link.plain-click { + cursor: pointer; + } + .sample-link.ul-always { + text-decoration: underline; + } + .sample-link.ul-hover { + text-decoration: none; + } + .sample-link.ul-hover:hover { + text-decoration: underline; + } + .sample-link.ul-never { + text-decoration: none; + } + .sample-link.ext::after { + content: "↗"; + font-size: 0.7em; + vertical-align: super; + margin-left: 1px; + opacity: 0.75; + } + .sample-hint { + margin: 8px 0 20px; + color: var(--text-tertiary); + font-size: 12px; + } + .pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid var(--border); + } + .pref-label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + color: var(--text); + } + .pref-sub { + color: var(--text-tertiary); + font-size: 11.5px; + } + .seg { + display: flex; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + } + .seg-btn { + padding: 4px 12px; + font-size: 12px; + color: var(--text-secondary); + } + .seg-btn + .seg-btn { + border-left: 1px solid var(--border); + } + .seg-btn:hover { + background: var(--bg-hover); + } + .seg-btn[aria-checked="true"] { + background: var(--accent-soft); + color: var(--accent-text); + font-weight: 500; + } + .switch { + position: relative; + flex-shrink: 0; + width: 34px; + height: 20px; + border-radius: 10px; + background: var(--bg-hover); + border: 1px solid var(--border); + transition: background 0.15s ease; + } + .switch[aria-checked="true"] { + background: var(--accent); + border-color: var(--accent); + } + .switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 0.15s ease; + } + .switch[aria-checked="true"] .switch-thumb { + transform: translateX(14px); + } diff --git a/src/lib/editor/ARCHITECTURE.md b/src/lib/editor/ARCHITECTURE.md new file mode 100644 index 0000000..cf8c99b --- /dev/null +++ b/src/lib/editor/ARCHITECTURE.md @@ -0,0 +1,86 @@ +# The editor kernel + +`src/lib/editor/` is the platform layer between CodeMirror 6 and every +markdown feature InstantNotes has or will have. It exists because the previous +design (one self-contained extension per feature) let five separate tree walks +each invent their own answer to "what is hidden, where may the caret go, what +is clickable," and those answers drifted apart. Every editor bug shipped so +far was one of those disagreements. + +The kernel replaces the seams with one pipeline: + + lezer parse -> ConstructScanner (ONE walk) -> ConstructTable + |-- decorations (render) + |-- atomic ranges (caret legality) + |-- reveal state (what shows raw) + |-- hit info (what a click means) + +Features are `ConstructSpec` classes registered with the scanner. A spec +declares what a construct looks like; it never touches the view, events, or +other constructs. The kernel derives rendering, caret rules, and click routing +from the same table, so they cannot disagree by construction. + +## The experience contract + +These are invariants of the whole editing experience, not per-feature rules. +`kernel.test.ts` enforces the testable ones over a corpus of every construct. + +1. WRITING. A keystroke always lands visibly. No zero-width hidden range may + touch the caret: any construct the selection touches (boundary inclusive) + shows its raw syntax while touched. Enter continues lists, quotes, and + tasks (markdownKeymap). +2. DELETING. Markers behave like objects. Backspace at a marker boundary + removes the marker whole (keymap + atomic ranges); deletion can never eat + text the eye has not seen. +3. SELECTING. Anything a selection covers is fully visible while covered, so + copy and delete operate on exactly what is on screen. +4. CLICKING. The caret lands where the eye says. A click past the visible end + of a line goes to the true end of line, never invisibly inside markup. + Clickability, pointer cursor, tooltip, and open target all derive from the + construct table. +5. READING. Constructs reveal per smallest sensible unit (a quote line, not + the whole quote), widgets are stable across rebuilds (eq()), and the whole + system costs one tree walk per update, viewport scoped, at any note size. + +## Modules + +Each module is directly specified: purpose, construction, public surface. + +- `types.ts`: the construct model. `ConstructSpec` (tree driven), `TextSpec` + (text driven, e.g. tags), `Emit` (what a spec may declare: construct spans, + hides, marks, line chrome), `RevealMode` (`span` reveals on touch, `never` + is widget-stable). +- `scanner.ts`: `ConstructScanner` class. Constructor takes the spec + registry; `scan(state, ranges, preview)` performs the single syntax-tree + walk and returns a `ConstructTable`. `ConstructTable` encapsulates the scan + result; public queries: `decorations(sel)`, `atomicRanges(sel)`, + `constructAt(pos)`. Pure over EditorState, fully unit testable. +- `reveal.ts`: the one reveal predicate. `revealed(construct, selFrom, selTo)` + boundary inclusive. No other module may reimplement this comparison. +- `kernel.ts`: `previewModeField` and the `PreviewKernel` ViewPlugin that owns + the scan lifecycle (doc, viewport, selection, mode, prefs) and exposes the + table to the view layer. +- `caret.ts`: `CaretGuard`. Atomic ranges wiring plus pointer normalization + (contract 4). The only module allowed to touch selection placement. +- `constructs/`: one spec class per construct: inline marks, link, heading, + fence, table, list, quote, hr, task, image, tags. Parity with the retired + wysiwyg.ts, link-click.ts, task-list.ts, image-preview.ts behavior. +- `links.ts`, `images.ts`, `tasks.ts`, `blocks.ts`: behavior modules (open + routing, paste/drop capture, toggle, marker backspace) plus their pure, + tested helpers. State fields and effects keep their old names. +- `theme.ts`: all kernel base themes in one place. +- `index.ts`: `editorKernel(opts)`, the only entry point. Editor.svelte + consumes this and nothing else from the kernel. + +Outside the kernel, unchanged by design: parsing (`markdown-extensions.ts`), +paint (`markdown-highlight.ts`), commands (`markdown-format.ts`, +`list-indent.ts`), queries (`markdown-active.ts`). They are upstream or +downstream of the kernel, not seams inside it. + +## Adding a construct + +Write one `ConstructSpec` class in `constructs/`, register it in `index.ts`, +add corpus lines to `kernel.test.ts`. Do not add a ViewPlugin, a decoration +set, an event handler, or a `touches` comparison anywhere else. If a new +feature seems to need one of those, it is a kernel capability; extend the +kernel once, for everyone. diff --git a/src/lib/wysiwyg.test.ts b/src/lib/editor/blocks.test.ts similarity index 81% rename from src/lib/wysiwyg.test.ts rename to src/lib/editor/blocks.test.ts index 9554446..6cb7b40 100644 --- a/src/lib/wysiwyg.test.ts +++ b/src/lib/editor/blocks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { blockMarkerRange } from "./wysiwyg"; +import { blockMarkerRange } from "./blocks"; describe("blockMarkerRange", () => { it("returns null for plain text", () => { @@ -47,4 +47,13 @@ describe("blockMarkerRange", () => { // " - item": indent=2, marker="-", space=1 → marker occupies [2,4), text starts at 4 expect(blockMarkerRange(" - item", 0)).toEqual({ from: 2, to: 4 }); }); + + it("detects heading markers (# )", () => { + expect(blockMarkerRange("# Title", 0)).toEqual({ from: 0, to: 2 }); + expect(blockMarkerRange("### Deep", 10)).toEqual({ from: 10, to: 14 }); + }); + + it("does not treat a #tag (no space) as a heading marker", () => { + expect(blockMarkerRange("#tag stays", 0)).toBeNull(); + }); }); diff --git a/src/lib/editor/blocks.ts b/src/lib/editor/blocks.ts new file mode 100644 index 0000000..d09e1ca --- /dev/null +++ b/src/lib/editor/blocks.ts @@ -0,0 +1,47 @@ +// Block markers as objects under deletion. Backspace with the caret at the +// end of a leading block marker removes the whole marker, so a bullet or +// heading prefix dies in one keystroke instead of shedding characters. The +// kernel's atomic ranges give widget markers the same treatment during +// normal cursor motion and deletion; this keymap covers the revealed ones +// (heading #, quote >) and must be registered above defaultKeymap, whose +// deleteCharBackward would otherwise always win. + +import { keymap } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { previewModeField } from "./kernel"; + +/** + * Given a line's text and its document-start offset, returns the range + * occupied by the block marker (including trailing whitespace), or null if + * the line does not start with one. + */ +export function blockMarkerRange( + lineText: string, + lineFrom: number, +): { from: number; to: number } | null { + const m = lineText.match(/^(\s*)(>\s*|[-*+]\s+|\d+\.\s+|#{1,6}\s+)/); + if (!m) return null; + return { from: lineFrom + m[1].length, to: lineFrom + m[0].length }; +} + +export function markerBackspaceKeymap(): Extension { + return keymap.of([ + { + key: "Backspace", + run(view) { + if (!view.state.field(previewModeField)) return false; + const sel = view.state.selection.main; + if (!sel.empty) return false; + + const line = view.state.doc.lineAt(sel.from); + const range = blockMarkerRange(line.text, line.from); + if (!range || sel.from !== range.to) return false; + + view.dispatch({ + changes: { from: range.from, to: range.to, insert: "" }, + }); + return true; + }, + }, + ]); +} diff --git a/src/lib/editor/caret.ts b/src/lib/editor/caret.ts new file mode 100644 index 0000000..2fe312e --- /dev/null +++ b/src/lib/editor/caret.ts @@ -0,0 +1,79 @@ +// CaretGuard: the only module allowed to influence selection placement. +// +// CM6's atomic ranges (wired in kernel.ts) already keep cursor MOTION and +// deletion out of folded markup. Pointer placement is the remaining gap: +// with `](url)` folded away, the DOM's last caret position on the line sits +// at the end of the visible link text, so a click in the blank space to the +// right of the line resolves just before the `]` and typing would extend the +// link. The guard detects exactly that case (everything between the resolved +// position and the end of the line is folded, and the click landed past the +// last glyph) and places the caret at the true end of the line instead: +// where the eye says it clicked. +// +// Implemented with EditorView.mouseSelectionStyle, the sanctioned hook, so +// drag selections keep working: every drag event maps through the same +// correction. + +import { EditorView, type MouseSelectionStyle } from "@codemirror/view"; +import { EditorSelection, type Extension } from "@codemirror/state"; +import { previewModeField, type Kernel } from "./kernel"; + +export class CaretGuard { + constructor(private readonly kernel: Kernel["plugin"]) {} + + extension(): Extension { + return EditorView.mouseSelectionStyle.of((view, event) => + this.#style(view, event), + ); + } + + #style(view: EditorView, event: MouseEvent): MouseSelectionStyle | null { + // Multi-clicks are word/line selection; leave them native. + if (event.button !== 0 || event.detail > 1) return null; + if (!(view.state.field(previewModeField, false) ?? false)) return null; + const start = this.#corrected(view, event); + if (start === null) return null; + const guard = this; + return { + get(cur, extend, multiple) { + const head = guard.#corrected(view, cur) ?? guard.#natural(view, cur); + const anchor = extend ? view.state.selection.main.anchor : start; + const range = EditorSelection.range(anchor, head); + if (multiple) { + const ranges = view.state.selection.ranges; + return EditorSelection.create([...ranges, range], ranges.length); + } + return EditorSelection.create([range]); + }, + update() {}, + }; + } + + #natural(view: EditorView, e: MouseEvent): number { + return ( + view.posAtCoords({ x: e.clientX, y: e.clientY }) ?? + view.state.selection.main.head + ); + } + + /** + * The position this event should place the caret at, or null when the + * native mapping is already truthful. Foldedness is judged against the + * selection at click time, because that is what was on screen when the + * user aimed. + */ + #corrected(view: EditorView, e: MouseEvent): number | null { + const k = view.plugin(this.kernel); + if (!k) return null; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }); + if (pos === null) return null; + const line = view.state.doc.lineAt(pos); + if (pos === line.to) return null; + if (!k.table.allHiddenBetween(pos, line.to, view.state.selection.ranges)) { + return null; + } + const rect = view.coordsAtPos(pos, -1) ?? view.coordsAtPos(pos, 1); + if (!rect || e.clientX <= rect.right + 1) return null; + return line.to; + } +} diff --git a/src/lib/editor/constructs/fence.ts b/src/lib/editor/constructs/fence.ts new file mode 100644 index 0000000..05aa81d --- /dev/null +++ b/src/lib/editor/constructs/fence.ts @@ -0,0 +1,34 @@ +// Code fence construct: block chrome on every fence line, the backtick +// fence marks fold, and the language word stays as a small label. Touching +// the fence reveals the backticks; the chrome stays either way, so the block +// reads as a block even while being edited. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const codeblockLine = Decoration.line({ class: "cm-wysiwyg-codeblock" }); +const codeinfoMark = Decoration.mark({ class: "cm-wysiwyg-codeinfo" }); + +export class FenceSpec implements ConstructSpec { + readonly nodes = ["FencedCode"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const doc = cx.state.doc; + const first = doc.lineAt(node.from).number; + const last = doc.lineAt(node.to).number; + for (let ln = first; ln <= last; ln++) { + emit.line(doc.line(ln).from, codeblockLine); + } + const owner = emit.construct(node.from, node.to, "span"); + for (let c = node.node.firstChild; c; c = c.nextSibling) { + if (c.name === "CodeMark") { + emit.hide(owner, c.from, c.to); + } else if (c.name === "CodeInfo") { + emit.mark(c.from, c.to, codeinfoMark); + } + } + return false; + } +} diff --git a/src/lib/editor/constructs/heading.ts b/src/lib/editor/constructs/heading.ts new file mode 100644 index 0000000..de21131 --- /dev/null +++ b/src/lib/editor/constructs/heading.ts @@ -0,0 +1,24 @@ +// ATX heading construct: the `# ` prefix folds in preview (the heading text +// is sized by the highlight style); touching the heading line reveals it. +// Setext underlines are left alone: hiding the underline line would collapse +// it to nothing. +// +// Headings and tags coexist without ambiguity: CommonMark only parses +// `# Heading` (with a space) as a heading, and the tag construct only +// matches `#tag` (no space). + +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class HeadingSpec implements ConstructSpec { + readonly nodes = ["HeaderMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + const parent = node.node.parent; + if (!parent || !/^ATXHeading/.test(parent.name)) return false; + const owner = emit.construct(parent.from, parent.to, "span"); + const after = cx.state.doc.sliceString(node.to, node.to + 1); + emit.hide(owner, node.from, after === " " ? node.to + 1 : node.to); + return false; + } +} diff --git a/src/lib/editor/constructs/hr.ts b/src/lib/editor/constructs/hr.ts new file mode 100644 index 0000000..c268f18 --- /dev/null +++ b/src/lib/editor/constructs/hr.ts @@ -0,0 +1,35 @@ +// Horizontal rule construct: `---` renders as an actual rule. Caret contact +// reveals the raw dashes so the rule stays editable; the widget lets clicks +// through so CM places the caret, which is what reveals it. + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class HrWidget extends WidgetType { + eq(): boolean { + return true; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-hr"; + return s; + } + // Let CM place the caret on click, which reveals the raw `---`. + ignoreEvent(): boolean { + return false; + } +} + +const hrDeco = Decoration.replace({ widget: new HrWidget() }); + +export class HrSpec implements ConstructSpec { + readonly nodes = ["HorizontalRule"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const owner = emit.construct(node.from, node.to, "span"); + emit.hide(owner, node.from, node.to, hrDeco); + return false; + } +} diff --git a/src/lib/editor/constructs/image.ts b/src/lib/editor/constructs/image.ts new file mode 100644 index 0000000..88dad2e --- /dev/null +++ b/src/lib/editor/constructs/image.ts @@ -0,0 +1,66 @@ +// Image construct: `![alt](attachments/)` renders as the actual image +// through Tauri's asset protocol; caret contact brings the raw markdown back +// so it stays editable. Remote http(s) images stay as text: the CSP +// deliberately blocks remote loads, and the link layer opens them externally +// on click. The converter is injected so the spec stays testable without a +// Tauri runtime. + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { attachmentSrc, attachmentsBaseField } from "../images"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class ImageWidget extends WidgetType { + constructor( + readonly src: string, + readonly alt: string, + ) { + super(); + } + eq(o: ImageWidget): boolean { + return o.src === this.src && o.alt === this.alt; + } + toDOM(): HTMLElement { + const img = document.createElement("img"); + img.className = "cm-image-preview"; + img.src = this.src; + img.alt = this.alt; + img.draggable = false; + return img; + } + // Let CM handle clicks: the caret lands at the image's position, which + // reveals the raw markdown for editing. + ignoreEvent(): boolean { + return false; + } +} + +export class ImageSpec implements ConstructSpec { + readonly nodes = ["Image"]; + + constructor(private readonly convert: (path: string) => string) {} + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + // Always descend so the URL child keeps its link mark (visible in edit + // mode and whenever the image is revealed). + if (!cx.preview) return true; + const url = node.node.getChild("URL"); + if (!url) return true; + const src = attachmentSrc( + cx.state.doc.sliceString(url.from, url.to), + cx.state.field(attachmentsBaseField), + this.convert, + ); + if (!src) return true; + const alt = + cx.state.doc.sliceString(node.from, node.to).match(/^!\[([^\]]*)\]/)?.[1] ?? ""; + const owner = emit.construct(node.from, node.to, "span"); + emit.hide( + owner, + node.from, + node.to, + Decoration.replace({ widget: new ImageWidget(src, alt) }), + ); + return true; + } +} diff --git a/src/lib/editor/constructs/inline-marks.ts b/src/lib/editor/constructs/inline-marks.ts new file mode 100644 index 0000000..74ccdb3 --- /dev/null +++ b/src/lib/editor/constructs/inline-marks.ts @@ -0,0 +1,24 @@ +// Inline mark constructs: **bold**, *italic*, ~~strike~~, `code`, and +// ==highlight== markers fold away in preview; the styled text (painted by +// markdown-highlight.ts) stays. The construct span is the whole parent, so +// touching any part of **bold** reveals both markers together. + +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class InlineMarkSpec implements ConstructSpec { + readonly nodes = [ + "EmphasisMark", + "StrikethroughMark", + "CodeMark", + "HighlightMark", + ]; + + enter(node: SyntaxNodeRef, _cx: ScanContext, emit: Emit): boolean { + const parent = node.node.parent; + if (!parent) return false; + const owner = emit.construct(parent.from, parent.to, "span"); + emit.hide(owner, node.from, node.to); + return false; + } +} diff --git a/src/lib/editor/constructs/link.ts b/src/lib/editor/constructs/link.ts new file mode 100644 index 0000000..5bf8a5b --- /dev/null +++ b/src/lib/editor/constructs/link.ts @@ -0,0 +1,64 @@ +// Link constructs: [text](url), , and bare GFM URLs. +// +// Preview folds the [ and ](url) markup and keeps the text; touching the +// link reveals the whole thing. The appearance mark (underline, pointer, +// tooltip, external indicator) derives from linkPrefsField here, in both +// modes, so what looks clickable and what linkOpenHandler opens can never +// disagree. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { linkAt, linkMarkClass, linkPrefsField } from "../links"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class LinkSpec implements ConstructSpec { + readonly nodes = ["Link", "Autolink", "URL"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (node.name === "Link") { + const owner = emit.construct(node.from, node.to, "span"); + const raw = cx.state.doc.sliceString(node.from, node.to); + const split = raw.indexOf("]("); + if (split !== -1) { + emit.hide(owner, node.from, node.from + 1); + emit.hide(owner, node.from + split, node.to); + } + this.#mark(node.from, node.to, cx, emit); + // Descend: emphasis inside link text folds via its own construct. + return true; + } + + if (node.name === "Autolink") { + const owner = emit.construct(node.from, node.to, "span"); + for (let c = node.node.firstChild; c; c = c.nextSibling) { + if (c.name === "LinkMark") emit.hide(owner, c.from, c.to); + } + this.#mark(node.from, node.to, cx, emit); + return false; + } + + // URL: a bare GFM autolink, or the URL inside an Image (link-styled in + // edit mode; in preview the image widget covers it). URLs wrapped by + // Link/Autolink are already marked at the wrapper. + const parent = node.node.parent; + if (parent && (parent.name === "Link" || parent.name === "Autolink")) { + return false; + } + this.#mark(node.from, node.to, cx, emit); + return false; + } + + #mark(from: number, to: number, cx: ScanContext, emit: Emit): void { + const href = linkAt(cx.state, from); + if (!href) return; + const prefs = cx.state.field(linkPrefsField); + emit.mark( + from, + to, + Decoration.mark({ + class: linkMarkClass(prefs, cx.preview), + attributes: prefs.tooltip ? { title: href } : undefined, + }), + ); + } +} diff --git a/src/lib/editor/constructs/list.ts b/src/lib/editor/constructs/list.ts new file mode 100644 index 0000000..2c3657e --- /dev/null +++ b/src/lib/editor/constructs/list.ts @@ -0,0 +1,73 @@ +// List marker constructs: - / * / + render as a bullet, 1. as its number. +// These are "never" reveal: the marker stays an object. The kernel's atomic +// ranges make the caret step over it whole and backspace delete it whole +// (with markerBackspaceKeymap covering the revealed-marker cases). + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class BulletWidget extends WidgetType { + // All bullets are identical; report equality so the DOM node survives + // rebuilds instead of flickering. + eq(): boolean { + return true; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-bullet"; + s.textContent = "•"; + return s; + } + ignoreEvent(): boolean { + return true; + } +} + +class NumberWidget extends WidgetType { + constructor(readonly num: number) { + super(); + } + eq(o: NumberWidget): boolean { + return o.num === this.num; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-number"; + s.textContent = `${this.num}.`; + return s; + } + ignoreEvent(): boolean { + return true; + } +} + +const bulletDeco = Decoration.replace({ widget: new BulletWidget() }); + +const LIST_RE = /^(\s*)([-*+]|\d+\.)\s+/; + +export class ListSpec implements ConstructSpec { + readonly nodes = ["ListMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const line = cx.state.doc.lineAt(node.from); + const m = line.text.match(LIST_RE); + if (!m) return false; + // The marker range covers marker char(s) plus trailing space, after any + // indent. + const from = line.from + m[1].length; + const to = line.from + m[0].length; + const owner = emit.construct(from, to, "never"); + const ordered = /^\d+\./.test(m[2]); + emit.hide( + owner, + from, + to, + ordered + ? Decoration.replace({ widget: new NumberWidget(parseInt(m[2], 10)) }) + : bulletDeco, + ); + return false; + } +} diff --git a/src/lib/editor/constructs/quote.ts b/src/lib/editor/constructs/quote.ts new file mode 100644 index 0000000..0d8a549 --- /dev/null +++ b/src/lib/editor/constructs/quote.ts @@ -0,0 +1,27 @@ +// Blockquote construct: quoted lines get the accent border treatment and +// the `>` prefix folds per line. The construct span is the line, not the +// whole quote, so a long quote does not flicker open as the caret crosses +// one of its lines. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const quoteLine = Decoration.line({ class: "cm-wysiwyg-blockquote" }); + +const QUOTE_RE = /^(>\s*)/; + +export class QuoteSpec implements ConstructSpec { + readonly nodes = ["QuoteMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const line = cx.state.doc.lineAt(node.from); + const m = line.text.match(QUOTE_RE); + if (!m) return false; + emit.line(line.from, quoteLine); + const owner = emit.construct(line.from, line.to, "span"); + emit.hide(owner, line.from, line.from + m[0].length); + return false; + } +} diff --git a/src/lib/editor/constructs/table.ts b/src/lib/editor/constructs/table.ts new file mode 100644 index 0000000..ca82a8c --- /dev/null +++ b/src/lib/editor/constructs/table.ts @@ -0,0 +1,30 @@ +// Table construct: tables read as tables in preview. Monospace lines so +// columns align, bold header row, dimmed delimiter row. Structure only; the +// pipes stay, because a full grid rebuild would fight the caret. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const tableLine = Decoration.line({ class: "cm-wysiwyg-table" }); +const headMark = Decoration.mark({ class: "cm-wysiwyg-tablehead" }); +const delimMark = Decoration.mark({ class: "cm-wysiwyg-tabledelim" }); + +export class TableSpec implements ConstructSpec { + readonly nodes = ["Table", "TableHeader", "TableDelimiter"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + if (node.name === "Table") { + const doc = cx.state.doc; + const first = doc.lineAt(node.from).number; + const last = doc.lineAt(node.to).number; + for (let ln = first; ln <= last; ln++) { + emit.line(doc.line(ln).from, tableLine); + } + return true; + } + emit.mark(node.from, node.to, node.name === "TableHeader" ? headMark : delimMark); + return false; + } +} diff --git a/src/lib/editor/constructs/tags.ts b/src/lib/editor/constructs/tags.ts new file mode 100644 index 0000000..4bc5316 --- /dev/null +++ b/src/lib/editor/constructs/tags.ts @@ -0,0 +1,22 @@ +// Tag construct: #tag spans get the cm-tag treatment (styled in app.css) in +// both modes. Text-driven, not tree-driven: tags are an InstantNotes notion, +// not markdown. `#tag` (no space) never collides with `# Heading` (space +// required by CommonMark). + +import { Decoration } from "@codemirror/view"; +import type { Emit, ScanContext, TextSpec } from "../types"; + +const tagMark = Decoration.mark({ class: "cm-tag" }); + +const TAG_RE = /(^|\s)(#[\p{L}\p{N}_-]+)/gu; + +export class TagSpec implements TextSpec { + scan(text: string, offset: number, _cx: ScanContext, emit: Emit): void { + TAG_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TAG_RE.exec(text))) { + const start = offset + m.index + m[1].length; + emit.mark(start, start + m[2].length, tagMark); + } + } +} diff --git a/src/lib/editor/constructs/task.ts b/src/lib/editor/constructs/task.ts new file mode 100644 index 0000000..0b34c11 --- /dev/null +++ b/src/lib/editor/constructs/task.ts @@ -0,0 +1,69 @@ +// Task construct: GFM `[ ]` / `[x]` markers become native checkboxes in +// preview, and completed items get a struck, dimmed treatment in both modes +// so a burn-down list reads at a glance. The checkbox is a "never" reveal +// object: it stays a checkbox, the caret steps over it, and clicking it +// toggles the underlying marker character through taskToggleChange (an +// ordinary undoable edit). + +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { taskChecked, taskToggleChange } from "../tasks"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class CheckboxWidget extends WidgetType { + constructor(readonly checked: boolean) { + super(); + } + eq(o: CheckboxWidget): boolean { + return o.checked === this.checked; + } + toDOM(view: EditorView): HTMLElement { + const box = document.createElement("input"); + box.type = "checkbox"; + box.className = "cm-task-checkbox"; + box.checked = this.checked; + // posAtDOM at click time, not a stored offset: edits elsewhere in the + // note shift positions and a stale offset would toggle the wrong line. + box.addEventListener("mousedown", (e) => e.preventDefault()); + box.addEventListener("click", (e) => { + e.preventDefault(); + const pos = view.posAtDOM(box); + const line = view.state.doc.lineAt(pos); + const change = taskToggleChange(line.text, line.from); + if (change) view.dispatch({ changes: change }); + }); + return box; + } + ignoreEvent(): boolean { + // The widget owns its clicks; CM must not turn them into caret moves. + return true; + } +} + +const doneMark = Decoration.mark({ class: "cm-task-done" }); + +export class TaskSpec implements ConstructSpec { + readonly nodes = ["Task"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + const marker = node.node.getChild("TaskMarker"); + if (!marker) return false; + const checked = taskChecked(cx.state.doc.sliceString(marker.from, marker.to)); + if (cx.preview) { + // Swallow the trailing space too so the checkbox sits flush. + const after = cx.state.doc.sliceString(marker.to, marker.to + 1); + const to = after === " " ? marker.to + 1 : marker.to; + const owner = emit.construct(marker.from, to, "never"); + emit.hide( + owner, + marker.from, + to, + Decoration.replace({ widget: new CheckboxWidget(checked) }), + ); + } + if (checked && node.to > marker.to) { + emit.mark(marker.to, node.to, doneMark); + } + return false; + } +} diff --git a/src/lib/editor/images.test.ts b/src/lib/editor/images.test.ts new file mode 100644 index 0000000..250c92f --- /dev/null +++ b/src/lib/editor/images.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { attachmentSrc, extForMime, attachmentMarkdown } from "./images"; + +const convert = (p: string) => `asset://${p}`; + +describe("attachmentSrc", () => { + it("resolves a relative attachment through the converter", () => { + expect(attachmentSrc("attachments/a1.png", "/data/App", convert)).toBe( + "asset:///data/App/a1.png", + ); + }); + + it("returns null until the base directory is known", () => { + expect(attachmentSrc("attachments/a1.png", null, convert)).toBeNull(); + }); + + it("leaves remote URLs unresolved", () => { + expect(attachmentSrc("https://example.com/x.png", "/data/App", convert)).toBeNull(); + }); + + it("rejects traversal and nested paths", () => { + expect(attachmentSrc("attachments/../notes.db", "/data/App", convert)).toBeNull(); + expect(attachmentSrc("attachments/sub/x.png", "/data/App", convert)).toBeNull(); + expect(attachmentSrc("attachments/", "/data/App", convert)).toBeNull(); + }); +}); + +describe("extForMime", () => { + it("maps supported image types", () => { + expect(extForMime("image/png")).toBe("png"); + expect(extForMime("image/jpeg")).toBe("jpg"); + expect(extForMime("image/gif")).toBe("gif"); + expect(extForMime("image/webp")).toBe("webp"); + }); + + it("rejects everything else", () => { + expect(extForMime("image/svg+xml")).toBeNull(); + expect(extForMime("text/plain")).toBeNull(); + }); +}); + +describe("attachmentMarkdown", () => { + it("builds the relative markdown reference", () => { + expect(attachmentMarkdown("a1.png")).toBe("![](attachments/a1.png)"); + }); +}); diff --git a/src/lib/editor/images.ts b/src/lib/editor/images.ts new file mode 100644 index 0000000..86317d1 --- /dev/null +++ b/src/lib/editor/images.ts @@ -0,0 +1,137 @@ +// Attachment storage plumbing and image capture (paste/drop). +// +// Rendering lives in constructs/image.ts; this module owns the pure path +// resolution (unit-tested) and the capture flow: pasting or dropping an +// image file saves it as an attachment (bytes go to Rust, which owns the +// attachments directory) and inserts the relative markdown reference at the +// caret/drop point. + +import { EditorView } from "@codemirror/view"; +import { StateEffect, StateField, type Extension } from "@codemirror/state"; + +// --------------------------------------------------------------------------- +// Attachments base directory +// +// The absolute directory arrives async from Rust after the view exists, so it +// lives in a state field seeded by an effect. Until it lands, attachment +// images simply stay as markdown text. +// --------------------------------------------------------------------------- + +export const setAttachmentsBase = StateEffect.define(); + +export const attachmentsBaseField = StateField.define({ + create: () => null, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setAttachmentsBase)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested without a view) +// --------------------------------------------------------------------------- + +const ATTACHMENT_PREFIX = "attachments/"; + +/** + * Resolve a markdown image URL to something the webview can load, or null + * when it can't be rendered. Only relative attachment paths resolve; the + * `convert` parameter is Tauri's convertFileSrc, injected so this stays pure. + */ +export function attachmentSrc( + url: string, + base: string | null, + convert: (path: string) => string, +): string | null { + const u = url.trim(); + if (!u.startsWith(ATTACHMENT_PREFIX)) return null; + const name = u.slice(ATTACHMENT_PREFIX.length); + // A traversal like attachments/../notes.db must never reach the resolver. + if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) { + return null; + } + return base ? convert(`${base}/${name}`) : null; +} + +/** File extension for a pasteable image MIME type, or null to skip the file. */ +export function extForMime(mime: string): string | null { + switch (mime) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/gif": + return "gif"; + case "image/webp": + return "webp"; + default: + return null; + } +} + +/** The markdown inserted for a stored attachment. */ +export function attachmentMarkdown(name: string): string { + return `![](${ATTACHMENT_PREFIX}${name})`; +} + +function imageFiles(data: DataTransfer | null): File[] { + if (!data) return []; + return [...data.files].filter((f) => extForMime(f.type) !== null); +} + +// --------------------------------------------------------------------------- +// Paste / drop capture +// --------------------------------------------------------------------------- + +export interface ImageCaptureOpts { + /** Persist one image; resolves to the stored filename. */ + save: (bytes: Uint8Array, ext: string) => Promise; + onError?: (message: string) => void; +} + +export function imageCapture(opts: ImageCaptureOpts): Extension { + async function insertFiles(view: EditorView, files: File[], at: number) { + let pos = at; + for (const file of files) { + const ext = extForMime(file.type); + if (!ext) continue; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const name = await opts.save(bytes, ext); + const insert = attachmentMarkdown(name); + // The document may have changed while the save was in flight; clamp + // rather than dispatch out of range. + const clamped = Math.min(pos, view.state.doc.length); + view.dispatch({ + changes: { from: clamped, insert }, + selection: { anchor: clamped + insert.length }, + }); + pos = clamped + insert.length; + } catch (e) { + opts.onError?.(e instanceof Error ? e.message : String(e)); + } + } + } + + return EditorView.domEventHandlers({ + paste(e, view) { + const files = imageFiles(e.clipboardData); + if (files.length === 0) return false; + e.preventDefault(); + void insertFiles(view, files, view.state.selection.main.from); + return true; + }, + drop(e, view) { + const files = imageFiles(e.dataTransfer); + if (files.length === 0) return false; + e.preventDefault(); + const pos = + view.posAtCoords({ x: e.clientX, y: e.clientY }) ?? + view.state.selection.main.from; + void insertFiles(view, files, pos); + return true; + }, + }); +} diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts new file mode 100644 index 0000000..07b0684 --- /dev/null +++ b/src/lib/editor/index.ts @@ -0,0 +1,105 @@ +// The editor kernel's single entry point. Editor.svelte consumes this and +// nothing else from the kernel; everything the rest of the app needs +// (effects, fields, pure helpers, types) is re-exported here. +// +// See ARCHITECTURE.md in this directory for the experience contract and the +// module map. + +import { keymap } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { markdownKeymap } from "@codemirror/lang-markdown"; +import { convertFileSrc } from "@tauri-apps/api/core"; + +import { previewKernel } from "./kernel"; +import { CaretGuard } from "./caret"; +import { markerBackspaceKeymap } from "./blocks"; +import { linkOpenHandler, linkPrefsField } from "./links"; +import { attachmentsBaseField, imageCapture } from "./images"; +import { editModeTaskToggle } from "./tasks"; +import { kernelTheme } from "./theme"; + +import { InlineMarkSpec } from "./constructs/inline-marks"; +import { LinkSpec } from "./constructs/link"; +import { HeadingSpec } from "./constructs/heading"; +import { FenceSpec } from "./constructs/fence"; +import { TableSpec } from "./constructs/table"; +import { ListSpec } from "./constructs/list"; +import { QuoteSpec } from "./constructs/quote"; +import { HrSpec } from "./constructs/hr"; +import { TaskSpec } from "./constructs/task"; +import { ImageSpec } from "./constructs/image"; +import { TagSpec } from "./constructs/tags"; + +export interface EditorKernelOpts { + /** Open a normalized, scheme-checked URL externally (Rust open_url). */ + openUrl: (url: string) => void; + /** Persist one pasted/dropped image; resolves to the stored filename. */ + saveImage: (bytes: Uint8Array, ext: string) => Promise; + onImageError?: (message: string) => void; + /** Attachment path resolver; defaults to Tauri's convertFileSrc. */ + convertSrc?: (path: string) => string; +} + +/** + * The full editor platform bundle. Register it ABOVE defaultKeymap: the + * kernel owns Backspace (marker-as-object deletion) and Enter (list/quote + * continuation via markdownKeymap), which defaultKeymap would otherwise + * shadow. + */ +export function editorKernel(opts: EditorKernelOpts): Extension { + const { extension, plugin } = previewKernel({ + specs: [ + new InlineMarkSpec(), + new LinkSpec(), + new HeadingSpec(), + new FenceSpec(), + new TableSpec(), + new ListSpec(), + new QuoteSpec(), + new HrSpec(), + new TaskSpec(), + new ImageSpec(opts.convertSrc ?? convertFileSrc), + ], + textSpecs: [new TagSpec()], + rescanOn: [linkPrefsField, attachmentsBaseField], + }); + + return [ + linkPrefsField, + attachmentsBaseField, + // Keymap order within the bundle is precedence order: marker backspace + // first, then markdown's markup-aware Backspace/Enter continuation. + markerBackspaceKeymap(), + keymap.of(markdownKeymap), + extension, + new CaretGuard(plugin).extension(), + linkOpenHandler(opts.openUrl), + editModeTaskToggle(), + imageCapture({ save: opts.saveImage, onError: opts.onImageError }), + kernelTheme, + ]; +} + +// --------------------------------------------------------------------------- +// Public surface for the rest of the app +// --------------------------------------------------------------------------- + +export { previewModeField, setPreviewMode } from "./kernel"; +export { + setLinkPrefs, + linkPrefsField, + DEFAULT_LINK_PREFS, + normalizeHref, + linkAt, + linkMarkClass, +} from "./links"; +export type { LinkOpenWith, LinkUnderline, LinkPrefsSnapshot } from "./links"; +export { + setAttachmentsBase, + attachmentsBaseField, + attachmentSrc, + extForMime, + attachmentMarkdown, +} from "./images"; +export { taskToggleChange, taskChecked } from "./tasks"; +export { blockMarkerRange } from "./blocks"; diff --git a/src/lib/editor/kernel.test.ts b/src/lib/editor/kernel.test.ts new file mode 100644 index 0000000..134733b --- /dev/null +++ b/src/lib/editor/kernel.test.ts @@ -0,0 +1,295 @@ +// The kernel's experience contract, enforced as properties over a corpus of +// every construct (see ARCHITECTURE.md). These are not per-feature tests: +// they assert the invariants that make "typing into invisible markup" +// structurally impossible, for every construct at every caret position. + +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { highlightExtension } from "../markdown-extensions"; +import { ConstructScanner, type ConstructTable } from "./scanner"; +import { linkPrefsField } from "./links"; +import { attachmentsBaseField, setAttachmentsBase } from "./images"; +import { InlineMarkSpec } from "./constructs/inline-marks"; +import { LinkSpec } from "./constructs/link"; +import { HeadingSpec } from "./constructs/heading"; +import { FenceSpec } from "./constructs/fence"; +import { TableSpec } from "./constructs/table"; +import { ListSpec } from "./constructs/list"; +import { QuoteSpec } from "./constructs/quote"; +import { HrSpec } from "./constructs/hr"; +import { TaskSpec } from "./constructs/task"; +import { ImageSpec } from "./constructs/image"; +import { TagSpec } from "./constructs/tags"; + +// Mirrors the registry in index.ts, with a fake asset converter so no Tauri +// runtime is needed. +function makeScanner(): ConstructScanner { + return new ConstructScanner( + [ + new InlineMarkSpec(), + new LinkSpec(), + new HeadingSpec(), + new FenceSpec(), + new TableSpec(), + new ListSpec(), + new QuoteSpec(), + new HrSpec(), + new TaskSpec(), + new ImageSpec((p) => `asset://${p}`), + ], + [new TagSpec()], + ); +} + +function stateOf(doc: string): EditorState { + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: [highlightExtension] }), + linkPrefsField, + attachmentsBaseField, + ], + }); + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +function tableOf(state: EditorState, preview = true): ConstructTable { + return makeScanner().scan( + state, + [{ from: 0, to: state.doc.length }], + preview, + ); +} + +// One line (or block) per construct the kernel supports, blank-line +// separated so blocks terminate the way they do in real notes. +const CORPUS = [ + "plain text with #tag inline", + "", + "# Heading one", + "", + "## Second heading", + "", + "**bold** and *italic* and ~~gone~~ and `code`", + "", + "==marked== text", + "", + "a [link](https://example.com) mid line", + "", + "trailing [LINK_CLICK_ME](https://example.com/x)", + "", + " autolink", + "", + "bare https://example.com url", + "", + "- bullet item", + "", + "12. ordered item", + "", + "- [ ] open task", + "", + "- [x] done task", + "", + "> quoted line", + "", + "---", + "", + "```js", + "const x = 1;", + "```", + "", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "![alt](attachments/pic.png)", +].join("\n"); + +describe("kernel invariants", () => { + it("WRITING: no invisible fold ever touches the caret, at any position", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + for (let pos = 0; pos <= CORPUS.length; pos++) { + const folded = table.foldedHides([{ from: pos, to: pos }]); + for (const h of folded) { + if (h.widget) continue; // widgets are visible objects, guarded by atomic ranges + expect( + h.to < pos || h.from > pos, + `invisible hidden range [${h.from},${h.to}] "${CORPUS.slice(h.from, h.to)}" touches caret at ${pos}`, + ).toBe(true); + } + } + }); + + it("SELECTING: a selection reveals every non-widget construct it overlaps", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + const folded = table.foldedHides([{ from: 0, to: CORPUS.length }]); + expect(folded.every((h) => h.widget)).toBe(true); + }); + + it("the original complaint: caret at the end of a trailing link's text reveals the whole link", () => { + const doc = "trailing [LINK_CLICK_ME](https://example.com/x)"; + const table = tableOf(stateOf(doc)); + const pos = doc.indexOf("LINK_CLICK_ME") + "LINK_CLICK_ME".length; + expect(doc[pos]).toBe("]"); + const invisible = table + .foldedHides([{ from: pos, to: pos }]) + .filter((h) => !h.widget); + expect(invisible).toEqual([]); + }); + + it("CLICKING: past the visible end of a link line, everything to the line end is folded", () => { + const doc = "trailing [LINK_CLICK_ME](https://example.com/x)"; + const table = tableOf(stateOf(doc)); + const textEnd = doc.indexOf("LINK_CLICK_ME") + "LINK_CLICK_ME".length; + const away = [{ from: 0, to: 0 }]; // caret elsewhere, link folded + // From the last visible glyph to the line end is pure hidden markup, so + // CaretGuard snaps such a click to the line end. + expect(table.allHiddenBetween(textEnd, doc.length, away)).toBe(true); + // One glyph earlier is visible text: no snapping. + expect(table.allHiddenBetween(textEnd - 1, doc.length, away)).toBe(false); + }); + + it("caret legality: atomic ranges are exactly the folded hides", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + const sel = [{ from: 0, to: 0 }]; + const folded = table.foldedHides(sel); + const atomic = table.atomicRanges(sel); + let count = 0; + const cursor = atomic.iter(); + while (cursor.value !== null) { + count++; + cursor.next(); + } + expect(count).toBe(folded.length); + }); + + it("edit mode folds nothing", () => { + const state = stateOf(CORPUS); + const table = tableOf(state, false); + expect(table.foldedHides([{ from: 0, to: 0 }])).toEqual([]); + }); +}); + +describe("construct parity", () => { + function hiddenTexts(doc: string, sel = [{ from: 0, to: 0 }]): string[] { + const table = tableOf(stateOf(doc)); + return table.foldedHides(sel).map((h) => doc.slice(h.from, h.to)); + } + + it("folds link markup, keeps the text", () => { + expect(hiddenTexts("a [docs](https://x.dev) b")).toEqual([ + "[", + "](https://x.dev)", + ]); + }); + + it("folds autolink angle brackets", () => { + expect(hiddenTexts("go now")).toEqual(["<", ">"]); + }); + + it("folds inline emphasis marks, including inside link text", () => { + // Prefixed so the caret at 0 sits before the constructs (as the link and + // autolink cases above do); a caret touching a construct reveals it. + expect(hiddenTexts("a **b** [*i*](https://x.dev)")).toEqual([ + "**", + "**", + "[", + "*", + "*", + "](https://x.dev)", + ]); + }); + + it("folds the heading prefix", () => { + // Caret in the body, off the heading line: on the line, the prefix reveals. + expect(hiddenTexts("# Title\n\nbody", [{ from: 9, to: 9 }])).toEqual(["# "]); + }); + + it("folds highlight marks", () => { + expect(hiddenTexts("a ==hot== take")).toEqual(["==", "=="]); + }); + + it("folds the quote prefix per line: the caret's line opens, others stay", () => { + const doc = "> first\n> second"; + const table = tableOf(stateOf(doc)); + // Caret on the first quote line: its > opens, the second stays folded. + const folded = table.foldedHides([{ from: 2, to: 2 }]); + expect(folded.map((h) => h.from)).toEqual([doc.indexOf("> second")]); + }); + + it("replaces list markers with widgets", () => { + const doc = "- bullet\n\n12. numbered"; + const table = tableOf(stateOf(doc)); + const folded = table.foldedHides([{ from: doc.length, to: doc.length }]); + const listFolds = folded.filter((h) => h.widget); + expect(listFolds.map((h) => doc.slice(h.from, h.to))).toEqual(["- ", "12. "]); + }); + + it("keeps a checked task struck in both modes and its marker a widget in preview", () => { + const doc = "- [x] shipped"; + const state = stateOf(doc); + for (const preview of [true, false]) { + const table = tableOf(state, preview); + let struck = false; + table + .decorations([{ from: 0, to: 0 }]) + .between(0, doc.length, (_f, _t, deco) => { + if (deco.spec.class === "cm-task-done") struck = true; + }); + expect(struck, `cm-task-done missing (preview=${preview})`).toBe(true); + } + const folded = tableOf(state).foldedHides([{ from: doc.length, to: doc.length }]); + expect(folded.some((h) => h.widget && doc.slice(h.from, h.to) === "[x] ")).toBe(true); + }); + + it("renders attachment images as widgets once the base directory lands", () => { + // Lead-in text keeps the caret-at-0 selection off the image so it folds. + const doc = "pic:\n\n![alt](attachments/pic.png)"; + const caret = [{ from: 0, to: 0 }]; + const isImage = (h: { widget: boolean; from: number; to: number }) => + h.widget && doc.slice(h.from, h.to).startsWith("!["); + + // Before the async base directory arrives: markdown text, no widget. + expect(tableOf(stateOf(doc)).foldedHides(caret).some(isImage)).toBe(false); + + const withBase = stateOf(doc).update({ + effects: setAttachmentsBase.of("/data/App"), + }).state; + ensureSyntaxTree(withBase, doc.length, 5000); + expect(tableOf(withBase).foldedHides(caret).some(isImage)).toBe(true); + }); + + it("marks tags in both modes", () => { + const doc = "note about #spaces here"; + for (const preview of [true, false]) { + const table = tableOf(stateOf(doc), preview); + let tagged: string | null = null; + table.decorations([{ from: 0, to: 0 }]).between(0, doc.length, (f, t, deco) => { + if (deco.spec.class === "cm-tag") tagged = doc.slice(f, t); + }); + expect(tagged).toBe("#spaces"); + } + }); + + it("gives fence lines chrome and folds the fence marks", () => { + const doc = "```js\nconst x = 1;\n```"; + const state = stateOf(doc); + const table = tableOf(state); + const folded = table.foldedHides([{ from: 0, to: 0 }]); + // Caret inside the fence reveals the backticks (whole-fence construct). + expect(folded).toEqual([]); + const away = tableOf( + stateOf(`intro\n\n${doc}`), + ).foldedHides([{ from: 0, to: 0 }]); + expect(away.map((h) => `intro\n\n${doc}`.slice(h.from, h.to))).toEqual([ + "```", + "```", + ]); + }); +}); diff --git a/src/lib/editor/kernel.ts b/src/lib/editor/kernel.ts new file mode 100644 index 0000000..001c328 --- /dev/null +++ b/src/lib/editor/kernel.ts @@ -0,0 +1,116 @@ +// The preview kernel: owns the scan lifecycle and feeds the view. +// +// One PreviewKernel instance per editor. It rescans (one tree walk) only +// when the document, viewport, mode, or a watched preference field changes; +// a bare selection change just re-derives fold state from the cached table. +// Typing and arrow keys, the hottest paths, never re-walk the tree. + +import { + EditorView, + ViewPlugin, + Decoration, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import { + StateEffect, + StateField, + RangeSet, + type EditorState, + type Extension, +} from "@codemirror/state"; +import { ConstructScanner, type ConstructTable } from "./scanner"; +import type { ConstructSpec, TextSpec } from "./types"; + +// --------------------------------------------------------------------------- +// Mode state +// --------------------------------------------------------------------------- + +export const setPreviewMode = StateEffect.define(); + +export const previewModeField = StateField.define({ + create: () => false, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setPreviewMode)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// Kernel plugin +// --------------------------------------------------------------------------- + +export interface KernelConfig { + specs: readonly ConstructSpec[]; + textSpecs?: readonly TextSpec[]; + /** Fields whose value change forces a rescan (preference snapshots). */ + // deno-lint-ignore no-explicit-any StateField is invariant in its value type. + rescanOn?: readonly StateField[]; +} + +export class PreviewKernel { + table: ConstructTable; + decorations: DecorationSet = Decoration.none; + atomic: RangeSet = RangeSet.empty; + + constructor( + view: EditorView, + private readonly scanner: ConstructScanner, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly rescanOn: readonly StateField[], + ) { + this.table = this.#scan(view); + this.#derive(view.state); + } + + update(u: ViewUpdate): void { + const flip = + u.startState.field(previewModeField) !== u.state.field(previewModeField); + const prefsChanged = this.rescanOn.some( + (f) => u.startState.field(f, false) !== u.state.field(f, false), + ); + if (u.docChanged || u.viewportChanged || flip || prefsChanged) { + this.table = this.#scan(u.view); + this.#derive(u.state); + } else if (u.selectionSet) { + this.#derive(u.state); + } + } + + #scan(view: EditorView): ConstructTable { + return this.scanner.scan( + view.state, + view.visibleRanges, + view.state.field(previewModeField), + ); + } + + #derive(state: EditorState): void { + const sel = state.selection.ranges; + this.decorations = this.table.decorations(sel); + this.atomic = this.table.atomicRanges(sel); + } +} + +/** What previewKernel returns: the extension plus a handle for CaretGuard. */ +export interface Kernel { + extension: Extension; + plugin: ViewPlugin; +} + +export function previewKernel(config: KernelConfig): Kernel { + const scanner = new ConstructScanner(config.specs, config.textSpecs ?? []); + const plugin = ViewPlugin.define( + (view) => new PreviewKernel(view, scanner, config.rescanOn ?? []), + { + decorations: (v) => v.decorations, + // The documented idiom: expose the plugin's ranges to atomicRanges via + // view.plugin(), guarding against the plugin having been dropped. + provide: (p) => + EditorView.atomicRanges.of((view) => view.plugin(p)?.atomic ?? RangeSet.empty), + }, + ); + return { extension: [previewModeField, plugin], plugin }; +} diff --git a/src/lib/editor/links.test.ts b/src/lib/editor/links.test.ts new file mode 100644 index 0000000..5155ce5 --- /dev/null +++ b/src/lib/editor/links.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { linkAt, normalizeHref, linkMarkClass, DEFAULT_LINK_PREFS } from "./links"; + +// Same language setup as the editor (GFM base) so autolinks and bare URLs +// parse the way they do in the app. +function stateOf(doc: string): EditorState { + const state = EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage })], + }); + // linkAt reads the current tree; force a full parse up front since there + // is no view driving incremental parsing in tests. + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +describe("normalizeHref", () => { + it("passes http and https through", () => { + expect(normalizeHref("https://example.com")).toBe("https://example.com"); + expect(normalizeHref("http://example.com")).toBe("http://example.com"); + }); + + it("passes mailto through", () => { + expect(normalizeHref("mailto:a@b.com")).toBe("mailto:a@b.com"); + }); + + it("prepends https to bare www URLs", () => { + expect(normalizeHref("www.example.com")).toBe("https://www.example.com"); + }); + + it("rejects unsafe or unknown schemes", () => { + expect(normalizeHref("file:///etc/passwd")).toBeNull(); + expect(normalizeHref("javascript:alert(1)")).toBeNull(); + expect(normalizeHref("not a url")).toBeNull(); + }); +}); + +describe("linkMarkClass", () => { + it("defaults: underlined, clickable in preview, no indicator", () => { + expect(linkMarkClass(DEFAULT_LINK_PREFS, true)).toBe( + "cm-link-target cm-link-ul-always cm-link-clickable", + ); + }); + + it("is never clickable outside preview mode", () => { + expect(linkMarkClass(DEFAULT_LINK_PREFS, false)).toBe( + "cm-link-target cm-link-ul-always", + ); + }); + + it("drops the pointer when opening needs the modifier", () => { + expect( + linkMarkClass({ ...DEFAULT_LINK_PREFS, openWith: "modclick" }, true), + ).not.toContain("cm-link-clickable"); + }); + + it("carries underline and indicator choices", () => { + expect( + linkMarkClass( + { ...DEFAULT_LINK_PREFS, underline: "hover", externalIndicator: true }, + false, + ), + ).toBe("cm-link-target cm-link-ul-hover cm-link-ext"); + }); +}); + +describe("linkAt", () => { + it("finds the URL from inside [text](url) link text", () => { + const doc = "see [docs](https://example.com/docs) here"; + expect(linkAt(stateOf(doc), 6)).toBe("https://example.com/docs"); + }); + + it("finds the URL from inside the url part of a link", () => { + const doc = "see [docs](https://example.com/docs) here"; + expect(linkAt(stateOf(doc), 15)).toBe("https://example.com/docs"); + }); + + it("finds an autolink in angle brackets", () => { + const doc = "go now"; + expect(linkAt(stateOf(doc), 8)).toBe("https://example.com"); + }); + + it("finds a bare GFM autolink URL", () => { + const doc = "visit https://example.com today"; + expect(linkAt(stateOf(doc), 10)).toBe("https://example.com"); + }); + + it("normalizes a bare www autolink", () => { + const doc = "visit www.example.com today"; + expect(linkAt(stateOf(doc), 10)).toBe("https://www.example.com"); + }); + + it("returns null in plain text", () => { + expect(linkAt(stateOf("plain words only"), 3)).toBeNull(); + }); + + it("returns null just past the end of a link (trailing-space click)", () => { + const doc = "[docs](https://example.com)"; + expect(linkAt(stateOf(doc), doc.length)).toBeNull(); + }); + + it("returns null for links with unsafe schemes", () => { + const doc = "[bad](javascript:alert(1))"; + expect(linkAt(stateOf(doc), 2)).toBeNull(); + }); +}); diff --git a/src/lib/editor/links.ts b/src/lib/editor/links.ts new file mode 100644 index 0000000..ab4ddff --- /dev/null +++ b/src/lib/editor/links.ts @@ -0,0 +1,143 @@ +// Link preferences, URL resolution, and click-to-open routing. +// +// Interaction contract (user-tunable via the Links settings, carried in +// linkPrefsField): in preview mode (Aa toolbar closed) a plain click opens +// the link unless the user opted for Cmd/Ctrl+click everywhere. With the +// toolbar open the document is raw markdown being edited, so opening always +// requires Cmd/Ctrl+click and a plain click just places the caret. +// +// Appearance marks are emitted by constructs/link.ts from the same prefs, so +// the pointer cursor, underline, tooltip, and open target cannot disagree. + +import { syntaxTree } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { + StateEffect, + StateField, + type EditorState, + type Extension, +} from "@codemirror/state"; +import { previewModeField } from "./kernel"; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/** How a link opens in preview mode ("click") or only ever with Cmd/Ctrl. */ +export type LinkOpenWith = "click" | "modclick"; +export type LinkUnderline = "always" | "hover" | "never"; + +export interface LinkPrefsSnapshot { + openWith: LinkOpenWith; + underline: LinkUnderline; + /** Show the destination as a hover tooltip (the URL is hidden in preview). */ + tooltip: boolean; + /** Trailing arrow indicator so outbound links read as "leaves the app". */ + externalIndicator: boolean; +} + +export const DEFAULT_LINK_PREFS: LinkPrefsSnapshot = { + openWith: "click", + underline: "always", + tooltip: true, + externalIndicator: false, +}; + +export const setLinkPrefs = StateEffect.define(); + +export const linkPrefsField = StateField.define({ + create: () => DEFAULT_LINK_PREFS, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setLinkPrefs)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// URL resolution (pure, unit-tested) +// --------------------------------------------------------------------------- + +/** + * Restrict opening to schemes that are safe to hand to the OS opener. Bare + * `www.` URLs (GFM autolinks carry no scheme) get https prepended so the + * opener does not reject them. + */ +export function normalizeHref(raw: string): string | null { + const url = raw.trim(); + if (/^https?:\/\//i.test(url) || /^mailto:/i.test(url)) return url; + if (/^www\./i.test(url)) return `https://${url}`; + return null; +} + +/** + * The href of the link enclosing `pos`, or null when the position is not on + * a link. The right boundary is exclusive so a click in the whitespace after + * a link (which resolves to the link's end position) does not open it. + */ +export function linkAt(state: EditorState, pos: number): string | null { + const tree = syntaxTree(state); + for (const side of [1, -1] as const) { + let node: ReturnType | null = tree.resolveInner(pos, side); + while (node) { + if (node.name === "Link" || node.name === "Image" || node.name === "Autolink") { + if (pos < node.from || pos >= node.to) return null; + const url = node.getChild("URL"); + return url ? normalizeHref(state.doc.sliceString(url.from, url.to)) : null; + } + if (node.name === "URL") { + if (pos < node.from || pos >= node.to) return null; + return normalizeHref(state.doc.sliceString(node.from, node.to)); + } + node = node.parent; + } + } + return null; +} + +/** CSS classes for a link mark under the given prefs/mode. Pure for tests. */ +export function linkMarkClass(prefs: LinkPrefsSnapshot, preview: boolean): string { + let cls = `cm-link-target cm-link-ul-${prefs.underline}`; + if (preview && prefs.openWith === "click") cls += " cm-link-clickable"; + if (prefs.externalIndicator) cls += " cm-link-ext"; + return cls; +} + +// --------------------------------------------------------------------------- +// Click handling +// --------------------------------------------------------------------------- + +/** + * The open-on-click handler. `open` receives a normalized, scheme-checked + * URL. The click must land on rendered link text: the mark decoration spans + * exactly that, so anchoring on it keeps the pointer cursor and clickability + * in agreement. Coordinate mapping alone was too forgiving: clicks past the + * end of a line, or over syntax hidden by preview replace-decorations, + * resolve to a position inside the Link node and opened links the pointer + * never touched. + */ +export function linkOpenHandler(open: (url: string) => void): Extension { + return EditorView.domEventHandlers({ + mousedown(e, view) { + if (e.button !== 0 || e.shiftKey || e.altKey) return false; + const target = + e.target instanceof Element ? e.target.closest(".cm-link-target") : null; + if (!target) return false; + const preview = view.state.field(previewModeField, false) ?? false; + const prefs = view.state.field(linkPrefsField); + const mod = e.metaKey || e.ctrlKey; + // Plain click only opens in preview mode with the "click" preference; + // edit mode always needs Cmd/Ctrl so clicks can place the caret. + if (!mod && !(preview && prefs.openWith === "click")) return false; + const pos = view.posAtDOM(target, 0); + const url = linkAt(view.state, pos); + if (!url) return false; + // Swallow the event: without this a Cmd+click also spawns a second + // CM6 cursor, and a preview click would move the caret into markup. + e.preventDefault(); + open(url); + return true; + }, + }); +} diff --git a/src/lib/editor/reveal.ts b/src/lib/editor/reveal.ts new file mode 100644 index 0000000..dd67905 --- /dev/null +++ b/src/lib/editor/reveal.ts @@ -0,0 +1,45 @@ +// The one reveal predicate. No other module may compare a selection to a +// construct span; if a module needs the comparison, it imports this. The +// previous architecture had three private copies of this logic and every +// "typing lands in invisible markup" bug was two of them disagreeing. + +import type { RevealMode } from "./types"; + +/** A selection range in document offsets, anchor/head order normalized. */ +export interface SelRange { + readonly from: number; + readonly to: number; +} + +/** + * True when [selFrom, selTo] touches [from, to], boundary inclusive. A caret + * immediately after **bold** still counts, because typing there is exactly + * when the eye needs the markup on screen. + */ +export function touches( + from: number, + to: number, + selFrom: number, + selTo: number, +): boolean { + return selFrom <= to && selTo >= from; +} + +/** + * Whether a construct is revealed (shows raw syntax) for the given + * selection ranges. "never" constructs stay folded; "span" constructs open + * when any selection range touches them, so multi-cursor edits are as + * truthful as single-caret ones. + */ +export function revealed( + reveal: RevealMode, + from: number, + to: number, + sel: readonly SelRange[], +): boolean { + if (reveal !== "span") return false; + for (const r of sel) { + if (touches(from, to, r.from, r.to)) return true; + } + return false; +} diff --git a/src/lib/editor/scanner.ts b/src/lib/editor/scanner.ts new file mode 100644 index 0000000..fc85fad --- /dev/null +++ b/src/lib/editor/scanner.ts @@ -0,0 +1,182 @@ +// The single syntax walk and the table it produces. +// +// ConstructScanner routes every syntax node to the spec that owns it and +// collects the emissions into a ConstructTable. The table is the one source +// of truth the whole kernel derives from: rendering (decorations), caret +// legality (atomic ranges), and pointer normalization (coverage queries) all +// read the same data, so they cannot disagree about what is hidden. +// +// Pure over EditorState: no view, no DOM, fully unit-testable. + +import { Decoration, type DecorationSet } from "@codemirror/view"; +import { RangeSet, type EditorState, type Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { revealed, type SelRange } from "./reveal"; +import type { ConstructSpec, Emit, RevealMode, ScanContext, TextSpec } from "./types"; + +/** A declared construct span. */ +export interface ConstructSpan { + readonly from: number; + readonly to: number; + readonly reveal: RevealMode; +} + +/** A markup range replaced while its owning construct is folded. */ +export interface HiddenRange { + readonly owner: number; + readonly from: number; + readonly to: number; + readonly deco: Decoration; + /** True when the replacement renders a widget (visible object) rather than nothing. */ + readonly widget: boolean; +} + +const HIDDEN = Decoration.replace({}); + +/** + * The scan result. Immutable; every query takes the selection as input, so + * one table serves every selection state between rescans. + */ +export class ConstructTable { + constructor( + private readonly spans: readonly ConstructSpan[], + /** Sorted by (from, to). */ + private readonly hides: readonly HiddenRange[], + private readonly always: readonly Range[], + ) {} + + /** Hidden ranges whose owning construct is folded for `sel`. */ + foldedHides(sel: readonly SelRange[]): HiddenRange[] { + const open = this.spans.map((s) => revealed(s.reveal, s.from, s.to, sel)); + return this.hides.filter((h) => !open[h.owner]); + } + + /** Everything the view should draw for `sel`: styling plus active folds. */ + decorations(sel: readonly SelRange[]): DecorationSet { + const ranges = [...this.always]; + for (const h of this.foldedHides(sel)) { + ranges.push(h.deco.range(h.from, h.to)); + } + return Decoration.set(ranges, true); + } + + /** + * The folded ranges as a RangeSet for EditorView.atomicRanges: cursor + * motion and deletion treat them as single objects. Revealed constructs + * drop out, so approaching markup opens it and then edits it char by char. + */ + atomicRanges(sel: readonly SelRange[]): RangeSet { + const folded = this.foldedHides(sel); + if (folded.length === 0) return RangeSet.empty; + return RangeSet.of( + folded.map((h) => h.deco.range(h.from, h.to)), + true, + ); + } + + /** + * True when every position in [from, to) is inside some folded hidden + * range for `sel`: i.e. the span is entirely invisible (or replaced) on + * screen. CaretGuard uses this to recognize clicks past the visible end + * of a line. + */ + allHiddenBetween(from: number, to: number, sel: readonly SelRange[]): boolean { + if (from >= to) return true; + let cover = from; + for (const h of this.foldedHides(sel)) { + if (h.to <= cover) continue; + if (h.from > cover) return false; + cover = h.to; + if (cover >= to) return true; + } + return cover >= to; + } +} + +/** + * Owns the one tree walk. Constructed once with the spec registry; `scan` + * is called per update by the kernel plugin (and directly by tests). + */ +export class ConstructScanner { + readonly #byName = new Map(); + readonly #textSpecs: readonly TextSpec[]; + + constructor(specs: readonly ConstructSpec[], textSpecs: readonly TextSpec[] = []) { + for (const spec of specs) { + for (const name of spec.nodes) { + if (this.#byName.has(name)) { + throw new Error(`Two construct specs claim syntax node "${name}"`); + } + this.#byName.set(name, spec); + } + } + this.#textSpecs = textSpecs; + } + + scan( + state: EditorState, + ranges: readonly SelRange[], + preview: boolean, + ): ConstructTable { + const spans: ConstructSpan[] = []; + const spanIds = new Map(); + const hides: HiddenRange[] = []; + const hideSeen = new Set(); + const always: Range[] = []; + const lineSeen = new Set(); + + const emit: Emit = { + construct(from, to, reveal) { + const key = `${from}:${to}:${reveal}`; + let id = spanIds.get(key); + if (id === undefined) { + id = spans.length; + spans.push({ from, to, reveal }); + spanIds.set(key, id); + } + return id; + }, + hide(owner, from, to, deco) { + if (!preview || to <= from) return; + const key = `${owner}:${from}:${to}`; + if (hideSeen.has(key)) return; + hideSeen.add(key); + hides.push({ + owner, + from, + to, + deco: deco ?? HIDDEN, + widget: deco?.spec.widget != null, + }); + }, + mark(from, to, deco) { + if (to > from) always.push(deco.range(from, to)); + }, + line(lineFrom, deco) { + if (lineSeen.has(lineFrom)) return; + lineSeen.add(lineFrom); + always.push(deco.range(lineFrom)); + }, + }; + + const cx: ScanContext = { state, preview }; + const tree = syntaxTree(state); + for (const { from, to } of ranges) { + tree.iterate({ + from, + to, + enter: (node) => { + const spec = this.#byName.get(node.name); + if (!spec) return; + return spec.enter(node, cx, emit); + }, + }); + for (const ts of this.#textSpecs) { + ts.scan(state.doc.sliceString(from, to), from, cx, emit); + } + } + + hides.sort((a, b) => a.from - b.from || a.to - b.to); + return new ConstructTable(spans, hides, always); + } +} diff --git a/src/lib/editor/tasks.test.ts b/src/lib/editor/tasks.test.ts new file mode 100644 index 0000000..985336e --- /dev/null +++ b/src/lib/editor/tasks.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { taskToggleChange, taskChecked } from "./tasks"; + +describe("taskToggleChange", () => { + it("checks an open task", () => { + expect(taskToggleChange("- [ ] ship it", 100)).toEqual({ + from: 103, + to: 104, + insert: "x", + }); + }); + + it("unchecks a done task", () => { + expect(taskToggleChange("- [x] shipped", 0)).toEqual({ + from: 3, + to: 4, + insert: " ", + }); + }); + + it("handles uppercase X", () => { + expect(taskToggleChange("- [X] done", 0)?.insert).toBe(" "); + }); + + it("handles indented and ordered task items", () => { + expect(taskToggleChange(" - [ ] nested", 0)).toEqual({ + from: 5, + to: 6, + insert: "x", + }); + expect(taskToggleChange("1. [ ] first", 0)).toEqual({ + from: 4, + to: 5, + insert: "x", + }); + }); + + it("returns null for non-task lines", () => { + expect(taskToggleChange("- plain bullet", 0)).toBeNull(); + expect(taskToggleChange("plain text [ ] not a task", 0)).toBeNull(); + expect(taskToggleChange("> [ ] quoted, not a list", 0)).toBeNull(); + }); +}); + +describe("taskChecked", () => { + it("reads the marker state", () => { + expect(taskChecked("[x]")).toBe(true); + expect(taskChecked("[X]")).toBe(true); + expect(taskChecked("[ ]")).toBe(false); + }); +}); diff --git a/src/lib/editor/tasks.ts b/src/lib/editor/tasks.ts new file mode 100644 index 0000000..2080603 --- /dev/null +++ b/src/lib/editor/tasks.ts @@ -0,0 +1,58 @@ +// GFM task toggling. The checkbox widget (constructs/task.ts) and the +// edit-mode marker click below both flow through taskToggleChange, so a +// toggle is always the same one-character edit: undoable, auto-saved, +// tag-safe. + +import { EditorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { previewModeField } from "./kernel"; + +/** + * The one-character change that flips a task marker on this line, or null if + * the line is not a task item. Pure for tests; `lineFrom` is the line's + * document offset. + */ +export function taskToggleChange( + lineText: string, + lineFrom: number, +): { from: number; to: number; insert: string } | null { + const m = lineText.match(/^(\s*(?:[-*+]|\d+\.)\s+\[)([ xX])\]/); + if (!m) return null; + const at = lineFrom + m[1].length; + return { from: at, to: at + 1, insert: m[2] === " " ? "x" : " " }; +} + +/** True when the marker on this task line is checked. */ +export function taskChecked(markerText: string): boolean { + return /\[[xX]\]/.test(markerText); +} + +/** + * Edit mode: clicking the raw [ ] / [x] marker toggles it, so the checkbox + * habit works no matter which mode the note is in. Preview clicks are owned + * by the checkbox widget itself. + */ +export function editModeTaskToggle(): Extension { + return EditorView.domEventHandlers({ + mousedown(e, view) { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return false; + } + if (view.state.field(previewModeField)) return false; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }); + if (pos === null) return false; + const tree = syntaxTree(view.state); + const node = tree.resolveInner(pos, 1); + if (node.name !== "TaskMarker" || pos < node.from || pos >= node.to) { + return false; + } + const line = view.state.doc.lineAt(pos); + const change = taskToggleChange(line.text, line.from); + if (!change) return false; + e.preventDefault(); + view.dispatch({ changes: change }); + return true; + }, + }); +} diff --git a/src/lib/editor/theme.ts b/src/lib/editor/theme.ts new file mode 100644 index 0000000..35f62c7 --- /dev/null +++ b/src/lib/editor/theme.ts @@ -0,0 +1,97 @@ +// Every kernel base theme in one place. Class names are unchanged from the +// retired per-feature files, so app.css overrides and user familiarity hold. +// (.cm-tag is styled in app.css; underline ownership lives here, not in the +// highlight spec, so the Links underline setting is the single source of +// truth in both modes.) + +import { EditorView } from "@codemirror/view"; + +export const kernelTheme = EditorView.baseTheme({ + // Lists + ".cm-wysiwyg-bullet, .cm-wysiwyg-number": { + color: "var(--text)", + marginRight: "0.35em", + userSelect: "none", + }, + // Blockquotes + ".cm-wysiwyg-blockquote": { + borderLeft: "3px solid var(--accent)", + paddingLeft: "12px", + color: "var(--text-secondary)", + fontStyle: "italic", + }, + // Code fences + ".cm-wysiwyg-codeblock": { + background: "var(--bg-sidebar)", + fontFamily: "var(--font-meta)", + fontSize: "0.9em", + paddingLeft: "10px", + paddingRight: "10px", + }, + ".cm-wysiwyg-codeinfo": { + color: "var(--text-tertiary)", + fontSize: "0.85em", + }, + // Horizontal rules + ".cm-wysiwyg-hr": { + display: "inline-block", + width: "100%", + height: "1px", + verticalAlign: "middle", + background: "var(--border)", + }, + // Tables + ".cm-wysiwyg-table": { + fontFamily: "var(--font-meta)", + fontSize: "0.9em", + }, + ".cm-wysiwyg-tablehead": { + fontWeight: "600", + }, + ".cm-wysiwyg-tabledelim": { + color: "var(--text-tertiary)", + }, + // Links + ".cm-link-clickable": { + cursor: "pointer", + }, + ".cm-link-ul-always": { + textDecoration: "underline", + }, + ".cm-link-ul-hover": { + textDecoration: "none", + }, + ".cm-link-ul-hover:hover": { + textDecoration: "underline", + }, + ".cm-link-ul-never": { + textDecoration: "none", + }, + ".cm-link-ext::after": { + content: "'↗'", + fontSize: "0.7em", + verticalAlign: "super", + marginLeft: "1px", + opacity: "0.75", + }, + // Tasks + ".cm-task-checkbox": { + width: "14px", + height: "14px", + margin: "0 6px 0 0", + verticalAlign: "middle", + accentColor: "var(--accent)", + cursor: "pointer", + }, + ".cm-task-done": { + textDecoration: "line-through", + color: "var(--text-tertiary)", + }, + // Images + ".cm-image-preview": { + maxWidth: "100%", + maxHeight: "420px", + borderRadius: "6px", + verticalAlign: "text-bottom", + }, +}); diff --git a/src/lib/editor/types.ts b/src/lib/editor/types.ts new file mode 100644 index 0000000..2314dd1 --- /dev/null +++ b/src/lib/editor/types.ts @@ -0,0 +1,72 @@ +// The construct model shared by every kernel module. +// +// A ConstructSpec declares what a markdown construct looks like; the scanner +// (scanner.ts) routes syntax nodes to specs and collects what they emit into +// a ConstructTable. Specs never touch the view, events, or other constructs. +// That single direction of data flow is what makes the kernel's guarantees +// hold by construction (see ARCHITECTURE.md). + +import type { Decoration } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; +import type { SyntaxNodeRef } from "@lezer/common"; + +/** + * When a construct shows its raw syntax. + * + * - "span": revealed while the selection touches the span, boundary + * inclusive. The WYSIWYG default: approach it and it opens, leave and it + * folds. + * - "never": stays rendered. Used for widget replacements (bullets, task + * checkboxes) that remain stable objects; their hidden ranges are atomic, + * so the caret treats them as single units instead of entering them. + */ +export type RevealMode = "span" | "never"; + +/** Read-only context handed to each spec during a scan. */ +export interface ScanContext { + readonly state: EditorState; + /** True when the Aa toolbar is closed and markdown chrome is rendered. */ + readonly preview: boolean; +} + +/** + * The declaration surface for specs. All positions are document offsets. + * Everything emitted is collected by the scanner; nothing renders directly. + */ +export interface Emit { + /** + * Declare a construct span and how it reveals. Deduplicated on + * (from, to, reveal) so several marks sharing a parent declare it once. + * Returns the construct id used to attach hidden ranges. + */ + construct(from: number, to: number, reveal: RevealMode): number; + /** + * Markup replaced while the owning construct is folded: hidden entirely, + * or swapped for `deco`'s widget. A no-op outside preview mode, so specs + * never need to gate hides themselves. + */ + hide(owner: number, from: number, to: number, deco?: Decoration): void; + /** A styling mark, active regardless of reveal state or mode. */ + mark(from: number, to: number, deco: Decoration): void; + /** + * A line decoration at `lineFrom`. Deduplicated per line, first emit + * wins, matching the retired wysiwyg layer's behavior for nested blocks. + */ + line(lineFrom: number, deco: Decoration): void; +} + +/** A tree-driven construct: the scanner routes syntax nodes by name. */ +export interface ConstructSpec { + /** Node names this spec owns. A name may belong to only one spec. */ + readonly nodes: readonly string[]; + /** + * Handle one node. Return false to skip its children, mirroring lezer's + * iterate contract; return true (or nothing) to descend. + */ + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean | void; +} + +/** A text-driven construct (regex over visible text), e.g. #tags. */ +export interface TextSpec { + scan(text: string, offset: number, cx: ScanContext, emit: Emit): void; +} diff --git a/src/lib/markdown-active.ts b/src/lib/markdown-active.ts index 0c2b5cb..443d90f 100644 --- a/src/lib/markdown-active.ts +++ b/src/lib/markdown-active.ts @@ -14,6 +14,7 @@ export interface ActiveMarks { code: boolean; quote: boolean; list: boolean; + task: boolean; } export const NO_MARKS: ActiveMarks = { @@ -23,6 +24,7 @@ export const NO_MARKS: ActiveMarks = { code: false, quote: false, list: false, + task: false, }; // Lezer-markdown node names → the toolbar mark they represent. @@ -48,6 +50,9 @@ function mark(name: string, marks: ActiveMarks): void { case "ListItem": marks.list = true; break; + case "Task": + marks.task = true; + break; } } diff --git a/src/lib/markdown-extensions.test.ts b/src/lib/markdown-extensions.test.ts new file mode 100644 index 0000000..49a7cc1 --- /dev/null +++ b/src/lib/markdown-extensions.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { highlightExtension } from "./markdown-extensions"; + +// Same parser setup as the editor. +function treeNames(doc: string): string[] { + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: [highlightExtension] }), + ], + }); + ensureSyntaxTree(state, doc.length, 5000); + const names: string[] = []; + syntaxTree(state).iterate({ + enter(n) { + names.push(n.name); + }, + }); + return names; +} + +describe("highlightExtension", () => { + it("parses ==text== into a Highlight node with marks", () => { + const names = treeNames("this is ==important== stuff"); + expect(names).toContain("Highlight"); + expect(names).toContain("HighlightMark"); + }); + + it("leaves single = alone", () => { + const names = treeNames("a = b and c =d"); + expect(names).not.toContain("Highlight"); + }); + + it("nests emphasis inside a highlight", () => { + const names = treeNames("==really **bold** point=="); + expect(names).toContain("Highlight"); + expect(names).toContain("StrongEmphasis"); + }); +}); diff --git a/src/lib/markdown-extensions.ts b/src/lib/markdown-extensions.ts new file mode 100644 index 0000000..f403682 --- /dev/null +++ b/src/lib/markdown-extensions.ts @@ -0,0 +1,34 @@ +// Custom lezer-markdown syntax beyond GFM. Currently one addition: +// ==highlight== (the marker pencil), which GFM does not define but every +// serious markdown note app supports. Parsed as a proper inline node so the +// WYSIWYG layer can hide the markers and the highlight style can tint the +// span - no regex over text. + +import type { MarkdownConfig } from "@lezer/markdown"; +import { Tag } from "@lezer/highlight"; + +/** Highlight content tag, styled in markdown-highlight.ts. */ +export const highlightTag = Tag.define(); + +const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" }; + +const EQ = 61; // "=" + +export const highlightExtension: MarkdownConfig = { + defineNodes: [ + { name: "Highlight", style: { "Highlight/...": highlightTag } }, + { name: "HighlightMark" }, + ], + parseInline: [ + { + name: "Highlight", + parse(cx, next, pos) { + if (next !== EQ || cx.char(pos + 1) !== EQ) return -1; + return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true); + }, + // Run alongside emphasis-style delimiters, before link resolution + // swallows the characters. + before: "Emphasis", + }, + ], +}; diff --git a/src/lib/markdown-format.ts b/src/lib/markdown-format.ts index 82f0774..e2a8e94 100644 --- a/src/lib/markdown-format.ts +++ b/src/lib/markdown-format.ts @@ -11,7 +11,15 @@ export interface Edit { selection: Sel; } -export type FormatKind = "bold" | "italic" | "strike" | "code" | "quote" | "list" | "link"; +export type FormatKind = + | "bold" + | "italic" + | "strike" + | "code" + | "quote" + | "list" + | "task" + | "link"; /** Map a toolbar/shortcut action to the corresponding document edit. */ export function formatEdit(doc: string, sel: Sel, kind: FormatKind): Edit { @@ -28,6 +36,8 @@ export function formatEdit(doc: string, sel: Sel, kind: FormatKind): Edit { return toggleLinePrefix(doc, sel, "> "); case "list": return toggleLinePrefix(doc, sel, "- "); + case "task": + return toggleLinePrefix(doc, sel, "- [ ] "); case "link": return insertLink(doc, sel); } diff --git a/src/lib/markdown-highlight.test.ts b/src/lib/markdown-highlight.test.ts index 0ce4558..bac9898 100644 --- a/src/lib/markdown-highlight.test.ts +++ b/src/lib/markdown-highlight.test.ts @@ -1,14 +1,19 @@ import { describe, it, expect } from "vitest"; -import { markdownHighlightSpec, HEADING_TAGS } from "./markdown-highlight"; +import { tags as t } from "@lezer/highlight"; +import { markdownHighlightSpec } from "./markdown-highlight"; describe("markdown highlight spec", () => { const styledTags = markdownHighlightSpec.flatMap((s) => Array.isArray(s.tag) ? s.tag : [s.tag], ); - it("never styles a markdown heading (# is reserved for tags)", () => { - for (const heading of HEADING_TAGS) { - expect(styledTags).not.toContain(heading); + // Headings render because `# Heading` (space) and `#tag` (no space) are + // disjoint: CommonMark requires the space for a heading, the tag + // highlighter requires its absence. If either side of that invariant + // changes, tags and headings collide - revisit both together. + it("styles headings with a visual scale", () => { + for (const heading of [t.heading1, t.heading2, t.heading3, t.heading]) { + expect(styledTags).toContain(heading); } }); diff --git a/src/lib/markdown-highlight.ts b/src/lib/markdown-highlight.ts index 576ac59..c26b327 100644 --- a/src/lib/markdown-highlight.ts +++ b/src/lib/markdown-highlight.ts @@ -1,31 +1,34 @@ // Inline markdown styling for the editor: markers stay visible, the text just -// looks structured. Headings are intentionally NOT styled - `#` is reserved for -// tags in InstantNotes, so `#title` stays a tag and is never rendered as a -// heading. The editor's tag highlighter styles `#tag` on top of this. +// looks structured. +// +// Headings and tags coexist without ambiguity: CommonMark only parses +// `# Heading` (with a space) as a heading, and the editor's tag highlighter +// only matches `#tag` (no space). So `#roadmap` stays a tag and `# Roadmap` +// renders as a title - two different gestures, two different meanings. import { HighlightStyle } from "@codemirror/language"; import { tags as t } from "@lezer/highlight"; - -// Markdown highlight tags we must never style, so a tag can't be mistaken for a -// heading. Pinned by a test against the spec below. -export const HEADING_TAGS = [ - t.heading, - t.heading1, - t.heading2, - t.heading3, - t.heading4, - t.heading5, - t.heading6, -]; +import { highlightTag } from "./markdown-extensions"; export const markdownHighlightSpec = [ + // heading1..3 get distinct scale; deeper levels share the generic heading + // weight (lezer tag hierarchy: headingN falls back to heading). + { tag: t.heading1, fontSize: "1.5em", fontWeight: "700" }, + { tag: t.heading2, fontSize: "1.3em", fontWeight: "650" }, + { tag: t.heading3, fontSize: "1.15em", fontWeight: "600" }, + { tag: t.heading, fontWeight: "600" }, { tag: t.strong, fontWeight: "600" }, { tag: t.emphasis, fontStyle: "italic" }, { tag: t.strikethrough, textDecoration: "line-through" }, { tag: t.monospace, fontFamily: "var(--font-meta)" }, { tag: t.quote, color: "var(--text-secondary)", fontStyle: "italic" }, - { tag: t.link, color: "var(--accent)", textDecoration: "underline" }, + // Underline is owned by the link-appearance setting (src/lib/editor), not + // baked in here, so "underline: never/hover" can actually win. + { tag: t.link, color: "var(--accent)" }, { tag: t.url, color: "var(--accent)" }, + // ==highlight== spans, parsed by the custom extension in + // markdown-extensions.ts. + { tag: highlightTag, backgroundColor: "var(--accent-soft)", borderRadius: "2px" }, ]; export const markdownHighlight = HighlightStyle.define(markdownHighlightSpec); diff --git a/src/lib/stores/links.svelte.ts b/src/lib/stores/links.svelte.ts new file mode 100644 index 0000000..02c313d --- /dev/null +++ b/src/lib/stores/links.svelte.ts @@ -0,0 +1,77 @@ +// Link preferences (Svelte 5 runes): how links look and open inside notes. +// Persisted to the settings KV like editorPrefs. The editor consumes these +// through a CodeMirror state field (see src/lib/editor/links.ts), synced by +// Editor.svelte whenever a value changes, so edits apply live. + +import { getSetting, setSetting } from "$lib/api/client"; +import { + DEFAULT_LINK_PREFS, + type LinkOpenWith, + type LinkUnderline, + type LinkPrefsSnapshot, +} from "$lib/editor"; + +const KEY_OPEN = "links.openWith"; +const KEY_UNDERLINE = "links.underline"; +const KEY_TOOLTIP = "links.tooltip"; +const KEY_EXTERNAL = "links.externalIndicator"; + +class LinkPrefsStore { + openWith = $state(DEFAULT_LINK_PREFS.openWith); + underline = $state(DEFAULT_LINK_PREFS.underline); + tooltip = $state(DEFAULT_LINK_PREFS.tooltip); + externalIndicator = $state(DEFAULT_LINK_PREFS.externalIndicator); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const [open, ul, tip, ext] = await Promise.all([ + getSetting(KEY_OPEN), + getSetting(KEY_UNDERLINE), + getSetting(KEY_TOOLTIP), + getSetting(KEY_EXTERNAL), + ]); + if (open === "click" || open === "modclick") this.openWith = open; + if (ul === "always" || ul === "hover" || ul === "never") this.underline = ul; + if (typeof tip === "boolean") this.tooltip = tip; + if (typeof ext === "boolean") this.externalIndicator = ext; + } catch { + // Settings are best-effort; fall back to defaults silently. + } + } + + /** Plain object for dispatching into CodeMirror (no reactive proxies). */ + snapshot(): LinkPrefsSnapshot { + return { + openWith: this.openWith, + underline: this.underline, + tooltip: this.tooltip, + externalIndicator: this.externalIndicator, + }; + } + + setOpenWith(v: LinkOpenWith): void { + this.openWith = v; + void setSetting(KEY_OPEN, v); + } + + setUnderline(v: LinkUnderline): void { + this.underline = v; + void setSetting(KEY_UNDERLINE, v); + } + + setTooltip(v: boolean): void { + this.tooltip = v; + void setSetting(KEY_TOOLTIP, v); + } + + setExternalIndicator(v: boolean): void { + this.externalIndicator = v; + void setSetting(KEY_EXTERNAL, v); + } +} + +export const linkPrefs = new LinkPrefsStore(); diff --git a/src/lib/wysiwyg.ts b/src/lib/wysiwyg.ts deleted file mode 100644 index c05bf37..0000000 --- a/src/lib/wysiwyg.ts +++ /dev/null @@ -1,278 +0,0 @@ -// WYSIWYG preview extension for CodeMirror 6. -// -// When previewModeField is true (Aa toolbar closed), markdown syntax markers -// are hidden via replace decorations and block elements get visual treatment. -// The document stays fully editable - the raw markdown is unchanged, just -// rendered differently. - -import { - EditorView, - Decoration, - type DecorationSet, - ViewPlugin, - type ViewUpdate, - WidgetType, - keymap, -} from "@codemirror/view"; -import { StateEffect, StateField, RangeSetBuilder } from "@codemirror/state"; -import { syntaxTree } from "@codemirror/language"; - -// --------------------------------------------------------------------------- -// Mode state -// --------------------------------------------------------------------------- - -export const setPreviewMode = StateEffect.define(); - -export const previewModeField = StateField.define({ - create: () => false, - update(val, tr) { - for (const e of tr.effects) { - if (e.is(setPreviewMode)) return e.value; - } - return val; - }, -}); - -// --------------------------------------------------------------------------- -// Widgets -// --------------------------------------------------------------------------- - -class BulletWidget extends WidgetType { - toDOM() { - const s = document.createElement("span"); - s.className = "cm-wysiwyg-bullet"; - s.textContent = "•"; - return s; - } - ignoreEvent() { - return true; - } -} - -class NumberWidget extends WidgetType { - constructor(readonly num: number) { - super(); - } - eq(o: NumberWidget) { - return o.num === this.num; - } - toDOM() { - const s = document.createElement("span"); - s.className = "cm-wysiwyg-number"; - s.textContent = `${this.num}.`; - return s; - } - ignoreEvent() { - return true; - } -} - -// --------------------------------------------------------------------------- -// Decoration builder -// --------------------------------------------------------------------------- - -type Deco = { from: number; to: number; deco: Decoration }; - -function buildDecorations(view: EditorView): DecorationSet { - const state = view.state; - const collected: Deco[] = []; - // Track lines that already have a line-deco so we don't add it twice. - const lineDecoAdded = new Set(); - - for (const { from, to } of view.visibleRanges) { - syntaxTree(state).iterate({ - from, - to, - enter(node) { - const { name, from: nFrom, to: nTo } = node; - - // --- Inline: hide emphasis/strikethrough/code marks --- - if ( - name === "EmphasisMark" || - name === "StrikethroughMark" || - name === "CodeMark" - ) { - collected.push({ from: nFrom, to: nTo, deco: Decoration.replace({}) }); - return false; - } - - // --- Inline: hide link markup, keep link text visible --- - if (name === "Link") { - const raw = state.doc.sliceString(nFrom, nTo); - // Find ]( which separates link text from URL - const splitIdx = raw.indexOf("]("); - if (splitIdx !== -1) { - // Hide opening [ - collected.push({ - from: nFrom, - to: nFrom + 1, - deco: Decoration.replace({}), - }); - // Hide ](...) to end of link - collected.push({ - from: nFrom + splitIdx, - to: nTo, - deco: Decoration.replace({}), - }); - } - return false; - } - - // --- Block: list markers (- / * / + / 1.) --- - if (name === "ListMark") { - const line = state.doc.lineAt(nFrom); - const m = line.text.match(/^(\s*)([-*+]|\d+\.)\s+/); - if (m) { - // markerEnd covers indent + marker char(s) + trailing space - const markerEnd = line.from + m[0].length; - const isOrdered = /^\d+\./.test(m[2]); - if (isOrdered) { - const num = parseInt(m[2], 10); - collected.push({ - from: line.from + m[1].length, // after indent - to: markerEnd, - deco: Decoration.replace({ widget: new NumberWidget(num) }), - }); - } else { - collected.push({ - from: line.from + m[1].length, - to: markerEnd, - deco: Decoration.replace({ widget: new BulletWidget() }), - }); - } - } - return false; - } - - // --- Block: blockquote marker (>) --- - if (name === "QuoteMark") { - const line = state.doc.lineAt(nFrom); - const m = line.text.match(/^(>\s*)/); - if (m) { - const markerEnd = line.from + m[0].length; - // Line decoration for styling - add once per line - if (!lineDecoAdded.has(line.from)) { - lineDecoAdded.add(line.from); - collected.push({ - from: line.from, - to: line.from, - deco: Decoration.line({ class: "cm-wysiwyg-blockquote" }), - }); - } - // Replace the > prefix - collected.push({ - from: line.from, - to: markerEnd, - deco: Decoration.replace({}), - }); - } - return false; - } - }, - }); - } - - // Sort by from asc, then to asc (required by RangeSetBuilder). - collected.sort((a, b) => a.from - b.from || a.to - b.to); - - const builder = new RangeSetBuilder(); - for (const { from, to, deco } of collected) { - builder.add(from, to, deco); - } - return builder.finish(); -} - -// --------------------------------------------------------------------------- -// ViewPlugin -// --------------------------------------------------------------------------- - -const wysiwygPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = view.state.field(previewModeField) - ? buildDecorations(view) - : Decoration.none; - } - - update(u: ViewUpdate) { - const was = u.startState.field(previewModeField); - const is = u.state.field(previewModeField); - if (is) { - if (u.docChanged || u.viewportChanged || was !== is) { - this.decorations = buildDecorations(u.view); - } - } else if (was !== is) { - this.decorations = Decoration.none; - } - } - }, - { decorations: (v) => v.decorations }, -); - -// --------------------------------------------------------------------------- -// Smart Backspace for block elements -// -// Exported for unit testing independent of CM6. -// --------------------------------------------------------------------------- - -/** - * Given a line's text and its document-start offset, returns the range - * occupied by the block marker (including trailing whitespace), or null if - * the line does not start with one. - */ -export function blockMarkerRange( - lineText: string, - lineFrom: number, -): { from: number; to: number } | null { - const m = lineText.match(/^(\s*)(>\s*|[-*+]\s+|\d+\.\s+)/); - if (!m) return null; - return { from: lineFrom + m[1].length, to: lineFrom + m[0].length }; -} - -const wysiwygKeymap = keymap.of([ - { - key: "Backspace", - run(view) { - if (!view.state.field(previewModeField)) return false; - const sel = view.state.selection.main; - if (!sel.empty) return false; - - const line = view.state.doc.lineAt(sel.from); - const range = blockMarkerRange(line.text, line.from); - if (!range || sel.from !== range.to) return false; - - view.dispatch({ - changes: { from: range.from, to: range.to, insert: "" }, - }); - return true; - }, - }, -]); - -// --------------------------------------------------------------------------- -// Public extension bundle -// --------------------------------------------------------------------------- - -export function wysiwygExtension() { - return [previewModeField, wysiwygPlugin, wysiwygKeymap]; -} - -// --------------------------------------------------------------------------- -// Theme -// --------------------------------------------------------------------------- - -export const wysiwygTheme = EditorView.baseTheme({ - ".cm-wysiwyg-bullet, .cm-wysiwyg-number": { - color: "var(--text)", - marginRight: "0.35em", - userSelect: "none", - }, - ".cm-wysiwyg-blockquote": { - borderLeft: "3px solid var(--accent)", - paddingLeft: "12px", - color: "var(--text-secondary)", - fontStyle: "italic", - }, -}); From 0b05fd7a6e333b47c4d56d7d170dbe7ebad8dde2 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:24:05 -0700 Subject: [PATCH 24/41] feat(view): collapsible, resizable sidebar Add a drag handle on the sidebar border with pointer-capture resizing, double-click reset, and arrow-key nudging. Width and collapsed state persist to the settings store, and Cmd+\ or a command-palette entry toggles the sidebar from anywhere. --- src/lib/commands.ts | 8 +++ src/lib/stores/sidebar.svelte.ts | 60 +++++++++++++++++++++ src/routes/+page.svelte | 92 ++++++++++++++++++++++++++++++-- 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/lib/stores/sidebar.svelte.ts diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 90b0887..a9a6bcb 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -4,6 +4,7 @@ // of its own. Pure filtering/ranking lives in command-filter.ts. import { library } from "$lib/stores/library.svelte"; +import { sidebar } from "$lib/stores/sidebar.svelte"; import { theme } from "$lib/stores/theme.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; import { exportTheme, importTheme } from "$lib/themes/share"; @@ -63,6 +64,13 @@ export function buildThemeCommands(): Command[] { export function buildCommands(): Command[] { const commands: Command[] = [ { id: "note.new", title: "New note", group: "Notes", shortcut: `${modKey}N`, run: () => library.newNote() }, + { + id: "view.sidebar", + title: sidebar.collapsed ? "Show sidebar" : "Hide sidebar", + group: "View", + shortcut: `${modKey}\\`, + run: () => sidebar.toggle(), + }, ]; if (library.selected) { diff --git a/src/lib/stores/sidebar.svelte.ts b/src/lib/stores/sidebar.svelte.ts new file mode 100644 index 0000000..808896a --- /dev/null +++ b/src/lib/stores/sidebar.svelte.ts @@ -0,0 +1,60 @@ +// Sidebar layout state (Svelte 5 runes): drag-resizable width and an +// open/closed toggle. Persisted to the settings KV like editorPrefs, so the +// layout comes back the way it was left. + +import { getSetting, setSetting } from "$lib/api/client"; + +const KEY_WIDTH = "sidebar.width"; +const KEY_COLLAPSED = "sidebar.collapsed"; + +export const SIDEBAR_MIN = 150; +export const SIDEBAR_MAX = 420; +export const SIDEBAR_DEFAULT = 190; + +class SidebarState { + width = $state(SIDEBAR_DEFAULT); + collapsed = $state(false); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const [w, c] = await Promise.all([ + getSetting(KEY_WIDTH), + getSetting(KEY_COLLAPSED), + ]); + if (typeof w === "number" && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) this.width = w; + if (typeof c === "boolean") this.collapsed = c; + } catch { + // Settings are best-effort; fall back to defaults silently. + } + } + + #clamp(w: number): number { + return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(w))); + } + + /** Live-updates during a drag; call commitWidth() once when it ends. */ + setWidth(w: number): void { + this.width = this.#clamp(w); + } + + /** Persist the width once per gesture instead of on every pointermove. */ + commitWidth(): void { + void setSetting(KEY_WIDTH, this.width); + } + + resetWidth(): void { + this.width = SIDEBAR_DEFAULT; + this.commitWidth(); + } + + toggle(): void { + this.collapsed = !this.collapsed; + void setSetting(KEY_COLLAPSED, this.collapsed); + } +} + +export const sidebar = new SidebarState(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d2845e5..673ff6f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,6 +20,8 @@ import { library } from "$lib/stores/library.svelte"; import { updater } from "$lib/stores/updater.svelte"; import { editorPrefs } from "$lib/stores/editor.svelte"; + import { sidebar } from "$lib/stores/sidebar.svelte"; + import { linkPrefs as linkPrefsStore } from "$lib/stores/links.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; import { confirmDialog } from "$lib/stores/confirm.svelte"; @@ -31,6 +33,8 @@ onMount(() => { void library.init(); void editorPrefs.init(); + void sidebar.init(); + void linkPrefsStore.init(); void contexting.init(); void getVersion().then((v) => (appVersion = v)); updater.start(); @@ -115,6 +119,12 @@ editorPrefs.resetZoom(); return; } + // ⌘\ toggles the sidebar from anywhere, including input fields. + if (mod && e.key === "\\") { + e.preventDefault(); + sidebar.toggle(); + return; + } if (isTypingTarget(e.target)) { // Escape in the search field clears the search; everything else is typing. if ( @@ -184,6 +194,37 @@ await exportNoteFile(path, note.body); } + // Sidebar resize: pointer capture keeps the gesture on the handle even when + // the pointer outruns it; width persists once at release, not per move. + let draggingSidebar = $state(false); + + function startSidebarDrag(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + const handle = e.currentTarget as HTMLElement; + handle.setPointerCapture(e.pointerId); + draggingSidebar = true; + const startX = e.clientX; + const startWidth = sidebar.width; + const move = (ev: PointerEvent) => sidebar.setWidth(startWidth + ev.clientX - startX); + const up = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + draggingSidebar = false; + sidebar.commitWidth(); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + } + + function onHandleKeydown(e: KeyboardEvent) { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + sidebar.setWidth(sidebar.width + (e.key === "ArrowLeft" ? -10 : 10)); + sidebar.commitWidth(); + } + } + async function confirmBulkDestroy() { // Snapshot the ids when the dialog opens: the selection could otherwise // drift while it is up (menu events, cross-window refreshes) and the @@ -204,8 +245,34 @@ {#if settingsOpen} (settingsOpen = false)} /> {:else} -
- +
+ {#if !sidebar.collapsed} + + + + + + {/if}
{#if library.multiSelected.size > 1} @@ -227,12 +294,31 @@ From 2b5b6607cc64a52ae4eaf31da63f431e9dfe0fab Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:21:26 -0700 Subject: [PATCH 32/41] refactor(library): dedupe filter navigation and save-queue collection churn Collapse the three identical filter-reset blocks (selectWorkspace, selectRevisit, setTagFilter) into one resetForNavigation helper, and replace the hand-rolled copy-then-mutate of the save queue's unsaved and failed collections with small reactive-collections helpers that return a fresh Map or Set. Behavior is unchanged; the filter and save-queue tests still pass. --- src/lib/reactive-collections.test.ts | 49 ++++++++++++++++++++ src/lib/reactive-collections.ts | 34 ++++++++++++++ src/lib/stores/library.svelte.ts | 69 +++++++++++++--------------- 3 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 src/lib/reactive-collections.test.ts create mode 100644 src/lib/reactive-collections.ts diff --git a/src/lib/reactive-collections.test.ts b/src/lib/reactive-collections.test.ts new file mode 100644 index 0000000..4d648fb --- /dev/null +++ b/src/lib/reactive-collections.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + withMapEntry, + withoutMapKeys, + withSetEntry, + withoutSetEntries, +} from "./reactive-collections"; + +describe("reactive-collections", () => { + it("withMapEntry returns a new map with the entry set, leaving the source untouched", () => { + const src = new Map([["a", 1]]); + const out = withMapEntry(src, "b", 2); + expect(out).not.toBe(src); + expect([...out]).toEqual([ + ["a", 1], + ["b", 2], + ]); + expect(src.has("b")).toBe(false); + }); + + it("withMapEntry overwrites an existing key", () => { + expect(withMapEntry(new Map([["a", 1]]), "a", 9).get("a")).toBe(9); + }); + + it("withoutMapKeys removes every given key without touching the source", () => { + const src = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + const out = withoutMapKeys(src, ["a", "c", "missing"]); + expect([...out]).toEqual([["b", 2]]); + expect(src.size).toBe(3); + }); + + it("withSetEntry adds a value into a fresh set", () => { + const src = new Set(["a"]); + const out = withSetEntry(src, "b"); + expect(out).not.toBe(src); + expect([...out]).toEqual(["a", "b"]); + expect(src.has("b")).toBe(false); + }); + + it("withoutSetEntries removes every given value", () => { + const src = new Set(["a", "b", "c"]); + expect([...withoutSetEntries(src, ["a", "c"])]).toEqual(["b"]); + expect(src.size).toBe(3); + }); +}); diff --git a/src/lib/reactive-collections.ts b/src/lib/reactive-collections.ts new file mode 100644 index 0000000..8c6b2c2 --- /dev/null +++ b/src/lib/reactive-collections.ts @@ -0,0 +1,34 @@ +// Svelte 5 runes track reassignment of a $state Map or Set, not in-place +// mutation. These return a fresh collection so `store.x = withMapEntry(store.x, +// ...)` triggers an update, keeping the copy-then-mutate boilerplate (and the +// easy-to-forget reassignment) out of the call sites. + +export function withMapEntry( + map: ReadonlyMap, + key: K, + value: V, +): Map { + return new Map(map).set(key, value); +} + +export function withoutMapKeys( + map: ReadonlyMap, + keys: Iterable, +): Map { + const next = new Map(map); + for (const key of keys) next.delete(key); + return next; +} + +export function withSetEntry(set: ReadonlySet, value: T): Set { + return new Set(set).add(value); +} + +export function withoutSetEntries( + set: ReadonlySet, + values: Iterable, +): Set { + const next = new Set(set); + for (const value of values) next.delete(value); + return next; +} diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 216ff4a..11a0a0f 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -37,6 +37,12 @@ import type { WorkspaceWithCount, } from "$lib/api/types"; import { debounce } from "$lib/debounce"; +import { + withMapEntry, + withoutMapKeys, + withSetEntry, + withoutSetEntries, +} from "$lib/reactive-collections"; import { friendlyMessage } from "$lib/errors"; import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; import { toasts } from "$lib/stores/toasts.svelte"; @@ -226,6 +232,8 @@ class LibraryStore { } setStatusFilter(filter: StatusFilter): void { + // Status (All / Archived / Trash) composes with the active space or tag, + // so it clears revisit and search but keeps the space/tag scope. this.statusFilter = filter; this.revisitMode = false; this.searchText = ""; @@ -233,41 +241,40 @@ class LibraryStore { void this.refresh(); } - /** Show All Notes (null) or one workspace's collected notes. */ - selectWorkspace(workspaceId: string | null): void { - this.activeWorkspaceId = workspaceId; - this.revisitMode = false; + /** + * Clear every primary filter dimension so a caller can set exactly one. + * The space, tag, and revisit views are mutually exclusive; each entry + * point resets the rest, drops any scoped tag, and clears search and the + * multi-selection before choosing its own dimension. + */ + #resetForNavigation(): void { + this.activeWorkspaceId = null; + this.activeTagId = null; this.scopedTagId = null; this.workspaceTags = []; + this.revisitMode = false; this.statusFilter = "active"; - this.activeTagId = null; this.searchText = ""; this.clearMultiSelect(); + } + + /** Show All Notes (null) or one workspace's collected notes. */ + selectWorkspace(workspaceId: string | null): void { + this.#resetForNavigation(); + this.activeWorkspaceId = workspaceId; void this.refresh(); } /** Show the open loops: capture-born notes never opened in the library. */ selectRevisit(): void { + this.#resetForNavigation(); this.revisitMode = true; - this.activeWorkspaceId = null; - this.activeTagId = null; - this.scopedTagId = null; - this.workspaceTags = []; - this.statusFilter = "active"; - this.searchText = ""; - this.clearMultiSelect(); void this.refresh(); } setTagFilter(tagId: string | null): void { + this.#resetForNavigation(); this.activeTagId = tagId; - this.activeWorkspaceId = null; - this.revisitMode = false; - this.scopedTagId = null; - this.workspaceTags = []; - this.statusFilter = "active"; - this.searchText = ""; - this.clearMultiSelect(); void this.refresh(); } @@ -681,7 +688,7 @@ class LibraryStore { // Optimistic local state; persistence is debounced. The note is dirty // from this moment until a write of this (or a newer) body succeeds. this.selected.body = body; - this.#unsaved = new Map(this.#unsaved).set(this.selected.id, body); + this.#unsaved = withMapEntry(this.#unsaved, this.selected.id, body); this.#saveBody(this.selected.id, body); } @@ -816,14 +823,10 @@ class LibraryStore { // Confirmed on disk. Clear the queue entry unless a newer edit // superseded the body this write carried. if (this.#unsaved.get(id) === body) { - const unsaved = new Map(this.#unsaved); - unsaved.delete(id); - this.#unsaved = unsaved; + this.#unsaved = withoutMapKeys(this.#unsaved, [id]); } if (this.#failed.has(id)) { - const failed = new Set(this.#failed); - failed.delete(id); - this.#failed = failed; + this.#failed = withoutSetEntries(this.#failed, [id]); } if (this.selected?.id === id) { // Keep local body if user kept typing past this save. @@ -844,7 +847,7 @@ class LibraryStore { }, SAVE_RETRY_MS); this.#retryTimers.set(id, timer); } else { - this.#failed = new Set(this.#failed).add(id); + this.#failed = withSetEntry(this.#failed, id); this.#fail(e); } } @@ -860,15 +863,9 @@ class LibraryStore { /** Forget queued edits for notes that are being discarded. */ #dropQueued(...ids: string[]): void { - const unsaved = new Map(this.#unsaved); - const failed = new Set(this.#failed); - for (const id of ids) { - unsaved.delete(id); - failed.delete(id); - this.#clearRetryTimer(id); - } - this.#unsaved = unsaved; - this.#failed = failed; + this.#unsaved = withoutMapKeys(this.#unsaved, ids); + this.#failed = withoutSetEntries(this.#failed, ids); + for (const id of ids) this.#clearRetryTimer(id); } /** From fe3c266653b33850875eb764d83809de82a51d49 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:27:22 -0700 Subject: [PATCH 33/41] test(ui): add component testing, starting with the sidebar row Stand up component tests (jsdom plus @testing-library/svelte, with browser resolve conditions so components mount) and cover SidebarEntityRow's rename flow: select, context menu, commit on Enter, the empty-name error, and Escape to cancel. The existing store and helper tests run unchanged under the jsdom environment. --- package-lock.json | 814 ++++++++++++++++++++ package.json | 3 + src/lib/components/SidebarEntityRow.test.ts | 73 ++ vitest.config.ts | 5 +- 4 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/SidebarEntityRow.test.ts diff --git a/package-lock.json b/package-lock.json index c19814d..baeb15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,10 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", "@types/node": "^25.9.3", + "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", @@ -38,6 +41,119 @@ "node": ">=22" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -428,6 +544,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -870,6 +1126,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1833,6 +2107,110 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2010,6 +2388,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -2040,6 +2441,16 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2119,6 +2530,41 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2137,6 +2583,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2157,6 +2610,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -2164,6 +2627,26 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2291,6 +2774,36 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2308,6 +2821,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2332,6 +2886,26 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2342,6 +2916,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2388,6 +2979,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2454,6 +3058,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2468,6 +3104,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -2526,6 +3186,19 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -2579,6 +3252,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2651,6 +3337,13 @@ "typescript": ">=5.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2712,6 +3405,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -2722,6 +3435,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2736,6 +3475,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -2940,6 +3689,54 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2957,6 +3754,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index b085b88..2b72163 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,10 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", "@types/node": "^25.9.3", + "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", diff --git a/src/lib/components/SidebarEntityRow.test.ts b/src/lib/components/SidebarEntityRow.test.ts new file mode 100644 index 0000000..02c501c --- /dev/null +++ b/src/lib/components/SidebarEntityRow.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import SidebarEntityRow from "./SidebarEntityRow.svelte"; + +afterEach(cleanup); + +function base(overrides: Record = {}) { + return { + name: "Projects", + count: 3, + normalize: (s: string) => s.trim(), + noun: "Space", + active: false, + editing: false, + onSelect: vi.fn(), + onStartRename: vi.fn(), + onRename: vi.fn().mockResolvedValue({ ok: true }), + onDoneRename: vi.fn(), + onMenu: vi.fn(), + ...overrides, + }; +} + +describe("SidebarEntityRow", () => { + it("renders name (with prefix) and count, and selects on click", async () => { + const props = base({ prefix: "#", name: "idea", count: 5 }); + const { getByRole } = render(SidebarEntityRow, props); + const button = getByRole("button"); + expect(button.textContent).toContain("#idea"); + expect(button.textContent).toContain("5"); + await fireEvent.click(button); + expect(props.onSelect).toHaveBeenCalledTimes(1); + }); + + it("opens the context menu on right-click", async () => { + const props = base(); + const { getByRole } = render(SidebarEntityRow, props); + await fireEvent.contextMenu(getByRole("button")); + expect(props.onMenu).toHaveBeenCalledTimes(1); + }); + + it("commits a normalized new name on Enter and finishes editing", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: " new name " } }); + await fireEvent.keyDown(input, { key: "Enter" }); + expect(props.onRename).toHaveBeenCalledWith("new name"); + await waitFor(() => expect(props.onDoneRename).toHaveBeenCalledTimes(1)); + }); + + it("shows an error and does not call onRename when the name is empty", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole, findByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: " " } }); + await fireEvent.keyDown(input, { key: "Enter" }); + const alert = await findByRole("alert"); + expect(alert.textContent).toContain("Space name can't be empty"); + expect(props.onRename).not.toHaveBeenCalled(); + }); + + it("cancels on Escape without renaming", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "changed" } }); + await fireEvent.keyDown(input, { key: "Escape" }); + expect(props.onRename).not.toHaveBeenCalled(); + await waitFor(() => expect(props.onDoneRename).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d9133ee..a03cae6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,8 +6,11 @@ export default defineConfig({ // ($state, etc, otherwise a bare "$state is not defined" at test time) // and so the $lib alias resolves the way it does in the real app. plugins: [sveltekit()], + // Resolve Svelte's browser build so component tests can mount (jsdom). The + // pure-helper and store tests are unaffected: they run the same in jsdom. + resolve: { conditions: ["browser"] }, test: { include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], - environment: "node", + environment: "jsdom", }, }); From 28dd883cd9e7dbd8dd537b7eb756151e11bcb64c Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:45:48 -0700 Subject: [PATCH 34/41] refactor(library): extract the body-save queue into its own unit Move the debounce/retry/flush persistence machinery (the unsaved and failed maps, retry timers, and persistBody) out of the 916-line LibraryStore into a composed SaveQueue class. The store keeps a thin facade (editBody, saveState, flushPendingEdits) and injects the one coupling: applying a confirmed write to the open note. The save-queue tests pass unchanged. --- src/lib/stores/library.svelte.ts | 171 ++++---------------- src/lib/stores/library/save-queue.svelte.ts | 156 ++++++++++++++++++ 2 files changed, 190 insertions(+), 137 deletions(-) create mode 100644 src/lib/stores/library/save-queue.svelte.ts diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 11a0a0f..a4c1c87 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -37,32 +37,22 @@ import type { WorkspaceWithCount, } from "$lib/api/types"; import { debounce } from "$lib/debounce"; -import { - withMapEntry, - withoutMapKeys, - withSetEntry, - withoutSetEntries, -} from "$lib/reactive-collections"; import { friendlyMessage } from "$lib/errors"; import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; +import { SaveQueue, type SaveState } from "$lib/stores/library/save-queue.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; import { listen } from "@tauri-apps/api/event"; +export type { SaveState }; + // Archived and trash live behind a list filter in All Notes, not as // top-level sections (two-section library: All Notes and Workspaces). export type StatusFilter = "active" | "archived" | "trash"; -// One quiet retry this long after a failed body save; most failures (a -// competing writer briefly holding the database lock) clear well within it. -const SAVE_RETRY_MS = 2000; - // Debounce for search-text refreshes only, so a query runs per pause rather // than per keystroke; filter clicks and change events stay immediate. const SEARCH_DEBOUNCE_MS = 150; -/** Selected-note save status for the editor status bar. */ -export type SaveState = "saved" | "saving" | "failed"; - // A capture-born note that nobody has opened within this window is an open // loop worth resurfacing. Newer captures aren't nagged about: they're often // still in the user's head, and Revisit must never feel like a task manager. @@ -96,16 +86,21 @@ class LibraryStore { multiSelected = $state>(new Set()); error = $state(null); - // Bodies not yet confirmed persisted, by note id. An entry is only removed - // by a successful write, so a failed save stays queued for the next flush - // (note switch, blur, quit) instead of being silently dropped. Reassigned - // on change, like multiSelected, so the status bar tracks it reactively. - #unsaved = $state(new Map()); - // Note ids whose save failed even after the retry; drives "Not saved". - #failed = $state(new Set()); - // Scheduled 2s retry per note id, so a newer write, a drop, or a flush can - // cancel it before it fires a stray write behind the caller's back. - #retryTimers = new Map>(); + // Body persistence (debounce, retry, flush) lives in its own single-writer + // unit; the store composes one and delegates. A confirmed write updates the + // open note; a terminal failure surfaces an error. + #saveQueue = new SaveQueue({ + onPersisted: async (id, updated) => { + if (this.selected?.id === id) { + // Keep local body if the user kept typing past this save. + const localBody = this.selected.body; + this.selected = { ...updated, body: localBody }; + this.selectedTags = await tagsForNote(id); + } + this.error = null; + }, + onError: (e) => this.#fail(e), + }); #anchorId: string | null = null; #initialized = false; @@ -115,15 +110,10 @@ class LibraryStore { // events (bulk delete, undo) costs one count query, not one per event. #revisitCountDebounced = debounce(() => void this.#refreshRevisitCount(), 50); #searchRefresh = debounce(() => void this.refresh(), SEARCH_DEBOUNCE_MS); - #saveBody = debounce((id: string, body: string) => { - void this.#persistBody(id, body, true); - }, 400); /** Save status of the selected note, for the editor status bar. */ get saveState(): SaveState { - const id = this.selected?.id; - if (!id || !this.#unsaved.has(id)) return "saved"; - return this.#failed.has(id) ? "failed" : "saving"; + return this.#saveQueue.stateFor(this.selected?.id); } async init(): Promise { @@ -355,12 +345,12 @@ class LibraryStore { async #open(id: string): Promise { // Flush any pending edit of the previous note before switching. - this.#saveBody.flush(); + this.#saveQueue.flushDebounce(); try { const note = await getNote(id, true); // A queued edit (debounced or awaiting retry) is newer than what disk // returned; showing the disk body would fork the note's history. - const queued = this.#unsaved.get(id); + const queued = this.#saveQueue.peek(id); this.selected = queued !== undefined ? { ...note, body: queued } : note; [this.selectedTags, this.selectedWorkspaces] = await Promise.all([ tagsForNote(id), @@ -449,7 +439,7 @@ class LibraryStore { this.#lastRangeEnd = ids[0]; if (this.selected?.id !== ids[0]) await this.#open(ids[0]); } else { - this.#saveBody.flush(); + this.#saveQueue.flushDebounce(); this.selected = null; this.selectedTags = []; this.selectedWorkspaces = []; @@ -472,9 +462,9 @@ class LibraryStore { const ids = [...this.multiSelected]; // Trash is reversible and Undo promises fidelity: persist any pending // edit first, so a restored note holds the user's last keystrokes. - this.#saveBody.cancel(); - await this.#flushIds(ids); - this.#dropQueued(...ids); + this.#saveQueue.cancelDebounce(); + await this.#saveQueue.flushIds(ids); + this.#saveQueue.drop(ids); await this.#bulk((sel) => softDeleteNotes(sel)); this.clearMultiSelect(); if (ids.length > 0) { @@ -506,8 +496,8 @@ class LibraryStore { // Destroyed notes must also forget their queued edits, or the retry and // every later flush re-attempts a write against a row that no longer // exists and surfaces NOT_FOUND forever. - this.#saveBody.cancel(); - this.#dropQueued(...ids); + this.#saveQueue.cancelDebounce(); + this.#saveQueue.drop(ids); try { await destroyNotesCmd(ids, true); this.error = null; @@ -520,8 +510,8 @@ class LibraryStore { async emptyTrash(): Promise { try { const trashed = await listNotes({ isDeleted: true }); - this.#saveBody.cancel(); - this.#dropQueued(...trashed.map((n) => n.id)); + this.#saveQueue.cancelDebounce(); + this.#saveQueue.drop(trashed.map((n) => n.id)); await destroyNotesCmd( trashed.map((n) => n.id), true, @@ -688,8 +678,7 @@ class LibraryStore { // Optimistic local state; persistence is debounced. The note is dirty // from this moment until a write of this (or a newer) body succeeds. this.selected.body = body; - this.#unsaved = withMapEntry(this.#unsaved, this.selected.id, body); - this.#saveBody(this.selected.id, body); + this.#saveQueue.queue(this.selected.id, body); } editTitle(title: string): void { @@ -718,9 +707,9 @@ class LibraryStore { const id = this.selected.id; // Trash is reversible and Undo promises fidelity: persist any pending // edit first, so a restored note holds the user's last keystrokes. - this.#saveBody.cancel(); - await this.#flushIds([id]); - this.#dropQueued(id); + this.#saveQueue.cancelDebounce(); + await this.#saveQueue.flushIds([id]); + this.#saveQueue.drop([id]); try { await softDeleteNote(id); this.clearMultiSelect(); @@ -771,101 +760,9 @@ class LibraryStore { * Persist every queued edit now (note switch, window blur, export, quit). * Resolves once the writes have settled; anything that still fails stays * queued for the next flush. - * - * Cancels the debounce outright rather than flushing through it: flushing - * would run the retry-enabled path, which schedules its own 2s retry on - * failure and can fire a stray write after this call has already - * resolved. #unsaved already holds the latest body for every queued note - * (editBody sets it synchronously, ahead of the debounce), so a single - * no-retry persist below covers the just-typed edit too, with exactly one - * write attempt per note. */ async flushPendingEdits(): Promise { - this.#saveBody.cancel(); - await Promise.all( - [...this.#unsaved.entries()].map(([id, body]) => - this.#persistBody(id, body, false), - ), - ); - } - - /** - * Persist queued edits for specific ids now, no retry. Used ahead of a - * soft delete: the note survives in the trash, so the last keystrokes - * must land before the row leaves the list (Undo depends on them). - */ - async #flushIds(ids: string[]): Promise { - await Promise.all( - ids - .filter((id) => this.#unsaved.has(id)) - .map((id) => - this.#persistBody(id, this.#unsaved.get(id) as string, false), - ), - ); - } - - /** - * Write one note body. A failure retries once after a short backoff (state - * stays "saving", so the UI never claims "Saved" over unpersisted data); - * a second failure flips the note to "failed" while keeping the edit in - * #unsaved so a later flush still attempts it. - */ - async #persistBody( - id: string, - body: string, - canRetry: boolean, - ): Promise { - // Any write attempt for this id, whether from the debounce, a retry, or - // a flush, supersedes an outstanding scheduled retry for the same id. - this.#clearRetryTimer(id); - try { - const updated = await updateNote(id, { body }); - // Confirmed on disk. Clear the queue entry unless a newer edit - // superseded the body this write carried. - if (this.#unsaved.get(id) === body) { - this.#unsaved = withoutMapKeys(this.#unsaved, [id]); - } - if (this.#failed.has(id)) { - this.#failed = withoutSetEntries(this.#failed, [id]); - } - if (this.selected?.id === id) { - // Keep local body if user kept typing past this save. - const localBody = this.selected.body; - this.selected = { ...updated, body: localBody }; - this.selectedTags = await tagsForNote(id); - } - this.error = null; - } catch (e) { - if (canRetry) { - // One quiet retry: most failures (a competing writer briefly holding - // the database lock) clear well within the backoff. Tracked so a - // drop or a flush can cancel it before it fires. - const timer = setTimeout(() => { - this.#retryTimers.delete(id); - const latest = this.#unsaved.get(id); - if (latest !== undefined) void this.#persistBody(id, latest, false); - }, SAVE_RETRY_MS); - this.#retryTimers.set(id, timer); - } else { - this.#failed = withSetEntry(this.#failed, id); - this.#fail(e); - } - } - } - - #clearRetryTimer(id: string): void { - const timer = this.#retryTimers.get(id); - if (timer !== undefined) { - clearTimeout(timer); - this.#retryTimers.delete(id); - } - } - - /** Forget queued edits for notes that are being discarded. */ - #dropQueued(...ids: string[]): void { - this.#unsaved = withoutMapKeys(this.#unsaved, ids); - this.#failed = withoutSetEntries(this.#failed, ids); - for (const id of ids) this.#clearRetryTimer(id); + await this.#saveQueue.flushAll(); } /** diff --git a/src/lib/stores/library/save-queue.svelte.ts b/src/lib/stores/library/save-queue.svelte.ts new file mode 100644 index 0000000..5013344 --- /dev/null +++ b/src/lib/stores/library/save-queue.svelte.ts @@ -0,0 +1,156 @@ +// The library's body-save queue: debounced writes, one quiet retry, and +// flush-on-switch/blur/quit, kept as a single-writer unit apart from the rest +// of the store. It owns every "is this note persisted" decision; the store +// composes one instance and delegates. +// +// The only outward coupling is the open note: a confirmed write updates it, and +// a terminal failure surfaces an error. Both arrive via injected callbacks so +// this class never reaches back into the store's selection or filter state. + +import { debounce } from "$lib/debounce"; +import { + withMapEntry, + withoutMapKeys, + withSetEntry, + withoutSetEntries, +} from "$lib/reactive-collections"; +import { updateNote } from "$lib/api/client"; +import type { Note } from "$lib/api/types"; + +/** Selected-note save status for the editor status bar. */ +export type SaveState = "saved" | "saving" | "failed"; + +// One quiet retry this long after a failed body save; most failures (a +// competing writer briefly holding the database lock) clear well within it. +const SAVE_RETRY_MS = 2000; + +export interface SaveQueueDeps { + /** Apply a confirmed write. Called for every successful persist so the store + * can refresh the open note (and clear its error) when it is the one saved. */ + onPersisted: (id: string, updated: Note) => Promise | void; + /** Surface a terminal failure (after the single retry) to the store. */ + onError: (e: unknown) => void; +} + +export class SaveQueue { + // Bodies not yet confirmed persisted, by note id. An entry is only removed by + // a successful write, so a failed save stays queued for the next flush (note + // switch, blur, quit) instead of being silently dropped. Reassigned on change + // so the status bar tracks it reactively. + #unsaved = $state(new Map()); + // Note ids whose save failed even after the retry; drives "Not saved". + #failed = $state(new Set()); + // Scheduled retry per note id, so a newer write, a drop, or a flush can + // cancel it before it fires a stray write behind the caller's back. + #retryTimers = new Map>(); + + #debounced = debounce((id: string, body: string) => { + void this.#persist(id, body, true); + }, 400); + + #deps: SaveQueueDeps; + + constructor(deps: SaveQueueDeps) { + this.#deps = deps; + } + + /** The queued (freshest) body for an id, or undefined; lets the opener show + * it instead of the disk copy, which would fork the note's history. */ + peek(id: string): string | undefined { + return this.#unsaved.get(id); + } + + /** Save status of one note id, for the editor status bar. */ + stateFor(id: string | undefined): SaveState { + if (!id || !this.#unsaved.has(id)) return "saved"; + return this.#failed.has(id) ? "failed" : "saving"; + } + + /** Queue an optimistic edit; the write is debounced (400ms). */ + queue(id: string, body: string): void { + this.#unsaved = withMapEntry(this.#unsaved, id, body); + this.#debounced(id, body); + } + + /** Run the pending debounced write now, e.g. before switching notes. */ + flushDebounce(): void { + this.#debounced.flush(); + } + + /** + * Persist every queued edit now, no retry (note switch, window blur, export, + * quit). Cancels the debounce rather than flushing it: the retry-enabled path + * could otherwise fire a stray write after this resolves. #unsaved already + * holds the latest body for every note, so one no-retry write per note covers + * the just-typed edit too. + */ + async flushAll(): Promise { + this.#debounced.cancel(); + await Promise.all( + [...this.#unsaved.entries()].map(([id, body]) => + this.#persist(id, body, false), + ), + ); + } + + /** Persist queued edits for specific ids now, no retry (before a soft delete, + * so a restored note keeps its last keystrokes). */ + async flushIds(ids: string[]): Promise { + await Promise.all( + ids + .filter((id) => this.#unsaved.has(id)) + .map((id) => this.#persist(id, this.#unsaved.get(id) as string, false)), + ); + } + + /** Cancel the debounce ahead of a delete path that will drop the ids. */ + cancelDebounce(): void { + this.#debounced.cancel(); + } + + /** Forget queued edits for notes being discarded, so no later flush or retry + * writes against a row that no longer exists. */ + drop(ids: string[]): void { + this.#unsaved = withoutMapKeys(this.#unsaved, ids); + this.#failed = withoutSetEntries(this.#failed, ids); + for (const id of ids) this.#clearRetryTimer(id); + } + + async #persist(id: string, body: string, canRetry: boolean): Promise { + // Any write attempt for this id, whether from the debounce, a retry, or a + // flush, supersedes an outstanding scheduled retry for the same id. + this.#clearRetryTimer(id); + try { + const updated = await updateNote(id, { body }); + // Confirmed on disk. Clear the queue entry unless a newer edit superseded + // the body this write carried. + if (this.#unsaved.get(id) === body) { + this.#unsaved = withoutMapKeys(this.#unsaved, [id]); + } + if (this.#failed.has(id)) { + this.#failed = withoutSetEntries(this.#failed, [id]); + } + await this.#deps.onPersisted(id, updated); + } catch (e) { + if (canRetry) { + const timer = setTimeout(() => { + this.#retryTimers.delete(id); + const latest = this.#unsaved.get(id); + if (latest !== undefined) void this.#persist(id, latest, false); + }, SAVE_RETRY_MS); + this.#retryTimers.set(id, timer); + } else { + this.#failed = withSetEntry(this.#failed, id); + this.#deps.onError(e); + } + } + } + + #clearRetryTimer(id: string): void { + const timer = this.#retryTimers.get(id); + if (timer !== undefined) { + clearTimeout(timer); + this.#retryTimers.delete(id); + } + } +} From ff8f3bd04316431842a102d86b5d3000cd2ec53f Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:47:55 -0700 Subject: [PATCH 35/41] refactor(library): extract the list selection into its own model Move the checkbox multi-selection (the id set, its anchor and active-end cursor, and the toggle/range/step logic) out of the LibraryStore into a composed SelectionModel that takes the visible-id list as a dependency. The store keeps the open-note state and the editor-sync orchestration. Persistence and selection now live apart from the store's core query engine; all 269 frontend tests pass. --- src/lib/stores/library.svelte.ts | 50 ++++++--------- src/lib/stores/library/selection.svelte.ts | 71 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 src/lib/stores/library/selection.svelte.ts diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index a4c1c87..33fe667 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -38,8 +38,8 @@ import type { } from "$lib/api/types"; import { debounce } from "$lib/debounce"; import { friendlyMessage } from "$lib/errors"; -import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; import { SaveQueue, type SaveState } from "$lib/stores/library/save-queue.svelte"; +import { SelectionModel } from "$lib/stores/library/selection.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; import { listen } from "@tauri-apps/api/event"; @@ -80,12 +80,17 @@ class LibraryStore { selected = $state(null); selectedTags = $state([]); selectedWorkspaces = $state([]); - // Ids checked for bulk actions. Holds the open note's id on a plain click; - // grows via cmd-click / shift-click. Size > 1 swaps the editor for the - // bulk-actions panel. - multiSelected = $state>(new Set()); error = $state(null); + // Ids checked for bulk actions (the open note's id on a plain click; grows + // via cmd-click / shift-click). Size > 1 swaps the editor for the bulk panel. + // The set and its anchor/cursor live in a composed model; the store keeps the + // open-note state and the editor-sync orchestration. + #selection = new SelectionModel(() => this.visibleIds); + get multiSelected(): ReadonlySet { + return this.#selection.ids; + } + // Body persistence (debounce, retry, flush) lives in its own single-writer // unit; the store composes one and delegates. A confirmed write updates the // open note; a terminal failure surfaces an error. @@ -102,7 +107,6 @@ class LibraryStore { onError: (e) => this.#fail(e), }); - #anchorId: string | null = null; #initialized = false; #refreshDebounced = debounce(() => void this.refresh(), 50); @@ -321,11 +325,8 @@ class LibraryStore { setSearch(text: string): void { this.searchText = text; // Reset the multi-selection but keep the open note in the editor. - this.multiSelected = this.selected - ? new Set([this.selected.id]) - : new Set(); - this.#anchorId = this.selected?.id ?? null; - this.#lastRangeEnd = this.#anchorId; + const openId = this.selected?.id ?? null; + this.#selection.reset(openId ? [openId] : [], openId); if (text.trim()) { this.#searchRefresh(); } else { @@ -337,9 +338,7 @@ class LibraryStore { } async select(id: string): Promise { - this.multiSelected = new Set([id]); - this.#anchorId = id; - this.#lastRangeEnd = id; + this.#selection.reset([id], id); await this.#open(id); } @@ -385,20 +384,17 @@ class LibraryStore { } async toggleInSelection(id: string): Promise { - this.multiSelected = toggleSelection(this.multiSelected, id); - this.#anchorId = id; - this.#lastRangeEnd = id; + this.#selection.toggle(id); await this.#syncEditorToSelection(); } async extendSelectionTo(id: string): Promise { - this.multiSelected = rangeSelection(this.visibleIds, this.#anchorId, id); - this.#lastRangeEnd = id; + this.#selection.extendTo(id); await this.#syncEditorToSelection(); } async selectAllVisible(): Promise { - this.multiSelected = new Set(this.visibleIds); + this.#selection.selectAll(); await this.#syncEditorToSelection(); } @@ -407,8 +403,7 @@ class LibraryStore { * Returns the id the selection moved to so the view can reveal it. */ async moveSelection(delta: number, extend = false): Promise { - const current = this.#lastRangeEnd ?? this.selected?.id ?? this.#anchorId; - const next = stepId(this.visibleIds, current, delta); + const next = this.#selection.step(delta, this.selected?.id ?? null); if (!next) return null; if (extend) { await this.extendSelectionTo(next); @@ -418,14 +413,8 @@ class LibraryStore { return next; } - // Active end of the selection: the last row clicked, toggled, or stepped to. - // Shift+arrow continues from here rather than from the anchor. - #lastRangeEnd: string | null = null; - clearMultiSelect(): void { - this.multiSelected = new Set(); - this.#anchorId = null; - this.#lastRangeEnd = null; + this.#selection.clear(); this.selected = null; this.selectedTags = []; this.selectedWorkspaces = []; @@ -435,8 +424,7 @@ class LibraryStore { async #syncEditorToSelection(): Promise { const ids = [...this.multiSelected]; if (ids.length === 1) { - this.#anchorId = ids[0]; - this.#lastRangeEnd = ids[0]; + this.#selection.setActive(ids[0]); if (this.selected?.id !== ids[0]) await this.#open(ids[0]); } else { this.#saveQueue.flushDebounce(); diff --git a/src/lib/stores/library/selection.svelte.ts b/src/lib/stores/library/selection.svelte.ts new file mode 100644 index 0000000..14faa03 --- /dev/null +++ b/src/lib/stores/library/selection.svelte.ts @@ -0,0 +1,71 @@ +// The list multi-selection: which note ids are checked, plus the anchor and +// active end that shift-range and arrow-step read from. The open note itself +// (selected / selectedTags / selectedWorkspaces) stays in the store; this owns +// only the checkbox set and its navigation cursor. +// +// The current visible id list is injected (it depends on the store's filter / +// search results), so this class never reaches into query state. + +import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; + +export class SelectionModel { + ids = $state>(new Set()); + + // The row a range grows from, and the last row acted on. Shift+arrow + // continues from the active end rather than from the anchor. + #anchor: string | null = null; + #activeEnd: string | null = null; + + #visibleIds: () => string[]; + + constructor(visibleIds: () => string[]) { + this.#visibleIds = visibleIds; + } + + has(id: string): boolean { + return this.ids.has(id); + } + + get anchor(): string | null { + return this.#anchor; + } + + /** Replace the selection with exactly these ids, anchored at `anchor`. */ + reset(ids: string[], anchor: string | null): void { + this.ids = new Set(ids); + this.#anchor = anchor; + this.#activeEnd = anchor; + } + + clear(): void { + this.reset([], null); + } + + toggle(id: string): void { + this.ids = toggleSelection(this.ids, id); + this.#anchor = id; + this.#activeEnd = id; + } + + extendTo(id: string): void { + this.ids = rangeSelection(this.#visibleIds(), this.#anchor, id); + this.#activeEnd = id; + } + + selectAll(): void { + this.ids = new Set(this.#visibleIds()); + } + + /** Next id when stepping by `delta` from the active end (falling back to the + * open note, then the anchor); null at the edge of the visible list. */ + step(delta: number, fallback: string | null): string | null { + const current = this.#activeEnd ?? fallback ?? this.#anchor; + return stepId(this.#visibleIds(), current, delta); + } + + /** Pin both cursor ends to one id (a single row became the selection). */ + setActive(id: string): void { + this.#anchor = id; + this.#activeEnd = id; + } +} From 3174a3d2b6b1426e8950e9db249a0f9ab57f9495 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:57:37 -0700 Subject: [PATCH 36/41] refactor(shell): extract the desktop shell out of lib.rs Move window management, capture-latency metrics, the file I/O commands (theme, note export, attachments), and the quit handshake out of the 970-line lib.rs into shell/{capture,windows,files,quit}.rs. lib.rs keeps the shared IPC types and run() with its native menu, tray, and setup wiring, down from 970 to 445 lines. Behavior is unchanged; the moved unit tests travel with their code, and a small mark_shown method replaces the one place a window helper reached into the capture metrics' internals. --- src-tauri/src/lib.rs | 540 +-------------------------------- src-tauri/src/shell/capture.rs | 112 +++++++ src-tauri/src/shell/files.rs | 183 +++++++++++ src-tauri/src/shell/mod.rs | 8 + src-tauri/src/shell/quit.rs | 43 +++ src-tauri/src/shell/windows.rs | 202 ++++++++++++ 6 files changed, 554 insertions(+), 534 deletions(-) create mode 100644 src-tauri/src/shell/capture.rs create mode 100644 src-tauri/src/shell/files.rs create mode 100644 src-tauri/src/shell/mod.rs create mode 100644 src-tauri/src/shell/quit.rs create mode 100644 src-tauri/src/shell/windows.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0fc3e36..999c4bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ use instantnotes_core::types::*; use instantnotes_core::{AppError, Store}; use serde::Serialize; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::Ordering; use std::sync::Mutex; use tauri::menu::{Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder}; use tauri::tray::TrayIconBuilder; @@ -17,51 +17,6 @@ struct AppState { store: Mutex, } -// ---- capture latency metrics ---- -// "Capture is discharge" only holds if the panel is ready before the thought -// decays, so reveal-to-input-ready is tracked as a first-class number. The -// anchor is the moment the shell starts revealing the window: the earliest -// point we control (the OS delivers no timestamp for the hotkey press). -// Note content is never involved here. - -/// Rolling window; enough for a stable median, small enough to forget history. -const CAPTURE_SAMPLE_CAP: usize = 50; - -#[derive(Default)] -struct CaptureMetrics { - inner: Mutex, -} - -#[derive(Default)] -struct CaptureMetricsInner { - shown_at: Option, - samples_ms: Vec, -} - -#[derive(Serialize, Debug, PartialEq)] -#[serde(rename_all = "camelCase")] -struct CaptureLatencySummary { - last_ms: Option, - median_ms: Option, - samples: usize, -} - -fn push_capture_sample(samples: &mut Vec, ms: u64) { - samples.push(ms); - if samples.len() > CAPTURE_SAMPLE_CAP { - samples.remove(0); - } -} - -fn median_ms(samples: &[u64]) -> Option { - if samples.is_empty() { - return None; - } - let mut sorted = samples.to_vec(); - sorted.sort_unstable(); - Some(sorted[sorted.len() / 2]) -} - /// Serializable error per API.md §3.6 / §11. #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] @@ -102,430 +57,20 @@ fn emit_workspaces_changed(app: &AppHandle) { let _ = app.emit("workspaces:changed", ()); } - -mod commands; -use commands::{notes::*, settings::*, tags::*, workspaces::*}; - -// ---- capture latency commands ---- - -/// Called by the capture webview once its textarea has focus after a -/// reveal (post-paint). Consumes the pending stamp so a stray call can -/// never double-record; returns the measured reveal-to-ready milliseconds. -#[tauri::command] -fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult> { - let mut inner = metrics.inner.lock().map_err(|_| CmdError { - code: "STORAGE_ERROR".into(), - message: "internal state lock poisoned".into(), - })?; - let Some(shown) = inner.shown_at.take() else { - return Ok(None); - }; - let ms = shown.elapsed().as_millis() as u64; - push_capture_sample(&mut inner.samples_ms, ms); - Ok(Some(ms)) -} - -#[tauri::command] -fn get_capture_latency(metrics: State<'_, CaptureMetrics>) -> CmdResult { - let inner = metrics.inner.lock().map_err(|_| CmdError { - code: "STORAGE_ERROR".into(), - message: "internal state lock poisoned".into(), - })?; - Ok(CaptureLatencySummary { - last_ms: inner.samples_ms.last().copied(), - median_ms: median_ms(&inner.samples_ms), - samples: inner.samples_ms.len(), - }) -} - -// ---- window commands ---- - -// Window show/hide is instant and main-thread-friendly, so these stay synchronous -// (unlike the data/IO commands, which run async to keep off the UI thread). -#[tauri::command] -fn hide_capture(app: AppHandle) { - hide_capture_window(&app); -} - -#[tauri::command] -fn open_library(app: AppHandle) { - show_library_window(&app); -} - -/// Apply a native macOS vibrancy material to the library window, or clear it when -/// `material` is None/unknown. Vibrancy is the closest a webview app gets to the -/// Tahoe "Liquid Glass" look; it requires the always-transparent window and a -/// translucent surface above it (the theme's sidebar token). A no-op off macOS. -#[tauri::command] -fn set_window_vibrancy(app: AppHandle, material: Option) { - #[cfg(target_os = "macos")] - { - use window_vibrancy::{apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial}; - let Some(win) = app.get_webview_window("library") else { - return; - }; - // Any known material applies; None or an unknown string clears. - let chosen = material.as_deref().and_then(|m| match m { - "sidebar" => Some(NSVisualEffectMaterial::Sidebar), - "under-window" => Some(NSVisualEffectMaterial::UnderWindowBackground), - "header" => Some(NSVisualEffectMaterial::HeaderView), - "menu" => Some(NSVisualEffectMaterial::Menu), - "popover" => Some(NSVisualEffectMaterial::Popover), - "hud" => Some(NSVisualEffectMaterial::HudWindow), - _ => None, - }); - match chosen { - Some(m) => { - let _ = apply_vibrancy(&win, m, None, None); - } - None => { - let _ = clear_vibrancy(&win); - } - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = (app, material); - } -} - -/// Match the native library window's theme (titlebar and traffic-light treatment) -/// to the in-app light/dark variant. Tauri maps this to the window's OS appearance, -/// so the chrome follows the active theme instead of the launch-time system setting. -/// An unknown variant is a no-op; the borderless capture window has no native chrome -/// and is left alone. -#[tauri::command] -fn set_window_theme(app: AppHandle, variant: String) { - use tauri::Theme; - let theme = match variant.as_str() { - "light" => Theme::Light, - "dark" => Theme::Dark, - _ => return, - }; - if let Some(win) = app.get_webview_window("library") { - let _ = win.set_theme(Some(theme)); - } -} - -// ---- theme file sharing ---- -// Thin byte I/O for portable `.intheme.json` theme files. The open/save dialog -// runs in JS via the dialog plugin; Rust only reads/writes the chosen path, so -// no broad filesystem capability is needed. Validation happens in the webview -// before any token is applied. - -/// Reject anything that isn't an absolute path to a `.json` file. The path is -/// chosen by the user through a native save/open dialog but arrives here from the -/// webview, so this guard keeps the command from becoming a way to read or write -/// arbitrary files anywhere on disk. -fn validate_theme_path(path: &str) -> CmdResult<()> { - let p = std::path::Path::new(path); - if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme path must be absolute".into(), - }); - } - let is_json = p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("json")); - if !is_json { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme file must have a .json extension".into(), - }); - } - Ok(()) -} - -#[tauri::command(async)] -fn export_theme_file(path: String, contents: String) -> CmdResult<()> { - validate_theme_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write theme file: {e}"), - }) -} - -#[tauri::command(async)] -fn import_theme_file(path: String) -> CmdResult { - validate_theme_path(&path)?; - std::fs::read_to_string(&path).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not read theme file: {e}"), - }) -} - -// ---- note export ---- - -fn validate_export_path(path: &str) -> CmdResult<()> { - let p = std::path::Path::new(path); - if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export path must be absolute".into(), - }); - } - let is_allowed = p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt")); - if !is_allowed { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export file must have a .md or .txt extension".into(), - }); - } - Ok(()) -} - -#[tauri::command(async)] -fn export_note_file(path: String, contents: String) -> CmdResult<()> { - validate_export_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write export file: {e}"), - }) -} - -// ---- attachments ---- -// Pasted/dropped images live as files under /attachments and notes -// reference them by relative `attachments/` markdown paths, so exported -// markdown stays portable and the DB stays lean. The webview reads them back -// through the asset protocol (scoped to this directory in tauri.conf.json). - -const ATTACHMENT_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; - -fn attachments_dir(app: &AppHandle) -> CmdResult { - let dir = app - .path() - .app_data_dir() - .map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("no app data dir: {e}"), - })? - .join("attachments"); - std::fs::create_dir_all(&dir).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not create attachments dir: {e}"), - })?; - Ok(dir) -} - -#[tauri::command(async)] -fn get_attachments_dir(app: AppHandle) -> CmdResult { - Ok(attachments_dir(&app)?.to_string_lossy().into_owned()) -} - -/// Store one image. The body is the raw bytes (not JSON) so a screenshot paste -/// doesn't pay for number-array serialization; the extension rides in a header. -/// Returns the generated filename; the caller builds `attachments/`. -#[tauri::command(async)] -fn save_attachment(app: AppHandle, request: tauri::ipc::Request<'_>) -> CmdResult { - let ext = request - .headers() - .get("x-attachment-ext") - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !ATTACHMENT_EXTS.contains(&ext.as_str()) { - return Err(CmdError { - code: "VALIDATION".into(), - message: format!("unsupported attachment type: {ext:?}"), - }); - } - let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { - return Err(CmdError { - code: "VALIDATION".into(), - message: "attachment body must be raw bytes".into(), - }); - }; - if bytes.is_empty() { - return Err(CmdError { - code: "VALIDATION".into(), - message: "attachment is empty".into(), - }); - } - let name = format!("{}.{ext}", uuid::Uuid::new_v4()); - let path = attachments_dir(&app)?.join(&name); - std::fs::write(&path, bytes).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write attachment: {e}"), - })?; - Ok(name) -} - -const REPO_URL: &str = "https://github.com/Jam-Sw/InstantNotes"; - -fn open_data_folder(app: &AppHandle) { - if let Ok(dir) = app.path().app_data_dir() { - // Via the opener plugin rather than a raw `open` subprocess, so it stays - // on Tauri's permission-checked path. Called only from Rust with our own - // data directory - never a webview-supplied path. - let _ = app.opener().open_path(dir.to_string_lossy(), None::<&str>); - } -} - -// ---- icon cache refresh ---- -// macOS caches an app's icon per bundle path (LaunchServices + iconservicesd). -// The in-app updater swaps the bundle in place at the same path and identifier, -// so without a nudge the Dock/Finder keep showing the icon cached for the old -// build. We record the version that last launched and, when it changes, ask -// macOS to re-read the bundle once. - -/// True when the icon cache should be refreshed: the recorded last-launched -/// version is missing (pre-marker install or first launch) or differs from the -/// running version. Pure so it can be unit-tested without a real bundle. -fn icon_refresh_needed(previous: Option<&str>, current: &str) -> bool { - previous != Some(current) -} - -/// Record the running version next to the database and, when it changed since -/// the last launch, refresh the macOS icon cache. A no-op on the happy path -/// (same version) and in dev builds (no `.app` bundle). -fn refresh_icon_cache_if_updated(data_dir: &std::path::Path) { - let current = env!("CARGO_PKG_VERSION"); - let marker = data_dir.join(".last_version"); - let stored = std::fs::read_to_string(&marker).ok(); - let previous = stored.as_deref().map(str::trim); - if !icon_refresh_needed(previous, current) { - return; - } - let _ = std::fs::write(&marker, current); - #[cfg(target_os = "macos")] - if let Some(bundle) = current_app_bundle() { - refresh_macos_icon(bundle); - } -} - -/// Path to the running `.app` bundle, or `None` in a dev build where the -/// executable is not inside a `*.app/Contents/MacOS/` layout. -#[cfg(target_os = "macos")] -fn current_app_bundle() -> Option { - let exe = std::env::current_exe().ok()?; - let bundle = exe.parent()?.parent()?.parent()?; // MacOS -> Contents -> .app - if bundle.extension()?.to_str()? == "app" { - Some(bundle.to_path_buf()) - } else { - None - } -} - -/// Nudge macOS to re-read the bundle's icon: re-register with LaunchServices, -/// bump the bundle mtime (part of the icon cache key), then relaunch the Dock. -/// Runs off the main thread; every step is best-effort. -#[cfg(target_os = "macos")] -fn refresh_macos_icon(bundle: std::path::PathBuf) { - std::thread::spawn(move || { - const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; - let _ = std::process::Command::new(LSREGISTER) - .arg("-f") - .arg(&bundle) - .status(); - let _ = std::process::Command::new("touch").arg(&bundle).status(); - let _ = std::process::Command::new("killall").arg("Dock").status(); - }); -} - -// ---- window helpers ---- - -fn show_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - // Stamp before any window work so the sample covers the whole reveal. - if let Some(metrics) = app.try_state::() { - if let Ok(mut inner) = metrics.inner.lock() { - inner.shown_at = Some(std::time::Instant::now()); - } - } - let _ = w.center(); - let _ = w.show(); - let _ = w.set_focus(); - // Frontend focuses the textarea and restores any preserved draft. - let _ = w.emit("capture:shown", ()); - } -} - -fn hide_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - let _ = w.hide(); - } -} - -fn toggle_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - if w.is_visible().unwrap_or(false) { - hide_capture_window(app); - } else { - show_capture_window(app); - } - } -} - -fn show_library_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("library") { - let _ = w.show(); - let _ = w.set_focus(); - } -} - -// ---- utility commands ---- - -#[tauri::command] -fn open_url(app: AppHandle, url: String) { - let _ = app.opener().open_url(&url, None::<&str>); -} - -// ---- quit handshake ---- -// Body edits are debounced in the webview, so exiting the process directly -// would drop the tail of whatever was just typed. Every quit path (menu, tray, -// Dock) instead emits "app:quit-requested"; the library window flushes its -// pending edits and answers with the quit_app command, which really exits. - -/// True once the frontend flushed and called quit_app, or once the fallback -/// gave up waiting. ExitRequested lets the exit proceed only when this is set, -/// so the flush handshake runs at most once per quit. -static QUIT_READY: AtomicBool = AtomicBool::new(false); - -/// How long a quit waits for the webview flush before exiting anyway. -const QUIT_FLUSH_GRACE_MS: u64 = 800; - -/// Ask the webviews to flush, then exit. The fallback timer exists because -/// quit must not block forever on a dead webview: if the frontend never -/// answers with quit_app, exit anyway after the grace period. -fn request_quit(app: &AppHandle) { - let _ = app.emit("app:quit-requested", ()); - let handle = app.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(QUIT_FLUSH_GRACE_MS)); - // swap keeps the fallback and quit_app from racing: whichever runs - // first marks the handshake done and the other becomes a no-op. - if !QUIT_READY.swap(true, Ordering::AcqRel) { - handle.exit(0); - } - }); -} - -/// Final leg of the handshake: the library webview has flushed pending edits. -#[tauri::command] -fn quit_app(app: AppHandle) { - QUIT_READY.store(true, Ordering::Release); - app.exit(0); -} - // ---- shortcut status ---- /// Set once at startup when global-shortcut registration failed (another app /// owns the hotkey). Queryable because the "shortcut:failed" event fires /// before the library webview has listeners attached, so an event alone /// would be lost. -struct ShortcutStatus { +pub(crate) struct ShortcutStatus { failed: Option, } -#[tauri::command] -fn get_shortcut_failure(state: State<'_, ShortcutStatus>) -> Option { - state.failed.clone() -} +mod commands; +mod shell; +use commands::{notes::*, settings::*, tags::*, workspaces::*}; +use shell::{capture::*, files::*, quit::*, windows::*}; // ---- app shell ---- @@ -898,76 +443,3 @@ pub fn run() { } }); } - -#[cfg(test)] -mod tests { - use super::{ - export_theme_file, icon_refresh_needed, import_theme_file, median_ms, push_capture_sample, - CAPTURE_SAMPLE_CAP, - }; - - #[test] - fn capture_samples_roll_over_at_the_cap() { - let mut samples = Vec::new(); - for ms in 0..(CAPTURE_SAMPLE_CAP as u64 + 10) { - push_capture_sample(&mut samples, ms); - } - assert_eq!(samples.len(), CAPTURE_SAMPLE_CAP); - // Oldest entries were evicted; the newest survives. - assert_eq!(samples.first().copied(), Some(10)); - assert_eq!(samples.last().copied(), Some(CAPTURE_SAMPLE_CAP as u64 + 9)); - } - - #[test] - fn median_is_none_when_empty_and_stable_against_outliers() { - assert_eq!(median_ms(&[]), None); - assert_eq!(median_ms(&[40]), Some(40)); - // One slow cold start must not drag the reported number. - assert_eq!(median_ms(&[35, 38, 40, 42, 900]), Some(40)); - // Input order is irrelevant. - assert_eq!(median_ms(&[900, 40, 35, 42, 38]), Some(40)); - } - - #[test] - fn icon_refresh_when_version_changed_or_unknown() { - // First launch / upgrade from a build that never wrote the marker. - assert!(icon_refresh_needed(None, "0.5.3")); - // In-place update from an older recorded version. - assert!(icon_refresh_needed(Some("0.5.2"), "0.5.3")); - // Same version relaunch: nothing to refresh. - assert!(!icon_refresh_needed(Some("0.5.3"), "0.5.3")); - } - - #[test] - fn theme_file_round_trip() { - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "instantnotes-theme-{}.intheme.json", - std::process::id() - )); - let path_str = path.to_string_lossy().to_string(); - let json = r#"{"id":"x","name":"X","version":1}"#.to_string(); - - export_theme_file(path_str.clone(), json.clone()).expect("write"); - let read_back = import_theme_file(path_str.clone()).expect("read"); - assert_eq!(read_back, json); - - let _ = std::fs::remove_file(&path); - } - - #[test] - fn import_missing_file_errors() { - let res = import_theme_file("/nonexistent/path/theme.intheme.json".into()); - assert!(res.is_err()); - assert_eq!(res.unwrap_err().code, "STORAGE_ERROR"); - } - - #[test] - fn theme_path_validation_rejects_non_absolute_and_non_json() { - // Relative path → rejected before any filesystem access. - assert!(export_theme_file("relative/theme.json".into(), "{}".into()).is_err()); - assert!(import_theme_file("relative/theme.json".into()).is_err()); - // Absolute but not a .json file → rejected. - assert!(import_theme_file("/tmp/not-a-theme.txt".into()).is_err()); - } -} diff --git a/src-tauri/src/shell/capture.rs b/src-tauri/src/shell/capture.rs new file mode 100644 index 0000000..5d528d5 --- /dev/null +++ b/src-tauri/src/shell/capture.rs @@ -0,0 +1,112 @@ +//! Capture reveal-to-ready latency: a rolling median surfaced in the About +//! panel. "Capture is discharge" only holds if the panel is ready before the +//! thought decays, so reveal-to-input-ready is tracked as a first-class number. +//! The anchor is the moment the shell starts revealing the window (the earliest +//! point we control; the OS delivers no hotkey-press timestamp). Note content is +//! never involved here. + +use crate::*; + +/// Rolling window; enough for a stable median, small enough to forget history. +const CAPTURE_SAMPLE_CAP: usize = 50; + +#[derive(Default)] +pub(crate) struct CaptureMetrics { + inner: Mutex, +} + +#[derive(Default)] +struct CaptureMetricsInner { + shown_at: Option, + samples_ms: Vec, +} + +impl CaptureMetrics { + /// Stamp the reveal start; the next capture_input_ready measures against it. + pub(crate) fn mark_shown(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.shown_at = Some(std::time::Instant::now()); + } + } +} + +#[derive(Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureLatencySummary { + last_ms: Option, + median_ms: Option, + samples: usize, +} + +fn push_capture_sample(samples: &mut Vec, ms: u64) { + samples.push(ms); + if samples.len() > CAPTURE_SAMPLE_CAP { + samples.remove(0); + } +} + +fn median_ms(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + Some(sorted[sorted.len() / 2]) +} + +/// Called by the capture webview once its textarea has focus after a +/// reveal (post-paint). Consumes the pending stamp so a stray call can +/// never double-record; returns the measured reveal-to-ready milliseconds. +#[tauri::command] +pub fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult> { + let mut inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + let Some(shown) = inner.shown_at.take() else { + return Ok(None); + }; + let ms = shown.elapsed().as_millis() as u64; + push_capture_sample(&mut inner.samples_ms, ms); + Ok(Some(ms)) +} + +#[tauri::command] +pub fn get_capture_latency(metrics: State<'_, CaptureMetrics>) -> CmdResult { + let inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + Ok(CaptureLatencySummary { + last_ms: inner.samples_ms.last().copied(), + median_ms: median_ms(&inner.samples_ms), + samples: inner.samples_ms.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::{median_ms, push_capture_sample, CAPTURE_SAMPLE_CAP}; + + #[test] + fn capture_samples_roll_over_at_the_cap() { + let mut samples = Vec::new(); + for ms in 0..(CAPTURE_SAMPLE_CAP as u64 + 10) { + push_capture_sample(&mut samples, ms); + } + assert_eq!(samples.len(), CAPTURE_SAMPLE_CAP); + // Oldest entries were evicted; the newest survives. + assert_eq!(samples.first().copied(), Some(10)); + assert_eq!(samples.last().copied(), Some(CAPTURE_SAMPLE_CAP as u64 + 9)); + } + + #[test] + fn median_is_none_when_empty_and_stable_against_outliers() { + assert_eq!(median_ms(&[]), None); + assert_eq!(median_ms(&[40]), Some(40)); + // One slow cold start must not drag the reported number. + assert_eq!(median_ms(&[35, 38, 40, 42, 900]), Some(40)); + // Input order is irrelevant. + assert_eq!(median_ms(&[900, 40, 35, 42, 38]), Some(40)); + } +} diff --git a/src-tauri/src/shell/files.rs b/src-tauri/src/shell/files.rs new file mode 100644 index 0000000..9a02c7f --- /dev/null +++ b/src-tauri/src/shell/files.rs @@ -0,0 +1,183 @@ +//! Path-validated byte I/O for paths the user picks through native dialogs: +//! portable `.intheme.json` themes, note export, and pasted/dropped image +//! attachments. The dialogs run in JS; Rust only reads/writes the chosen path, +//! so no broad filesystem capability is needed. + +use crate::*; + +/// Reject anything that isn't an absolute path to a `.json` file. The path is +/// chosen by the user through a native save/open dialog but arrives here from the +/// webview, so this guard keeps the command from becoming a way to read or write +/// arbitrary files anywhere on disk. +fn validate_theme_path(path: &str) -> CmdResult<()> { + let p = std::path::Path::new(path); + if !p.is_absolute() { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "theme path must be absolute".into(), + }); + } + let is_json = p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("json")); + if !is_json { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "theme file must have a .json extension".into(), + }); + } + Ok(()) +} + +#[tauri::command(async)] +pub fn export_theme_file(path: String, contents: String) -> CmdResult<()> { + validate_theme_path(&path)?; + std::fs::write(&path, contents).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write theme file: {e}"), + }) +} + +#[tauri::command(async)] +pub fn import_theme_file(path: String) -> CmdResult { + validate_theme_path(&path)?; + std::fs::read_to_string(&path).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not read theme file: {e}"), + }) +} + +fn validate_export_path(path: &str) -> CmdResult<()> { + let p = std::path::Path::new(path); + if !p.is_absolute() { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "export path must be absolute".into(), + }); + } + let is_allowed = p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt")); + if !is_allowed { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "export file must have a .md or .txt extension".into(), + }); + } + Ok(()) +} + +#[tauri::command(async)] +pub fn export_note_file(path: String, contents: String) -> CmdResult<()> { + validate_export_path(&path)?; + std::fs::write(&path, contents).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write export file: {e}"), + }) +} + +// Pasted/dropped images live as files under /attachments and notes +// reference them by relative `attachments/` markdown paths, so exported +// markdown stays portable and the DB stays lean. The webview reads them back +// through the asset protocol (scoped to this directory in tauri.conf.json). + +const ATTACHMENT_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; + +fn attachments_dir(app: &AppHandle) -> CmdResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("no app data dir: {e}"), + })? + .join("attachments"); + std::fs::create_dir_all(&dir).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not create attachments dir: {e}"), + })?; + Ok(dir) +} + +#[tauri::command(async)] +pub fn get_attachments_dir(app: AppHandle) -> CmdResult { + Ok(attachments_dir(&app)?.to_string_lossy().into_owned()) +} + +/// Store one image. The body is the raw bytes (not JSON) so a screenshot paste +/// doesn't pay for number-array serialization; the extension rides in a header. +/// Returns the generated filename; the caller builds `attachments/`. +#[tauri::command(async)] +pub fn save_attachment(app: AppHandle, request: tauri::ipc::Request<'_>) -> CmdResult { + let ext = request + .headers() + .get("x-attachment-ext") + .and_then(|v| v.to_str().ok()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !ATTACHMENT_EXTS.contains(&ext.as_str()) { + return Err(CmdError { + code: "VALIDATION".into(), + message: format!("unsupported attachment type: {ext:?}"), + }); + } + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err(CmdError { + code: "VALIDATION".into(), + message: "attachment body must be raw bytes".into(), + }); + }; + if bytes.is_empty() { + return Err(CmdError { + code: "VALIDATION".into(), + message: "attachment is empty".into(), + }); + } + let name = format!("{}.{ext}", uuid::Uuid::new_v4()); + let path = attachments_dir(&app)?.join(&name); + std::fs::write(&path, bytes).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write attachment: {e}"), + })?; + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::{export_theme_file, import_theme_file}; + + #[test] + fn theme_file_round_trip() { + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "instantnotes-theme-{}.intheme.json", + std::process::id() + )); + let path_str = path.to_string_lossy().to_string(); + let json = r#"{"id":"x","name":"X","version":1}"#.to_string(); + + export_theme_file(path_str.clone(), json.clone()).expect("write"); + let read_back = import_theme_file(path_str.clone()).expect("read"); + assert_eq!(read_back, json); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn import_missing_file_errors() { + let res = import_theme_file("/nonexistent/path/theme.intheme.json".into()); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().code, "STORAGE_ERROR"); + } + + #[test] + fn theme_path_validation_rejects_non_absolute_and_non_json() { + // Relative path -> rejected before any filesystem access. + assert!(export_theme_file("relative/theme.json".into(), "{}".into()).is_err()); + assert!(import_theme_file("relative/theme.json".into()).is_err()); + // Absolute but not a .json file -> rejected. + assert!(import_theme_file("/tmp/not-a-theme.txt".into()).is_err()); + } +} diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs new file mode 100644 index 0000000..0a0544d --- /dev/null +++ b/src-tauri/src/shell/mod.rs @@ -0,0 +1,8 @@ +//! The desktop shell behind run(): capture-latency metrics, window management, +//! file I/O commands, and the quit handshake. run() keeps only the native menu, +//! tray, and setup wiring that composes these. + +pub(crate) mod capture; +pub(crate) mod files; +pub(crate) mod quit; +pub(crate) mod windows; diff --git a/src-tauri/src/shell/quit.rs b/src-tauri/src/shell/quit.rs new file mode 100644 index 0000000..1815fd8 --- /dev/null +++ b/src-tauri/src/shell/quit.rs @@ -0,0 +1,43 @@ +//! The quit handshake. Body edits are debounced in the webview, so exiting the +//! process directly would drop the tail of whatever was just typed. Every quit +//! path (menu, tray, Dock) emits "app:quit-requested"; the library window +//! flushes its pending edits and answers with quit_app, which really exits. + +use crate::*; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// True once the frontend flushed and called quit_app, or once the fallback +/// gave up waiting. ExitRequested lets the exit proceed only when this is set, +/// so the flush handshake runs at most once per quit. +pub(crate) static QUIT_READY: AtomicBool = AtomicBool::new(false); + +/// How long a quit waits for the webview flush before exiting anyway. +const QUIT_FLUSH_GRACE_MS: u64 = 800; + +/// Ask the webviews to flush, then exit. The fallback timer exists because +/// quit must not block forever on a dead webview: if the frontend never +/// answers with quit_app, exit anyway after the grace period. +pub(crate) fn request_quit(app: &AppHandle) { + let _ = app.emit("app:quit-requested", ()); + let handle = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(QUIT_FLUSH_GRACE_MS)); + // swap keeps the fallback and quit_app from racing: whichever runs + // first marks the handshake done and the other becomes a no-op. + if !QUIT_READY.swap(true, Ordering::AcqRel) { + handle.exit(0); + } + }); +} + +/// Final leg of the handshake: the library webview has flushed pending edits. +#[tauri::command] +pub fn quit_app(app: AppHandle) { + QUIT_READY.store(true, Ordering::Release); + app.exit(0); +} + +#[tauri::command] +pub fn get_shortcut_failure(state: State<'_, ShortcutStatus>) -> Option { + state.failed.clone() +} diff --git a/src-tauri/src/shell/windows.rs b/src-tauri/src/shell/windows.rs new file mode 100644 index 0000000..816c102 --- /dev/null +++ b/src-tauri/src/shell/windows.rs @@ -0,0 +1,202 @@ +//! Library and capture window show/hide, native macOS vibrancy and theme, the +//! external-link opener, and the icon-cache refresh the in-place updater needs. + +use crate::shell::capture::CaptureMetrics; +use crate::*; + +#[tauri::command] +pub fn hide_capture(app: AppHandle) { + hide_capture_window(&app); +} + +#[tauri::command] +pub fn open_library(app: AppHandle) { + show_library_window(&app); +} + +/// Apply a native macOS vibrancy material to the library window, or clear it when +/// `material` is None/unknown. Vibrancy is the closest a webview app gets to the +/// Tahoe "Liquid Glass" look; it requires the always-transparent window and a +/// translucent surface above it (the theme's sidebar token). A no-op off macOS. +#[tauri::command] +pub fn set_window_vibrancy(app: AppHandle, material: Option) { + #[cfg(target_os = "macos")] + { + use window_vibrancy::{apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial}; + let Some(win) = app.get_webview_window("library") else { + return; + }; + // Any known material applies; None or an unknown string clears. + let chosen = material.as_deref().and_then(|m| match m { + "sidebar" => Some(NSVisualEffectMaterial::Sidebar), + "under-window" => Some(NSVisualEffectMaterial::UnderWindowBackground), + "header" => Some(NSVisualEffectMaterial::HeaderView), + "menu" => Some(NSVisualEffectMaterial::Menu), + "popover" => Some(NSVisualEffectMaterial::Popover), + "hud" => Some(NSVisualEffectMaterial::HudWindow), + _ => None, + }); + match chosen { + Some(m) => { + let _ = apply_vibrancy(&win, m, None, None); + } + None => { + let _ = clear_vibrancy(&win); + } + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, material); + } +} + +/// Match the native library window's theme (titlebar and traffic-light treatment) +/// to the in-app light/dark variant. Tauri maps this to the window's OS appearance, +/// so the chrome follows the active theme instead of the launch-time system setting. +/// An unknown variant is a no-op; the borderless capture window has no native chrome +/// and is left alone. +#[tauri::command] +pub fn set_window_theme(app: AppHandle, variant: String) { + use tauri::Theme; + let theme = match variant.as_str() { + "light" => Theme::Light, + "dark" => Theme::Dark, + _ => return, + }; + if let Some(win) = app.get_webview_window("library") { + let _ = win.set_theme(Some(theme)); + } +} + +#[tauri::command] +pub fn open_url(app: AppHandle, url: String) { + let _ = app.opener().open_url(&url, None::<&str>); +} + +// ---- window helpers ---- + +pub(crate) fn show_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + // Stamp before any window work so the sample covers the whole reveal. + if let Some(metrics) = app.try_state::() { + metrics.mark_shown(); + } + let _ = w.center(); + let _ = w.show(); + let _ = w.set_focus(); + // Frontend focuses the textarea and restores any preserved draft. + let _ = w.emit("capture:shown", ()); + } +} + +pub(crate) fn hide_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + let _ = w.hide(); + } +} + +pub(crate) fn toggle_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + if w.is_visible().unwrap_or(false) { + hide_capture_window(app); + } else { + show_capture_window(app); + } + } +} + +pub(crate) fn show_library_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("library") { + let _ = w.show(); + let _ = w.set_focus(); + } +} + +// ---- data folder + icon cache refresh ---- + +pub(crate) const REPO_URL: &str = "https://github.com/Jam-Sw/InstantNotes"; + +pub(crate) fn open_data_folder(app: &AppHandle) { + if let Ok(dir) = app.path().app_data_dir() { + // Via the opener plugin rather than a raw `open` subprocess, so it stays + // on Tauri's permission-checked path. Called only from Rust with our own + // data directory - never a webview-supplied path. + let _ = app.opener().open_path(dir.to_string_lossy(), None::<&str>); + } +} + +// macOS caches an app's icon per bundle path (LaunchServices + iconservicesd). +// The in-app updater swaps the bundle in place at the same path and identifier, +// so without a nudge the Dock/Finder keep showing the icon cached for the old +// build. We record the version that last launched and, when it changes, ask +// macOS to re-read the bundle once. + +/// True when the icon cache should be refreshed: the recorded last-launched +/// version is missing (pre-marker install or first launch) or differs from the +/// running version. Pure so it can be unit-tested without a real bundle. +fn icon_refresh_needed(previous: Option<&str>, current: &str) -> bool { + previous != Some(current) +} + +/// Record the running version next to the database and, when it changed since +/// the last launch, refresh the macOS icon cache. A no-op on the happy path +/// (same version) and in dev builds (no `.app` bundle). +pub(crate) fn refresh_icon_cache_if_updated(data_dir: &std::path::Path) { + let current = env!("CARGO_PKG_VERSION"); + let marker = data_dir.join(".last_version"); + let stored = std::fs::read_to_string(&marker).ok(); + let previous = stored.as_deref().map(str::trim); + if !icon_refresh_needed(previous, current) { + return; + } + let _ = std::fs::write(&marker, current); + #[cfg(target_os = "macos")] + if let Some(bundle) = current_app_bundle() { + refresh_macos_icon(bundle); + } +} + +/// Path to the running `.app` bundle, or `None` in a dev build where the +/// executable is not inside a `*.app/Contents/MacOS/` layout. +#[cfg(target_os = "macos")] +fn current_app_bundle() -> Option { + let exe = std::env::current_exe().ok()?; + let bundle = exe.parent()?.parent()?.parent()?; // MacOS -> Contents -> .app + if bundle.extension()?.to_str()? == "app" { + Some(bundle.to_path_buf()) + } else { + None + } +} + +/// Nudge macOS to re-read the bundle's icon: re-register with LaunchServices, +/// bump the bundle mtime (part of the icon cache key), then relaunch the Dock. +/// Runs off the main thread; every step is best-effort. +#[cfg(target_os = "macos")] +fn refresh_macos_icon(bundle: std::path::PathBuf) { + std::thread::spawn(move || { + const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + let _ = std::process::Command::new(LSREGISTER) + .arg("-f") + .arg(&bundle) + .status(); + let _ = std::process::Command::new("touch").arg(&bundle).status(); + let _ = std::process::Command::new("killall").arg("Dock").status(); + }); +} + +#[cfg(test)] +mod tests { + use super::icon_refresh_needed; + + #[test] + fn icon_refresh_when_version_changed_or_unknown() { + // First launch / upgrade from a build that never wrote the marker. + assert!(icon_refresh_needed(None, "0.5.3")); + // In-place update from an older recorded version. + assert!(icon_refresh_needed(Some("0.5.2"), "0.5.3")); + // Same version relaunch: nothing to refresh. + assert!(!icon_refresh_needed(Some("0.5.3"), "0.5.3")); + } +} From 006001e97913ff7ebe95bae257aa3ad610c49441 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:00:56 -0700 Subject: [PATCH 37/41] refactor(ui): split SettingsView into per-page components Break the 661-line SettingsView into a shell (header, breadcrumb, and the category grid) plus settings/{SettingsAbout, SettingsContexting, SettingsLinks}.svelte, each owning its markup and scoped styles. The shell drops to 187 lines, and each page is editable without scrolling past the others. --- src/lib/components/SettingsView.svelte | 490 +----------------- .../components/settings/SettingsAbout.svelte | 140 +++++ .../settings/SettingsContexting.svelte | 123 +++++ .../components/settings/SettingsLinks.svelte | 253 +++++++++ 4 files changed, 524 insertions(+), 482 deletions(-) create mode 100644 src/lib/components/settings/SettingsAbout.svelte create mode 100644 src/lib/components/settings/SettingsContexting.svelte create mode 100644 src/lib/components/settings/SettingsLinks.svelte diff --git a/src/lib/components/SettingsView.svelte b/src/lib/components/SettingsView.svelte index 8f72d72..b7636e5 100644 --- a/src/lib/components/SettingsView.svelte +++ b/src/lib/components/SettingsView.svelte @@ -1,15 +1,12 @@ + +
+ InstantNotes +

InstantNotes

+ {#if appVersion} + v{appVersion} + {/if} +

Instant capture, organized knowledge.

+ +
+
+ Version + {appVersion || "-"} +
+
+
+ Platform + macOS · Apple Silicon +
+
+
+ Capture readiness + + {#if captureLatency && captureLatency.medianMs !== null} + {captureLatency.medianMs} ms + {:else} + Measured on first capture + {/if} + +
+
+
+ Source + +
+
+
+ + diff --git a/src/lib/components/settings/SettingsContexting.svelte b/src/lib/components/settings/SettingsContexting.svelte new file mode 100644 index 0000000..fc357b0 --- /dev/null +++ b/src/lib/components/settings/SettingsContexting.svelte @@ -0,0 +1,123 @@ + + +
+

Contexting

+

+ The template behind "Copy note as context" in the {modKey}K palette. Wrap the note + however a tool or model expects; this is the seed for InstantNotes' AI features. +

+ + + + +
+ {#each TEMPLATE_VARS as v} + {v} + {/each} +
+ + Preview +
{preview}
+
+ + diff --git a/src/lib/components/settings/SettingsLinks.svelte b/src/lib/components/settings/SettingsLinks.svelte new file mode 100644 index 0000000..fe5aa6b --- /dev/null +++ b/src/lib/components/settings/SettingsLinks.svelte @@ -0,0 +1,253 @@ + + + + + From db5e4d027791f1eca4407375276d9b96976968c0 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:02:41 -0700 Subject: [PATCH 38/41] test(ui): cover SettingsView navigation Add a component test for the settings shell: the landing grid renders a card per page, opening a card shows the page under a breadcrumb that returns home, and Escape steps back to the grid before closing the view. Guards the per-page split; the command palette's keyboard-nav logic is already covered by the palette-sections unit tests. --- src/lib/components/SettingsView.test.ts | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/lib/components/SettingsView.test.ts diff --git a/src/lib/components/SettingsView.test.ts b/src/lib/components/SettingsView.test.ts new file mode 100644 index 0000000..379158a --- /dev/null +++ b/src/lib/components/SettingsView.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, cleanup, within } from "@testing-library/svelte"; +import SettingsView from "./SettingsView.svelte"; + +// The sub-pages init preference stores and fetch capture latency on mount; +// stub the IPC client so they render without a Tauri backend. +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn().mockResolvedValue(undefined), + setSetting: vi.fn().mockResolvedValue(undefined), + getCaptureLatency: vi.fn().mockResolvedValue({ + lastMs: null, + medianMs: null, + samples: 0, + }), + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +afterEach(cleanup); + +function open() { + const onBack = vi.fn(); + const view = render(SettingsView, { appVersion: "0.8.0", onBack }); + return { onBack, ...view }; +} + +describe("SettingsView", () => { + it("lands on the category grid with a card per page", () => { + const { getByRole } = open(); + expect(getByRole("button", { name: /About/ })).toBeTruthy(); + expect(getByRole("button", { name: /Links/ })).toBeTruthy(); + expect(getByRole("button", { name: /Contexting/ })).toBeTruthy(); + }); + + it("opens a page from its card and shows a breadcrumb back to Settings", async () => { + const { getByRole, findByText } = open(); + await fireEvent.click(getByRole("button", { name: /About/ })); + // The About page rendered (its heading), under a breadcrumb. + expect(await findByText("InstantNotes")).toBeTruthy(); + const crumb = getByRole("navigation", { name: "Breadcrumb" }); + expect(within(crumb).getByText("About")).toBeTruthy(); + // Breadcrumb "Settings" returns to the grid. + await fireEvent.click(within(crumb).getByRole("button", { name: "Settings" })); + expect(getByRole("button", { name: /Contexting/ })).toBeTruthy(); + }); + + it("Escape steps back to the grid before closing the view", async () => { + const { getByRole, onBack } = open(); + await fireEvent.click(getByRole("button", { name: /Links/ })); + // First Escape: back to the grid, view stays open. + await fireEvent.keyDown(window, { key: "Escape" }); + expect(onBack).not.toHaveBeenCalled(); + expect(getByRole("button", { name: /About/ })).toBeTruthy(); + // Second Escape from the grid: closes the whole view. + await fireEvent.keyDown(window, { key: "Escape" }); + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); From 22b91c147053d865a262d8d1d8a5a4e706d8dcc7 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:03:50 -0700 Subject: [PATCH 39/41] docs: pin the Space/Workspace naming boundary The UI says "Space"; the commands, tables, and Rust core say "workspace". Document that boundary authoritatively where the two meet (client.ts) and in the project glossary, so the full through-stack rename (churn with no user value) stays unnecessary and no layer has to guess which name it uses. --- openspec/project.md | 3 +++ src/lib/api/client.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openspec/project.md b/openspec/project.md index 1564e5a..5d49812 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -42,6 +42,9 @@ Active OpenSpec change proposals live in `openspec/changes/`. Once a change has ## Domain Context Notes are the canonical user data. Tags are lightweight labels, including tags extracted from `#inline` text. Workspaces are named collections that group related notes; a note can belong to more than one, and deleting a workspace never deletes its notes. Search must support plain user input without exposing FTS syntax errors. +### Glossary: Space = Workspace +The product term is **Space** (sidebar, copy, component names). The storage tables, IPC command names, and Rust core keep the original **workspace** name. This is deliberate: renaming storage internals is churn with no user value. The one place the two vocabularies meet is `src/lib/api/client.ts`, which documents the boundary; UI and store code say "space", everything from the command strings down says "workspace". + ## Important Constraints - Notes are stored locally. - Note content must not appear in logs. diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 191bf15..0856bf8 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -86,7 +86,14 @@ export const removeTagFromNote = (noteId: string, tagId: string) => export const tagsForNote = (noteId: string) => call("tags_for_note", { noteId }); -// ---- workspaces ---- +// ---- workspaces (the UI calls these "Spaces") ---- +// GLOSSARY / naming boundary: the product term is "Space" everywhere the user +// sees it (sidebar, copy, component names); the command strings, storage +// tables, and these wrapper names keep "workspace". This file is the single +// place the two vocabularies meet, by decision: renaming the storage internals +// is churn with no user value (see openspec/project.md and +// docs/superpowers/specs/2026-07-10-spaces-design.md). One concept, two names, +// documented here so no layer has to guess which it is in. export const listWorkspaces = () => call("list_workspaces"); export const getOrCreateWorkspace = (name: string) => From be4582eac3bc4836f028d515a15ac369c4e01591 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:05:26 -0700 Subject: [PATCH 40/41] docs: drop a where-it-came-from comment on formatDate The 'mirrors the previous inline helper' aside pointed at deleted code and carried no rationale. The other comments the audit called essays are load-bearing (the client/server normalization boundary, the testability rationale on the snooze helper) and stay, per when-in-doubt-keep. --- src/lib/format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/format.ts b/src/lib/format.ts index 0fae5dc..38e5b2b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,7 +1,7 @@ // Small presentation helpers shared by the library views. /** Today shows a time (e.g. "3:04 PM"); any other day shows a short date - * (e.g. "Jun 5"). Mirrors the previous inline helper in the library page. */ + * (e.g. "Jun 5"). */ export function formatDate(iso: string): string { const d = new Date(iso); const today = new Date(); From 3e32f8cb0be9bb809947938089bd39bc8b2e1193 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:14:08 -0700 Subject: [PATCH 41/41] style: rustfmt the extracted store and command modules cargo fmt --all over the modules split out of store.rs and lib.rs; whitespace only, no behavior change. Keeps the CI fmt gate green. --- src-tauri/core/src/store.rs | 2 +- src-tauri/core/src/store/notes.rs | 5 +++-- src-tauri/core/src/store/settings.rs | 2 +- src-tauri/core/src/store/tags.rs | 2 +- src-tauri/core/src/store/workspaces.rs | 2 +- src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/commands/notes.rs | 2 -- src-tauri/src/commands/settings.rs | 13 +++++++++---- src-tauri/src/commands/tags.rs | 8 +++++--- src-tauri/src/commands/workspaces.rs | 7 ++++--- 10 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index c172e06..30506d6 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -403,9 +403,9 @@ impl Store { } mod notes; +mod settings; mod tags; mod workspaces; -mod settings; #[cfg(test)] mod pragma_tests { diff --git a/src-tauri/core/src/store/notes.rs b/src-tauri/core/src/store/notes.rs index af18003..9ad2a15 100644 --- a/src-tauri/core/src/store/notes.rs +++ b/src-tauri/core/src/store/notes.rs @@ -387,8 +387,9 @@ impl Store { "DELETE FROM notes WHERE id IN ({})", Self::id_placeholders(ids) ); - let args: Vec<&dyn rusqlite::ToSql> = ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); + let args: Vec<&dyn rusqlite::ToSql> = + ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); self.conn.execute(&sql, rusqlite::params_from_iter(args))?; Ok(()) } -} \ No newline at end of file +} diff --git a/src-tauri/core/src/store/settings.rs b/src-tauri/core/src/store/settings.rs index e701e36..943c67d 100644 --- a/src-tauri/core/src/store/settings.rs +++ b/src-tauri/core/src/store/settings.rs @@ -37,4 +37,4 @@ impl Store { .execute("DELETE FROM settings WHERE key = ?1", params![key])?; Ok(()) } -} \ No newline at end of file +} diff --git a/src-tauri/core/src/store/tags.rs b/src-tauri/core/src/store/tags.rs index cd792d1..b1b3856 100644 --- a/src-tauri/core/src/store/tags.rs +++ b/src-tauri/core/src/store/tags.rs @@ -118,4 +118,4 @@ impl Store { let rows = stmt.query_map(params![note_id], row_to_tag)?; Ok(rows.collect::>>()?) } -} \ No newline at end of file +} diff --git a/src-tauri/core/src/store/workspaces.rs b/src-tauri/core/src/store/workspaces.rs index 084db7a..9477f5c 100644 --- a/src-tauri/core/src/store/workspaces.rs +++ b/src-tauri/core/src/store/workspaces.rs @@ -155,4 +155,4 @@ impl Store { let rows = stmt.query_map(params![note_id], row_to_workspace)?; Ok(rows.collect::>>()?) } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0f2143c..977c3e5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,4 @@ pub mod notes; +pub mod settings; pub mod tags; pub mod workspaces; -pub mod settings; diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index f157dcd..264f2cd 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -2,7 +2,6 @@ use crate::*; - #[tauri::command(async)] pub fn create_note( state: State<'_, AppState>, @@ -126,4 +125,3 @@ pub fn destroy_notes( emit_tags_changed(&app); Ok(()) } - diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 88e62e6..0ee2a65 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,14 +2,20 @@ use crate::*; - #[tauri::command(async)] -pub fn get_setting(state: State<'_, AppState>, key: String) -> CmdResult> { +pub fn get_setting( + state: State<'_, AppState>, + key: String, +) -> CmdResult> { Ok(locked(&state)?.get_setting(&key)?) } #[tauri::command(async)] -pub fn set_setting(state: State<'_, AppState>, key: String, value: serde_json::Value) -> CmdResult<()> { +pub fn set_setting( + state: State<'_, AppState>, + key: String, + value: serde_json::Value, +) -> CmdResult<()> { Ok(locked(&state)?.set_setting(&key, value)?) } @@ -17,4 +23,3 @@ pub fn set_setting(state: State<'_, AppState>, key: String, value: serde_json::V pub fn delete_setting(state: State<'_, AppState>, key: String) -> CmdResult<()> { Ok(locked(&state)?.delete_setting(&key)?) } - diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs index 5ced679..358e2a4 100644 --- a/src-tauri/src/commands/tags.rs +++ b/src-tauri/src/commands/tags.rs @@ -2,14 +2,17 @@ use crate::*; - #[tauri::command(async)] pub fn list_tags(state: State<'_, AppState>) -> CmdResult> { Ok(locked(&state)?.list_tags()?) } #[tauri::command(async)] -pub fn get_or_create_tag(state: State<'_, AppState>, app: AppHandle, name: String) -> CmdResult { +pub fn get_or_create_tag( + state: State<'_, AppState>, + app: AppHandle, + name: String, +) -> CmdResult { let tag = locked(&state)?.get_or_create_tag(&name)?; emit_tags_changed(&app); Ok(tag) @@ -66,4 +69,3 @@ pub fn remove_tag_from_note( pub fn tags_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult> { Ok(locked(&state)?.tags_for_note(¬e_id)?) } - diff --git a/src-tauri/src/commands/workspaces.rs b/src-tauri/src/commands/workspaces.rs index 923e41b..4d04c57 100644 --- a/src-tauri/src/commands/workspaces.rs +++ b/src-tauri/src/commands/workspaces.rs @@ -2,7 +2,6 @@ use crate::*; - #[tauri::command(async)] pub fn list_workspaces(state: State<'_, AppState>) -> CmdResult> { Ok(locked(&state)?.list_workspaces()?) @@ -78,7 +77,9 @@ pub fn remove_note_from_workspace( } #[tauri::command(async)] -pub fn workspaces_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult> { +pub fn workspaces_for_note( + state: State<'_, AppState>, + note_id: String, +) -> CmdResult> { Ok(locked(&state)?.workspaces_for_note(¬e_id)?) } -