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", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user