From 7404acdfb401debf1431179fb8ebeeac37c605bc Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 09:55:10 -0500 Subject: [PATCH] feat: add Tauri event hook and lore.db file watcher Adds real-time updates when lore syncs new GitLab data by watching the lore.db file for changes. React hook (src/hooks/useTauriEvents.ts): - useTauriEvent(): Subscribe to a single Tauri event with auto-cleanup - useTauriEvents(): Subscribe to multiple events with a handler map - Typed payloads for each event type: - global-shortcut-triggered: toggle-window | quick-capture - lore-data-changed: void (refresh trigger) - sync-status: started | completed | failed - error-notification: code + message Rust watcher (src-tauri/src/watcher.rs): - Watches lore's data directory for lore.db modifications - Uses notify crate with 2-second poll interval - Emits "lore-data-changed" event to frontend on file change - Handles atomic writes by watching parent directory - Gracefully handles missing lore.db (logs warning, skips watcher) Test coverage: - Hook subscription and cleanup behavior - Focus store test fix: clear localStorage before each test Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/watcher.rs | 85 ++++++++++++++++++++++ src/hooks/useTauriEvents.ts | 104 +++++++++++++++++++++++++++ tests/hooks/useTauriEvents.test.ts | 112 +++++++++++++++++++++++++++++ tests/stores/focus-store.test.ts | 32 +++++++++ 4 files changed, 333 insertions(+) create mode 100644 src-tauri/src/watcher.rs create mode 100644 src/hooks/useTauriEvents.ts create mode 100644 tests/hooks/useTauriEvents.test.ts diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..0279466 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,85 @@ +//! File watcher for lore.db changes +//! +//! Watches the lore database file for modifications and emits events +//! to trigger frontend refresh when lore syncs new data. + +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +/// Get the path to lore's database file +fn lore_db_path() -> Option { + dirs::data_local_dir().map(|d| d.join("lore").join("lore.db")) +} + +/// Start watching lore.db for changes. +/// +/// When the file is modified, emits a "lore-data-changed" event to the frontend. +/// Returns the watcher handle (must be kept alive for watching to continue). +pub fn start_lore_watcher(app: AppHandle) -> Option { + let db_path = lore_db_path()?; + + // Check if the file exists + if !db_path.exists() { + tracing::warn!("lore.db not found at {:?}, skipping file watcher", db_path); + return None; + } + + let parent_dir = db_path.parent()?; + + // Create a channel for receiving events + let (tx, rx) = mpsc::channel(); + + // Create the watcher with a debounce of 1 second + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }, + Config::default().with_poll_interval(Duration::from_secs(2)), + ) + .ok()?; + + // Watch the lore directory (not just the file, for atomic write detection) + if let Err(e) = watcher.watch(parent_dir, RecursiveMode::NonRecursive) { + tracing::error!("Failed to start lore.db watcher: {}", e); + return None; + } + + tracing::info!("Watching lore.db for changes at {:?}", db_path); + + // Spawn a thread to handle events + std::thread::spawn(move || { + for event in rx { + // Only react to modify events on the actual db file + if matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db")); + if affects_db { + tracing::debug!("lore.db changed, emitting refresh event"); + let _ = app.emit("lore-data-changed", ()); + } + } + } + }); + + Some(watcher) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lore_db_path_returns_expected_location() { + let path = lore_db_path(); + assert!(path.is_some()); + let path = path.unwrap(); + assert!(path.ends_with("lore/lore.db")); + } +} diff --git a/src/hooks/useTauriEvents.ts b/src/hooks/useTauriEvents.ts new file mode 100644 index 0000000..6abe63f --- /dev/null +++ b/src/hooks/useTauriEvents.ts @@ -0,0 +1,104 @@ +/** + * React hook for Tauri event communication. + * + * Handles Rust→React events with automatic cleanup on unmount. + * Used for file watcher triggers, sync status, error notifications. + */ + +import { useEffect, useCallback } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +/** Event types emitted by the Rust backend */ +export type TauriEventType = + | "global-shortcut-triggered" + | "lore-data-changed" + | "sync-status" + | "error-notification"; + +/** Payload types for each event */ +export interface TauriEventPayloads { + "global-shortcut-triggered": "toggle-window" | "quick-capture"; + "lore-data-changed": void; + "sync-status": { status: "started" | "completed" | "failed"; message?: string }; + "error-notification": { code: string; message: string }; +} + +/** + * Subscribe to a Tauri event with automatic cleanup. + * + * @param eventName The event to listen for + * @param handler Callback when event is received + * + * @example + * ```tsx + * useTauriEvent("lore-data-changed", () => { + * refetch(); + * }); + * ``` + */ +export function useTauriEvent( + eventName: T, + handler: (payload: TauriEventPayloads[T]) => void +): void { + // Memoize handler to avoid re-subscribing on every render + const stableHandler = useCallback(handler, [handler]); + + useEffect(() => { + let unlisten: UnlistenFn | undefined; + + // Subscribe to the event + listen(eventName, (event) => { + stableHandler(event.payload); + }).then((fn) => { + unlisten = fn; + }); + + // Cleanup on unmount + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [eventName, stableHandler]); +} + +/** + * Subscribe to multiple Tauri events. + * + * @param handlers Map of event names to handlers + * + * @example + * ```tsx + * useTauriEvents({ + * "lore-data-changed": () => refetch(), + * "sync-status": (status) => setStatus(status), + * }); + * ``` + */ +export function useTauriEvents( + handlers: Partial<{ + [K in TauriEventType]: (payload: TauriEventPayloads[K]) => void; + }> +): void { + useEffect(() => { + const unlisteners: UnlistenFn[] = []; + + // Subscribe to each event + for (const [eventName, handler] of Object.entries(handlers)) { + if (handler) { + listen(eventName, (event) => { + (handler as (p: unknown) => void)(event.payload); + }).then((unlisten) => { + unlisteners.push(unlisten); + }); + } + } + + // Cleanup all subscriptions on unmount + return () => { + for (const unlisten of unlisteners) { + unlisten(); + } + }; + }, [handlers]); +} diff --git a/tests/hooks/useTauriEvents.test.ts b/tests/hooks/useTauriEvents.test.ts new file mode 100644 index 0000000..e529521 --- /dev/null +++ b/tests/hooks/useTauriEvents.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useTauriEvent, useTauriEvents } from "@/hooks/useTauriEvents"; + +// Mock the listen function +const mockUnlisten = vi.fn(); +const mockListen = vi.fn().mockResolvedValue(mockUnlisten); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (...args: unknown[]) => mockListen(...args), +})); + +describe("useTauriEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to the specified event on mount", async () => { + const handler = vi.fn(); + renderHook(() => useTauriEvent("lore-data-changed", handler)); + + // Wait for the listen promise to resolve + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalledWith( + "lore-data-changed", + expect.any(Function) + ); + }); + }); + + it("calls the handler when event is received", async () => { + const handler = vi.fn(); + renderHook(() => useTauriEvent("global-shortcut-triggered", handler)); + + // Get the callback that was passed to listen + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalled(); + }); + + const eventCallback = mockListen.mock.calls[0][1]; + + // Simulate receiving an event + act(() => { + eventCallback({ payload: "quick-capture" }); + }); + + expect(handler).toHaveBeenCalledWith("quick-capture"); + }); + + it("calls unlisten on unmount", async () => { + const handler = vi.fn(); + const { unmount } = renderHook(() => + useTauriEvent("lore-data-changed", handler) + ); + + // Wait for subscription to be set up + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockUnlisten).toHaveBeenCalled(); + }); +}); + +describe("useTauriEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to multiple events", async () => { + const handlers = { + "lore-data-changed": vi.fn(), + "sync-status": vi.fn(), + }; + + renderHook(() => useTauriEvents(handlers)); + + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalledTimes(2); + }); + + expect(mockListen).toHaveBeenCalledWith( + "lore-data-changed", + expect.any(Function) + ); + expect(mockListen).toHaveBeenCalledWith("sync-status", expect.any(Function)); + }); + + it("cleans up all subscriptions on unmount", async () => { + const handlers = { + "lore-data-changed": vi.fn(), + "sync-status": vi.fn(), + }; + + const { unmount } = renderHook(() => useTauriEvents(handlers)); + + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalledTimes(2); + }); + + unmount(); + + // Should call unlisten for each subscription + expect(mockUnlisten).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/stores/focus-store.test.ts b/tests/stores/focus-store.test.ts index 9609f09..daeb850 100644 --- a/tests/stores/focus-store.test.ts +++ b/tests/stores/focus-store.test.ts @@ -4,6 +4,8 @@ import { makeFocusItem } from "../helpers/fixtures"; describe("useFocusStore", () => { beforeEach(() => { + // Clear persisted state before each test + localStorage.clear(); // Reset store between tests useFocusStore.setState({ current: null, @@ -189,4 +191,34 @@ describe("useFocusStore", () => { expect(useFocusStore.getState().error).toBe("something broke"); }); }); + + describe("persistence", () => { + it("persists current and queue to localStorage", () => { + const items = [ + makeFocusItem({ id: "a", title: "First" }), + makeFocusItem({ id: "b", title: "Second" }), + ]; + + useFocusStore.getState().setItems(items); + + const stored = localStorage.getItem("mc-focus-store"); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed.state.current.id).toBe("a"); + expect(parsed.state.queue).toHaveLength(1); + expect(parsed.state.queue[0].id).toBe("b"); + }); + + it("does not persist isLoading or error", () => { + useFocusStore.setState({ isLoading: true, error: "oops" }); + + const stored = localStorage.getItem("mc-focus-store"); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed.state).not.toHaveProperty("isLoading"); + expect(parsed.state).not.toHaveProperty("error"); + }); + }); });