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

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