From 5faf285d28319ede6dc476aabdb4e2a6655da4a6 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 13:27:56 -0700 Subject: [PATCH] feat(tauri): add move, rename, grouping commands and file watcher Add Tauri commands: move_task, rename_list, set/get_group_by_due_date, watch_workspace. Implement file watcher using notify crate with 500ms debounce and self-change suppression via mute_watcher(). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tauri/src-tauri/Cargo.lock | 213 ++++++++++++++++++++++++++------ apps/tauri/src-tauri/Cargo.toml | 2 + apps/tauri/src-tauri/src/lib.rs | 148 +++++++++++++++++++++- 3 files changed, 327 insertions(+), 36 deletions(-) diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 2bab9cb..0b0814b 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -94,39 +94,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "onyx-core" -version = "0.1.0" -dependencies = [ - "chrono", - "directories", - "keyring", - "quick-xml 0.36.2", - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "tokio", - "uuid", -] - -[[package]] -name = "onyx-tauri" -version = "0.1.0" -dependencies = [ - "onyx-core", - "chrono", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-os", - "tokio", - "uuid", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -842,6 +809,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -912,6 +890,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1661,6 +1648,35 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1786,6 +1802,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -1859,7 +1895,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1981,6 +2020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2060,6 +2100,46 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2299,6 +2379,41 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "onyx-core" +version = "0.1.0" +dependencies = [ + "chrono", + "directories", + "keyring", + "quick-xml 0.36.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "uuid", +] + +[[package]] +name = "onyx-tauri" +version = "0.1.0" +dependencies = [ + "chrono", + "notify", + "notify-debouncer-mini", + "onyx-core", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-os", + "tokio", + "uuid", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2364,7 +2479,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2580,6 +2695,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2933,6 +3054,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -3612,7 +3742,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4131,6 +4261,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index be4977a..3752fda 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ onyx-core = { path = "../../../crates/onyx-core" } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } +notify = "7" +notify-debouncer-mini = "0.5" [package.metadata.tauri] diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 85c95c2..fc7d3da 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ use std::path::PathBuf; use std::sync::Mutex; +use std::time::Instant; +use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{Emitter, Manager, State}; use uuid::Uuid; use onyx_core::{ @@ -13,6 +15,13 @@ use onyx_core::{ webdav, }; +/// Active file watcher stored globally so it lives for the app lifetime. +static WATCHER: Mutex>> = + Mutex::new(None); + +/// Shared mute timestamp — set before writes, checked by the watcher. +static LAST_WRITE: Mutex> = Mutex::new(None); + /// Shared application state behind a mutex. struct AppState { config: AppConfig, @@ -43,6 +52,11 @@ impl From for SyncResult { } } +/// Suppress file watcher events for the next second (call before writes). +fn mute_watcher(_state: &mut AppState) { + *LAST_WRITE.lock().unwrap() = Some(Instant::now()); +} + /// Helper: get or open a TaskRepository for the current workspace. fn ensure_repo(state: &mut AppState) -> Result<(), String> { if state.repo.is_some() { @@ -151,6 +165,7 @@ fn create_list( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); s.repo .as_mut() .unwrap() @@ -165,6 +180,7 @@ fn delete_list( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; s.repo .as_mut() @@ -199,6 +215,7 @@ fn create_task( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let mut task = Task::new(title); if let Some(desc) = description.filter(|d| !d.is_empty()) { @@ -219,6 +236,7 @@ fn update_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; s.repo .as_mut() @@ -235,6 +253,7 @@ fn delete_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo @@ -252,6 +271,7 @@ fn toggle_task( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; let repo = s.repo.as_mut().unwrap(); @@ -274,6 +294,7 @@ fn reorder_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo @@ -283,6 +304,77 @@ fn reorder_task( .map_err(|e| e.to_string()) } +// ── Move / rename / grouping ──────────────────────────────────────── + +#[tauri::command] +fn move_task( + from_list_id: String, + to_list_id: String, + task_id: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let from = Uuid::parse_str(&from_list_id).map_err(|e| e.to_string())?; + let to = Uuid::parse_str(&to_list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .move_task(from, to, tid) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn rename_list( + list_id: String, + new_name: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .rename_list(id, new_name) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn set_group_by_due_date( + list_id: String, + enabled: bool, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .set_group_by_due_date(id, enabled) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_group_by_due_date( + list_id: String, + state: State<'_, Mutex>, +) -> Result { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_ref() + .unwrap() + .get_group_by_due_date(id) + .map_err(|e| e.to_string()) +} + // ── Sync commands ──────────────────────────────────────────────────── #[tauri::command] @@ -348,6 +440,43 @@ async fn sync_workspace( Ok(result.into()) } +// ── File watcher ──────────────────────────────────────────────────── + +fn start_watcher(handle: tauri::AppHandle, path: PathBuf) { + let handle = handle.clone(); + let debouncer = new_debouncer( + std::time::Duration::from_millis(500), + move |events: Result, notify::Error>| { + let Ok(events) = events else { return }; + // Only care about data file changes + let has_data_change = events.iter().any(|e| { + if e.kind != DebouncedEventKind::Any { return false; } + let p = e.path.to_string_lossy(); + p.ends_with(".md") || p.ends_with(".json") + }); + if !has_data_change { return; } + // Skip if we wrote recently (self-change suppression) + if let Some(t) = *LAST_WRITE.lock().unwrap() { + if t.elapsed() < std::time::Duration::from_secs(1) { return; } + } + let _ = handle.emit("fs-changed", ()); + }, + ); + match debouncer { + Ok(mut d) => { + let _ = d.watcher().watch(&path, notify::RecursiveMode::Recursive); + *WATCHER.lock().unwrap() = Some(d); + } + Err(e) => eprintln!("Failed to start file watcher: {e}"), + } +} + +#[tauri::command] +fn watch_workspace(path: String, app_handle: tauri::AppHandle) -> Result<(), String> { + start_watcher(app_handle, PathBuf::from(path)); + Ok(()) +} + // ── App entry ──────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -360,6 +489,18 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) .manage(Mutex::new(AppState { config, repo: None })) + .setup(|app| { + let handle = app.handle().clone(); + let state: State<'_, Mutex> = app.state(); + let workspace_path = { + let s = state.lock().unwrap(); + s.config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone()) + }; + if let Some(path) = workspace_path { + start_watcher(handle, path); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ get_config, save_config, @@ -376,11 +517,16 @@ pub fn run() { delete_task, toggle_task, reorder_task, + move_task, + rename_list, + set_group_by_due_date, + get_group_by_due_date, set_webdav_config, store_credentials, load_credentials, test_webdav_connection, sync_workspace, + watch_workspace, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");