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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 09:55:10 -05:00
parent df26eab361
commit 7404acdfb4
4 changed files with 333 additions and 0 deletions

85
src-tauri/src/watcher.rs Normal file
View File

@@ -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<PathBuf> {
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<RecommendedWatcher> {
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<Event, notify::Error>| {
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"));
}
}

104
src/hooks/useTauriEvents.ts Normal file
View File

@@ -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<T extends TauriEventType>(
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<TauriEventPayloads[T]>(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]);
}

View File

@@ -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);
});
});

View File

@@ -4,6 +4,8 @@ import { makeFocusItem } from "../helpers/fixtures";
describe("useFocusStore", () => { describe("useFocusStore", () => {
beforeEach(() => { beforeEach(() => {
// Clear persisted state before each test
localStorage.clear();
// Reset store between tests // Reset store between tests
useFocusStore.setState({ useFocusStore.setState({
current: null, current: null,
@@ -189,4 +191,34 @@ describe("useFocusStore", () => {
expect(useFocusStore.getState().error).toBe("something broke"); 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");
});
});
}); });