diff --git a/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts b/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts index 07bd8f9..d26af7f 100644 --- a/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts +++ b/cardinal/src/hooks/__tests__/useAppHotkeys.test.ts @@ -1,23 +1,23 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; +import { subscribeQuickLookKeydown } from '../../runtime/tauriEventRuntime'; import { openResultPath } from '../../utils/openResultPath'; import { useAppHotkeys } from '../useAppHotkeys'; -vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn(), -})); - vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })); +vi.mock('../../runtime/tauriEventRuntime', () => ({ + subscribeQuickLookKeydown: vi.fn(), +})); + vi.mock('../../utils/openResultPath', () => ({ openResultPath: vi.fn(), })); -const mockedListen = vi.mocked(listen); +const mockedSubscribeQuickLookKeydown = vi.mocked(subscribeQuickLookKeydown); const mockedInvoke = vi.mocked(invoke); const mockedOpenResultPath = vi.mocked(openResultPath); @@ -36,7 +36,7 @@ describe('useAppHotkeys', () => { const navigateSelection = vi.fn(); const triggerQuickLook = vi.fn(); - let quickLookListener: ((event: any) => void) | null; + let quickLookListener: ((payload: any) => void) | null; const renderHotkeys = (overrides: Partial = {}) => renderHook((props: HookProps) => useAppHotkeys(props), { @@ -56,12 +56,9 @@ describe('useAppHotkeys', () => { quickLookListener = null; mockedInvoke.mockResolvedValue(undefined); - mockedListen.mockImplementation(async (eventName: string, callback: (event: any) => void) => { - if (eventName === 'quicklook-keydown') { - quickLookListener = callback; - return quickLookUnlisten; - } - return vi.fn(); + mockedSubscribeQuickLookKeydown.mockImplementation((listener) => { + quickLookListener = listener; + return quickLookUnlisten; }); }); @@ -148,7 +145,7 @@ describe('useAppHotkeys', () => { expect(navigateSelection).toHaveBeenCalledWith(-1, { extend: false }); }); - it('handles Quick Look native keydown events and cleanup', async () => { + it('handles Quick Look runtime keydown events and cleanup', async () => { const { rerender, unmount } = renderHotkeys(); await waitFor(() => { @@ -157,14 +154,12 @@ describe('useAppHotkeys', () => { act(() => { quickLookListener?.({ - payload: { - keyCode: 125, - modifiers: { - shift: true, - control: false, - option: false, - command: false, - }, + keyCode: 125, + modifiers: { + shift: true, + control: false, + option: false, + command: false, }, }); }); @@ -182,20 +177,18 @@ describe('useAppHotkeys', () => { navigateSelection.mockClear(); act(() => { quickLookListener?.({ - payload: { - keyCode: 126, - modifiers: { - shift: false, - control: false, - option: false, - command: false, - }, + keyCode: 126, + modifiers: { + shift: false, + control: false, + option: false, + command: false, }, }); }); expect(navigateSelection).not.toHaveBeenCalled(); unmount(); - expect(quickLookUnlisten).toHaveBeenCalledTimes(1); + expect(quickLookUnlisten).toHaveBeenCalled(); }); }); diff --git a/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts b/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts index 483bda2..87524c2 100644 --- a/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts +++ b/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts @@ -1,19 +1,24 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { listen } from '@tauri-apps/api/event'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { + subscribeLifecycleState, + subscribeQuickLaunch, + subscribeStatusBarUpdate, + subscribeWindowDragDrop, +} from '../../runtime/tauriEventRuntime'; import { useAppWindowListeners } from '../useAppWindowListeners'; -vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn(), +vi.mock('../../runtime/tauriEventRuntime', () => ({ + subscribeStatusBarUpdate: vi.fn(), + subscribeLifecycleState: vi.fn(), + subscribeQuickLaunch: vi.fn(), + subscribeWindowDragDrop: vi.fn(), })); -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: vi.fn(), -})); - -const mockedListen = vi.mocked(listen); -const mockedGetCurrentWindow = vi.mocked(getCurrentWindow); +const mockedSubscribeStatusBarUpdate = vi.mocked(subscribeStatusBarUpdate); +const mockedSubscribeLifecycleState = vi.mocked(subscribeLifecycleState); +const mockedSubscribeQuickLaunch = vi.mocked(subscribeQuickLaunch); +const mockedSubscribeWindowDragDrop = vi.mocked(subscribeWindowDragDrop); type HookProps = { activeTab: 'files' | 'events'; @@ -41,8 +46,12 @@ describe('useAppWindowListeners', () => { const setEventFilterQuery = vi.fn(); const updateHistoryFromInput = vi.fn(); - let tauriListeners: Record void>; - let dragDropListener: ((event: any) => void) | null; + let statusCallback: + | ((payload: { scannedFiles: number; processedEvents: number; rescanErrors: number }) => void) + | null; + let lifecycleCallback: ((status: 'Initializing' | 'Updating' | 'Ready') => void) | null; + let quickLaunchCallback: (() => void) | null; + let dragDropCallback: ((event: any) => void) | null; const renderWindowListeners = (overrides: Partial = {}) => renderHook((props: HookProps) => useAppWindowListeners(props), { @@ -60,47 +69,52 @@ describe('useAppWindowListeners', () => { beforeEach(() => { vi.clearAllMocks(); - tauriListeners = {}; - dragDropListener = null; + statusCallback = null; + lifecycleCallback = null; + quickLaunchCallback = null; + dragDropCallback = null; document.documentElement.removeAttribute('data-window-focused'); - mockedListen.mockImplementation(async (eventName: string, callback: (event: any) => void) => { - tauriListeners[eventName] = callback; - if (eventName === 'status_bar_update') return statusUnlisten; - if (eventName === 'app_lifecycle_state') return lifecycleUnlisten; - if (eventName === 'quick_launch') return quickLaunchUnlisten; - return vi.fn(); + mockedSubscribeStatusBarUpdate.mockImplementation((listener) => { + statusCallback = listener; + return statusUnlisten; + }); + mockedSubscribeLifecycleState.mockImplementation((listener) => { + lifecycleCallback = listener; + return lifecycleUnlisten; + }); + mockedSubscribeQuickLaunch.mockImplementation((listener) => { + quickLaunchCallback = listener; + return quickLaunchUnlisten; + }); + mockedSubscribeWindowDragDrop.mockImplementation((listener) => { + dragDropCallback = listener; + return dragDropUnlisten; }); - - mockedGetCurrentWindow.mockReturnValue({ - onDragDropEvent: vi.fn(async (callback: (event: any) => void) => { - dragDropListener = callback; - return dragDropUnlisten; - }), - } as unknown as ReturnType); }); - it('subscribes to tauri events and dispatches payloads to handlers', async () => { + it('subscribes to runtime events and dispatches payloads to handlers', async () => { renderWindowListeners(); await waitFor(() => { - expect(mockedListen).toHaveBeenCalledTimes(3); + expect(mockedSubscribeStatusBarUpdate).toHaveBeenCalledTimes(1); + expect(mockedSubscribeLifecycleState).toHaveBeenCalledTimes(1); + expect(mockedSubscribeQuickLaunch).toHaveBeenCalledTimes(1); + expect(mockedSubscribeWindowDragDrop).toHaveBeenCalledTimes(1); }); act(() => { - tauriListeners.status_bar_update?.({ - payload: { scannedFiles: 11, processedEvents: 22, rescanErrors: 3 }, - }); + statusCallback?.({ scannedFiles: 11, processedEvents: 22, rescanErrors: 3 }); }); expect(handleStatusUpdate).toHaveBeenCalledWith(11, 22, 3); act(() => { - tauriListeners.app_lifecycle_state?.({ payload: 'Ready' }); + lifecycleCallback?.('Ready'); }); expect(setLifecycleState).toHaveBeenCalledWith('Ready'); act(() => { - tauriListeners.quick_launch?.({}); + quickLaunchCallback?.(); }); expect(focusSearchInput).toHaveBeenCalledTimes(1); }); @@ -109,11 +123,11 @@ describe('useAppWindowListeners', () => { const { rerender } = renderWindowListeners(); await waitFor(() => { - expect(dragDropListener).not.toBeNull(); + expect(dragDropCallback).not.toBeNull(); }); act(() => { - dragDropListener?.({ + dragDropCallback?.({ payload: { type: 'drop', paths: [' /tmp/file-a '] }, }); }); @@ -133,20 +147,16 @@ describe('useAppWindowListeners', () => { }); act(() => { - dragDropListener?.({ + dragDropCallback?.({ payload: { type: 'drop', paths: [' /tmp/file-b '] }, }); }); expect(setEventFilterQuery).toHaveBeenCalledWith('"/tmp/file-b"'); }); - it('syncs window focus attribute and cleans up listeners on unmount', async () => { + it('syncs window focus attribute and cleans up runtime subscriptions on unmount', async () => { const { unmount } = renderWindowListeners(); - await waitFor(() => { - expect(dragDropListener).not.toBeNull(); - }); - act(() => { window.dispatchEvent(new Event('blur')); }); diff --git a/cardinal/src/hooks/__tests__/useDataLoader.test.ts b/cardinal/src/hooks/__tests__/useDataLoader.test.ts index f571bb6..6e46b15 100644 --- a/cardinal/src/hooks/__tests__/useDataLoader.test.ts +++ b/cardinal/src/hooks/__tests__/useDataLoader.test.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { subscribeIconUpdate } from '../../runtime/tauriEventRuntime'; import type { SlabIndex } from '../../types/slab'; import { useDataLoader } from '../useDataLoader'; @@ -9,12 +9,12 @@ vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })); -vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn(), +vi.mock('../../runtime/tauriEventRuntime', () => ({ + subscribeIconUpdate: vi.fn(), })); const mockedInvoke = vi.mocked(invoke); -const mockedListen = vi.mocked(listen); +const mockedSubscribeIconUpdate = vi.mocked(subscribeIconUpdate); type HookProps = { results: SlabIndex[]; version: number }; const buildNodeInfo = (slabIndex: SlabIndex) => ({ @@ -32,8 +32,10 @@ const renderDataLoader = (initialProps: HookProps) => }); describe('useDataLoader', () => { + const iconUpdateUnlisten = vi.fn(); + beforeEach(() => { - mockedListen.mockResolvedValue((() => {}) as () => void); + mockedSubscribeIconUpdate.mockImplementation(() => iconUpdateUnlisten); mockedInvoke.mockImplementation((command: string, payload?: unknown) => { if (command !== 'get_nodes_info') { return Promise.resolve(null); @@ -97,4 +99,11 @@ describe('useDataLoader', () => { await waitFor(() => expect(result.current.cache.size).toBe(2)); expect(mockedInvoke).toHaveBeenCalledTimes(2); }); + + it('cleans up icon update subscription on unmount', async () => { + const { unmount } = renderDataLoader({ results: [11 as SlabIndex], version: 1 }); + unmount(); + + expect(iconUpdateUnlisten).toHaveBeenCalled(); + }); }); diff --git a/cardinal/src/hooks/__tests__/useRecentFSEvents.test.ts b/cardinal/src/hooks/__tests__/useRecentFSEvents.test.ts new file mode 100644 index 0000000..60cd74b --- /dev/null +++ b/cardinal/src/hooks/__tests__/useRecentFSEvents.test.ts @@ -0,0 +1,60 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { subscribeFSEventsBatch } from '../../runtime/tauriEventRuntime'; +import { useRecentFSEvents } from '../useRecentFSEvents'; + +vi.mock('../../runtime/tauriEventRuntime', () => ({ + subscribeFSEventsBatch: vi.fn(), +})); + +const mockedSubscribeFSEventsBatch = vi.mocked(subscribeFSEventsBatch); + +describe('useRecentFSEvents', () => { + const unlisten = vi.fn(); + let fsEventsBatchListener: ((payload: any) => void) | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + fsEventsBatchListener = null; + + mockedSubscribeFSEventsBatch.mockImplementation((callback: (payload: any) => void) => { + fsEventsBatchListener = callback; + return unlisten; + }); + }); + + it('stores and filters streamed events when active', async () => { + const { result } = renderHook(() => + useRecentFSEvents({ caseSensitive: false, isActive: true }), + ); + + await waitFor(() => { + expect(fsEventsBatchListener).not.toBeNull(); + }); + + act(() => { + fsEventsBatchListener?.([ + { path: '/tmp/Alpha.txt', eventId: 1, timestamp: 1, flagBits: 0 }, + { path: '/tmp/beta.txt', eventId: 2, timestamp: 2, flagBits: 0 }, + ]); + }); + + expect(result.current.filteredEvents).toHaveLength(2); + + act(() => { + result.current.setEventFilterQuery('alpha'); + }); + + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0]?.path).toBe('/tmp/Alpha.txt'); + }); + + it('cleans up runtime subscription on unmount', async () => { + const { unmount } = renderHook(() => + useRecentFSEvents({ caseSensitive: false, isActive: true }), + ); + unmount(); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cardinal/src/hooks/useAppHotkeys.ts b/cardinal/src/hooks/useAppHotkeys.ts index 9062e69..2d59513 100644 --- a/cardinal/src/hooks/useAppHotkeys.ts +++ b/cardinal/src/hooks/useAppHotkeys.ts @@ -1,9 +1,11 @@ import { useEffect, useRef } from 'react'; import type { MutableRefObject } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import type { UnlistenFn } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import type { StatusTabKey } from '../components/StatusBar'; +import { + subscribeQuickLookKeydown, + type QuickLookKeydownPayload, +} from '../runtime/tauriEventRuntime'; import { openResultPath } from '../utils/openResultPath'; import { useStableEvent } from './useStableEvent'; @@ -20,17 +22,6 @@ type UseAppHotkeysOptions = { triggerQuickLook: () => void; }; -type QuickLookKeydownPayload = { - keyCode: number; - characters?: string | null; - modifiers: { - shift: boolean; - control: boolean; - option: boolean; - command: boolean; - }; -}; - const QUICK_LOOK_KEYCODE_DOWN = 125; const QUICK_LOOK_KEYCODE_UP = 126; @@ -148,43 +139,29 @@ export function useAppHotkeys({ return () => window.removeEventListener('keydown', handleKeyDown); }, [handleMetaShortcut, handleFilesNavigation]); - useEffect(() => { - let unlisten: UnlistenFn | null = null; - - const setup = async () => { - try { - unlisten = await listen('quicklook-keydown', (event) => { - if (keyboardStateRef.current.activeTab !== 'files') { - return; - } - - const payload = event.payload; - if (!payload || !selectedIndicesRef.current.length) { - return; - } - - const { keyCode, modifiers } = payload; - if (modifiers.command || modifiers.option || modifiers.control) { - return; - } - - if (keyCode === QUICK_LOOK_KEYCODE_DOWN) { - navigateSelection(1, { extend: modifiers.shift }); - } else if (keyCode === QUICK_LOOK_KEYCODE_UP) { - navigateSelection(-1, { extend: modifiers.shift }); - } - }); - } catch (error) { - console.error('Failed to subscribe to Quick Look key events', error); - } - }; + const handleQuickLookKeydown = useStableEvent((payload: QuickLookKeydownPayload) => { + if (keyboardStateRef.current.activeTab !== 'files') { + return; + } + + if (!payload || !selectedIndicesRef.current.length) { + return; + } - void setup(); + const { keyCode, modifiers } = payload; + if (modifiers.command || modifiers.option || modifiers.control) { + return; + } - return () => { - if (unlisten) { - unlisten(); - } - }; - }, [navigateSelection, selectedIndicesRef]); + if (keyCode === QUICK_LOOK_KEYCODE_DOWN) { + navigateSelection(1, { extend: modifiers.shift }); + } else if (keyCode === QUICK_LOOK_KEYCODE_UP) { + navigateSelection(-1, { extend: modifiers.shift }); + } + }); + + useEffect(() => { + const unlistenQuickLook = subscribeQuickLookKeydown(handleQuickLookKeydown); + return unlistenQuickLook; + }, [handleQuickLookKeydown]); } diff --git a/cardinal/src/hooks/useAppWindowListeners.ts b/cardinal/src/hooks/useAppWindowListeners.ts index 837dd05..958e473 100644 --- a/cardinal/src/hooks/useAppWindowListeners.ts +++ b/cardinal/src/hooks/useAppWindowListeners.ts @@ -1,9 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import type { DragDropEvent } from '@tauri-apps/api/window'; +import { useEffect, useState } from 'react'; import type { StatusTabKey } from '../components/StatusBar'; +import { + subscribeLifecycleState, + subscribeQuickLaunch, + subscribeStatusBarUpdate, + subscribeWindowDragDrop, + type WindowDragDropEvent, +} from '../runtime/tauriEventRuntime'; import type { AppLifecycleStatus, StatusBarUpdatePayload } from '../types/ipc'; import { useStableEvent } from './useStableEvent'; @@ -45,45 +48,27 @@ export function useAppWindowListeners({ } return document.hasFocus(); }); - const isMountedRef = useRef(false); - useEffect(() => { - isMountedRef.current = true; - let unlistenStatus: UnlistenFn | undefined; - let unlistenLifecycle: UnlistenFn | undefined; - let unlistenQuickLaunch: UnlistenFn | undefined; - - const setupListeners = async (): Promise => { - unlistenStatus = await listen('status_bar_update', (event) => { - if (!isMountedRef.current) return; - const payload = event.payload; - if (!payload) return; - const { scannedFiles, processedEvents, rescanErrors } = payload; - handleStatusUpdate(scannedFiles, processedEvents, rescanErrors); - }); - - unlistenLifecycle = await listen('app_lifecycle_state', (event) => { - if (!isMountedRef.current) return; - const status = event.payload; - if (!status) return; - setLifecycleState(status); - }); - - unlistenQuickLaunch = await listen('quick_launch', () => { - if (!isMountedRef.current) return; - focusSearchInput(); - }); - }; + const unlistenStatus = subscribeStatusBarUpdate((payload: StatusBarUpdatePayload) => { + const { scannedFiles, processedEvents, rescanErrors } = payload; + handleStatusUpdate(scannedFiles, processedEvents, rescanErrors); + }); + return unlistenStatus; + }, [handleStatusUpdate]); - void setupListeners(); + useEffect(() => { + const unlistenLifecycle = subscribeLifecycleState((status: AppLifecycleStatus) => { + setLifecycleState(status); + }); + return unlistenLifecycle; + }, [setLifecycleState]); - return () => { - isMountedRef.current = false; - unlistenStatus?.(); - unlistenLifecycle?.(); - unlistenQuickLaunch?.(); - }; - }, [focusSearchInput, handleStatusUpdate, setLifecycleState]); + useEffect(() => { + const unlistenQuickLaunch = subscribeQuickLaunch(() => { + focusSearchInput(); + }); + return unlistenQuickLaunch; + }, [focusSearchInput]); useEffect(() => { if (typeof window === 'undefined') { @@ -108,7 +93,7 @@ export function useAppWindowListeners({ document.documentElement.dataset.windowFocused = isWindowFocused ? 'true' : 'false'; }, [isWindowFocused]); - const handleWindowDragDrop = useStableEvent((event: TauriEvent) => { + const handleWindowDragDrop = useStableEvent((event: WindowDragDropEvent) => { const payload = event.payload; if (payload.type !== 'drop') { return; @@ -129,21 +114,8 @@ export function useAppWindowListeners({ }); useEffect(() => { - let unlisten: UnlistenFn | null = null; - getCurrentWindow() - .onDragDropEvent(handleWindowDragDrop) - .then((unsubscribe) => { - unlisten = unsubscribe; - }) - .catch((error) => { - console.error('Failed to register drag-drop listener', error); - }); - - return () => { - if (unlisten) { - unlisten(); - } - }; + const unlistenDragDrop = subscribeWindowDragDrop(handleWindowDragDrop); + return unlistenDragDrop; }, [handleWindowDragDrop]); return { isWindowFocused }; diff --git a/cardinal/src/hooks/useDataLoader.ts b/cardinal/src/hooks/useDataLoader.ts index 49e3b5a..fec2a34 100644 --- a/cardinal/src/hooks/useDataLoader.ts +++ b/cardinal/src/hooks/useDataLoader.ts @@ -1,12 +1,9 @@ import { useCallback, useRef, useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import type { UnlistenFn } from '@tauri-apps/api/event'; +import { subscribeIconUpdate } from '../runtime/tauriEventRuntime'; import type { NodeInfoResponse, SearchResultItem } from '../types/search'; import type { SlabIndex } from '../types/slab'; -import type { IconUpdateWirePayload } from '../types/ipc'; - -type IconUpdateEventPayload = readonly IconUpdateWirePayload[] | null | undefined; +import type { IconUpdatePayload } from '../types/ipc'; export type DataLoaderCache = Map; type IconOverrideValue = string | undefined; @@ -44,54 +41,40 @@ export function useDataLoader(results: SlabIndex[], dataResultsVersion: number) }, [dataResultsVersion]); useEffect(() => { - let unlistenIconUpdate: UnlistenFn | undefined; - (async () => { - try { - unlistenIconUpdate = await listen('icon_update', (event) => { - const updates = event?.payload; - if (!Array.isArray(updates) || updates.length === 0) { - return; - } - - setCache((prev) => { - let nextCache: DataLoaderCache | null = null; + const unlistenIconUpdate = subscribeIconUpdate((updates: readonly IconUpdatePayload[]) => { + if (updates.length === 0) { + return; + } - updates.forEach((update) => { - if (!update || typeof update.slabIndex !== 'number') { - return; - } + setCache((prev) => { + let nextCache: DataLoaderCache | null = null; - const slabIndex = update.slabIndex as SlabIndex; - const nextIcon = update.icon; - iconOverridesRef.current.set(slabIndex, nextIcon); + updates.forEach((update) => { + const slabIndex = update.slabIndex; + const nextIcon = update.icon; + iconOverridesRef.current.set(slabIndex, nextIcon); - const current = prev.get(slabIndex); - if (!current || current.icon === nextIcon) { - return; - } + const current = prev.get(slabIndex); + if (!current || current.icon === nextIcon) { + return; + } - if (nextCache === null) { - nextCache = new Map(prev); - } + if (nextCache === null) { + nextCache = new Map(prev); + } - nextCache.set(slabIndex, { ...current, icon: nextIcon }); - }); + nextCache.set(slabIndex, { ...current, icon: nextIcon }); + }); - if (nextCache === null) { - return prev; - } + if (nextCache === null) { + return prev; + } - cacheRef.current = nextCache; - return nextCache; - }); - }); - } catch (error) { - console.error('Failed to listen icon_update', error); - } - })(); - return () => { - unlistenIconUpdate?.(); - }; + cacheRef.current = nextCache; + return nextCache; + }); + }); + return unlistenIconUpdate; }, []); const releaseLoadingBatch = useCallback((slabIndices: readonly SlabIndex[]) => { diff --git a/cardinal/src/hooks/useRecentFSEvents.ts b/cardinal/src/hooks/useRecentFSEvents.ts index d3b9f9e..1df0f8b 100644 --- a/cardinal/src/hooks/useRecentFSEvents.ts +++ b/cardinal/src/hooks/useRecentFSEvents.ts @@ -1,25 +1,10 @@ import { useEffect, useMemo, useReducer, useRef, useState } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import type { UnlistenFn } from '@tauri-apps/api/event'; +import { subscribeFSEventsBatch } from '../runtime/tauriEventRuntime'; import type { RecentEventPayload } from '../types/ipc'; // Listen to batched file-system events and expose filtered projections for the UI. const MAX_EVENTS = 10000; -const isRecentEventPayload = (value: unknown): value is RecentEventPayload => { - if (!value || typeof value !== 'object') { - return false; - } - - const candidate = value as Record; - return ( - typeof candidate.path === 'string' && - typeof candidate.eventId === 'number' && - typeof candidate.timestamp === 'number' && - typeof candidate.flagBits === 'number' - ); -}; - type RecentEventRecord = { payload: RecentEventPayload; path: string; @@ -36,7 +21,6 @@ type RecentEventsOptions = { export function useRecentFSEvents({ caseSensitive, isActive }: RecentEventsOptions) { const eventsRef = useRef([]); const [eventFilterQuery, setEventFilterQuery] = useState(''); - const isMountedRef = useRef(false); const isActiveRef = useRef(isActive); const [bufferVersion, bumpBufferVersion] = useReducer((count: number) => count + 1, 0); @@ -48,45 +32,22 @@ export function useRecentFSEvents({ caseSensitive, isActive }: RecentEventsOptio }, [isActive]); useEffect(() => { - isMountedRef.current = true; - let unlistenEvents: UnlistenFn | undefined; - - // Capture streamed events from the Rust side and keep only the latest N entries. - const setupListener = async () => { - try { - unlistenEvents = await listen('fs_events_batch', (event) => { - if (!isMountedRef.current) return; - const payload = event?.payload; - if (!Array.isArray(payload) || payload.length === 0) return; - - const validEvents = payload.filter(isRecentEventPayload); - if (validEvents.length === 0) { - return; - } - - const previous = eventsRef.current; - const normalizedBatch = validEvents.map(toEventRecord); - let updated = [...previous, ...normalizedBatch]; - if (updated.length > MAX_EVENTS) { - updated = updated.slice(updated.length - MAX_EVENTS); - } - eventsRef.current = updated; - - if (isActiveRef.current) { - bumpBufferVersion(); - } - }); - } catch (error) { - console.error('Failed to listen for file events', error); + const unlistenEvents = subscribeFSEventsBatch((payload: RecentEventPayload[]) => { + if (payload.length === 0) return; + + const previous = eventsRef.current; + const normalizedBatch = payload.map(toEventRecord); + let updated = [...previous, ...normalizedBatch]; + if (updated.length > MAX_EVENTS) { + updated = updated.slice(updated.length - MAX_EVENTS); } - }; + eventsRef.current = updated; - void setupListener(); - - return () => { - isMountedRef.current = false; - unlistenEvents?.(); - }; + if (isActiveRef.current) { + bumpBufferVersion(); + } + }); + return unlistenEvents; }, []); const filteredEvents = useMemo( diff --git a/cardinal/src/main.tsx b/cardinal/src/main.tsx index a53c2af..82a0818 100644 --- a/cardinal/src/main.tsx +++ b/cardinal/src/main.tsx @@ -3,12 +3,14 @@ import ReactDOM from 'react-dom/client'; import './i18n/config'; import App from './App'; import { initializeAppMenu } from './menu'; +import { initializeTauriEventRuntime } from './runtime/tauriEventRuntime'; import { initializeGlobalShortcuts } from './utils/globalShortcuts'; import { initializeThemePreference } from './theme'; initializeThemePreference(); void initializeGlobalShortcuts(); void initializeAppMenu(); +void initializeTauriEventRuntime(); const rootElement = document.getElementById('root'); diff --git a/cardinal/src/runtime/tauriEventRuntime.ts b/cardinal/src/runtime/tauriEventRuntime.ts new file mode 100644 index 0000000..350f7c5 --- /dev/null +++ b/cardinal/src/runtime/tauriEventRuntime.ts @@ -0,0 +1,194 @@ +import { listen } from '@tauri-apps/api/event'; +import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import type { DragDropEvent } from '@tauri-apps/api/window'; +import type { + AppLifecycleStatus, + IconUpdatePayload, + IconUpdateWirePayload, + RecentEventPayload, + StatusBarUpdatePayload, +} from '../types/ipc'; +import type { SlabIndex } from '../types/slab'; + +export type QuickLookKeydownPayload = { + keyCode: number; + characters?: string | null; + modifiers: { + shift: boolean; + control: boolean; + option: boolean; + command: boolean; + }; +}; + +type Listener = (payload: T) => void; +export type WindowDragDropEvent = TauriEvent; + +const statusBarUpdateListeners = new Set>(); +const lifecycleStateListeners = new Set>(); +const quickLaunchListeners = new Set>(); +const fsEventsBatchListeners = new Set>(); +const iconUpdateListeners = new Set>(); +const quickLookKeydownListeners = new Set>(); +const windowDragDropListeners = new Set>(); + +let initPromise: Promise | null = null; + +const emit = (listeners: Set>, payload: T): void => { + listeners.forEach((listener) => { + listener(payload); + }); +}; + +const subscribe = (listeners: Set>, listener: Listener): UnlistenFn => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const isRecentEventPayload = (value: unknown): value is RecentEventPayload => { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.path === 'string' && + typeof candidate.eventId === 'number' && + typeof candidate.timestamp === 'number' && + typeof candidate.flagBits === 'number' + ); +}; + +const normalizeRecentEvents = (payload: unknown): RecentEventPayload[] => { + if (!Array.isArray(payload)) { + return []; + } + return payload.filter(isRecentEventPayload); +}; + +const isIconUpdateWirePayload = (value: unknown): value is IconUpdateWirePayload => { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.slabIndex === 'number' && + (typeof candidate.icon === 'string' || typeof candidate.icon === 'undefined') + ); +}; + +const normalizeIconUpdates = (payload: unknown): IconUpdatePayload[] => { + if (!Array.isArray(payload)) { + return []; + } + return payload + .filter(isIconUpdateWirePayload) + .map((item) => ({ slabIndex: item.slabIndex as SlabIndex, icon: item.icon })); +}; + +export const initializeTauriEventRuntime = (): Promise => { + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + const setupTasks: Promise[] = [ + listen('status_bar_update', (event) => { + const payload = event.payload; + if (!payload) return; + emit(statusBarUpdateListeners, payload); + }).catch((error) => { + console.error('Failed to register status_bar_update listener', error); + }), + listen('app_lifecycle_state', (event) => { + const status = event.payload; + if (!status) return; + emit(lifecycleStateListeners, status); + }).catch((error) => { + console.error('Failed to register app_lifecycle_state listener', error); + }), + listen('quick_launch', () => { + emit(quickLaunchListeners, undefined); + }).catch((error) => { + console.error('Failed to register quick_launch listener', error); + }), + listen('fs_events_batch', (event) => { + const payload = normalizeRecentEvents(event.payload); + if (payload.length === 0) return; + emit(fsEventsBatchListeners, payload); + }).catch((error) => { + console.error('Failed to register fs_events_batch listener', error); + }), + listen('icon_update', (event) => { + const payload = normalizeIconUpdates(event.payload); + if (payload.length === 0) return; + emit(iconUpdateListeners, payload); + }).catch((error) => { + console.error('Failed to register icon_update listener', error); + }), + listen('quicklook-keydown', (event) => { + const payload = event.payload; + if (!payload) return; + emit(quickLookKeydownListeners, payload); + }).catch((error) => { + console.error('Failed to register quicklook-keydown listener', error); + }), + getCurrentWindow() + .onDragDropEvent((event) => { + emit(windowDragDropListeners, event); + }) + .catch((error) => { + console.error('Failed to register drag-drop listener', error); + }), + ]; + + await Promise.allSettled(setupTasks); + })(); + + return initPromise; +}; + +export const subscribeStatusBarUpdate = ( + listener: Listener, +): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(statusBarUpdateListeners, listener); +}; + +export const subscribeLifecycleState = (listener: Listener): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(lifecycleStateListeners, listener); +}; + +export const subscribeQuickLaunch = (listener: () => void): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(quickLaunchListeners, listener); +}; + +export const subscribeFSEventsBatch = (listener: Listener): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(fsEventsBatchListeners, listener); +}; + +export const subscribeIconUpdate = ( + listener: Listener, +): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(iconUpdateListeners, listener); +}; + +export const subscribeQuickLookKeydown = ( + listener: Listener, +): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(quickLookKeydownListeners, listener); +}; + +export const subscribeWindowDragDrop = (listener: Listener): UnlistenFn => { + void initializeTauriEventRuntime(); + return subscribe(windowDragDropListeners, listener); +};