Files
HelixNotes/src-tauri/src/vault/watcher.rs
T

156 lines
4.9 KiB
Rust

use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState;
use crate::types::FileEvent;
use crate::vault::operations::helixnotes_dir;
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatcherBackend {
Recommended,
Poll,
}
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
if is_ios {
WatcherBackend::Poll
} else {
WatcherBackend::Recommended
}
}
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
match backend {
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
WatcherBackend::Poll => IOS_POLL_INTERVAL,
}
}
pub enum VaultWatcher {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl VaultWatcher {
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
match self {
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
}
}
}
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
let (tx, rx) = mpsc::channel();
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
let mut watcher = match backend {
WatcherBackend::Recommended => VaultWatcher::Recommended(
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
),
WatcherBackend::Poll => {
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
}
};
watcher
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
.map_err(|e| e.to_string())?;
let hn_dir = helixnotes_dir(&vault_path);
std::thread::spawn(move || {
while let Ok(result) = rx.recv() {
match result {
Ok(event) => {
// Skip all file events while importing
let state = app.state::<AppState>();
if state.importing.load(std::sync::atomic::Ordering::Relaxed) {
continue;
}
// Skip .helixnotes directory events
let dominated_by_hn = event.paths.iter().all(|p| p.starts_with(&hn_dir));
if dominated_by_hn {
continue;
}
// Only care about .md file events
let has_md = event.paths.iter().any(|p| {
p.extension().and_then(|x| x.to_str()) == Some("md") || p.is_dir()
});
if !has_md {
continue;
}
let event_type = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => continue,
};
for path in &event.paths {
let fe = FileEvent {
event_type: event_type.to_string(),
path: path.to_string_lossy().to_string(),
};
let _ = app.emit("file-changed", &fe);
}
// On mobile, throttle event emission to prevent IPC flooding
// on FUSE filesystems where Syncthing/other apps generate
// constant file activity that blocks the Tauri command channel.
#[cfg(mobile)]
{
std::thread::sleep(Duration::from_secs(2));
while rx.try_recv().is_ok() {}
}
}
Err(e) => {
log::error!("File watcher error: {}", e);
}
}
}
});
Ok(watcher)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_uses_polling_backend() {
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
}
#[test]
fn other_platforms_keep_recommended_backend() {
assert_eq!(
watcher_backend_for_target(false),
WatcherBackend::Recommended
);
}
#[test]
fn watcher_backends_use_the_expected_intervals() {
assert_eq!(
poll_interval_for_backend(WatcherBackend::Poll),
Duration::from_secs(10)
);
assert_eq!(
poll_interval_for_backend(WatcherBackend::Recommended),
Duration::from_secs(1)
);
}
}