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:
85
src-tauri/src/watcher.rs
Normal file
85
src-tauri/src/watcher.rs
Normal 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
104
src/hooks/useTauriEvents.ts
Normal 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]);
|
||||||
|
}
|
||||||
112
tests/hooks/useTauriEvents.test.ts
Normal file
112
tests/hooks/useTauriEvents.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user