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

View File

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