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

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