diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 99ea85a..f28707d 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -127,9 +127,7 @@ function App() { const { showContextMenu: showFilesContextMenu, showHeaderContextMenu: showFilesHeaderContextMenu, - } = useContextMenu(autoFitColumns, toggleQuickLook, () => - activeTab === 'files' ? selectedPaths : [], - ); + } = useContextMenu(autoFitColumns, toggleQuickLook); const { showContextMenu: showEventsContextMenu, @@ -311,14 +309,21 @@ function App() { const handleRowContextMenu = useCallback( (event: ReactMouseEvent, path: string, rowIndex: number) => { - if (!selectedIndexSet.has(rowIndex)) { + const isRowSelected = selectedIndexSet.has(rowIndex); + if (!isRowSelected) { selectSingleRow(rowIndex); } - if (path) { - showFilesContextMenu(event, path); - } + const targetPaths = isRowSelected && selectedPaths.length > 0 ? selectedPaths : [path]; + showFilesContextMenu(event, targetPaths); + }, + [selectedIndexSet, selectedPaths, selectSingleRow, showFilesContextMenu], + ); + + const handleEventsContextMenu = useCallback( + (event: ReactMouseEvent, path: string) => { + showEventsContextMenu(event, [path]); }, - [selectedIndexSet, selectSingleRow, showFilesContextMenu], + [showEventsContextMenu], ); const renderRow = useCallback( @@ -444,7 +449,7 @@ function App() { ref={eventsPanelRef} events={filteredEvents} onResizeStart={onEventResizeStart} - onContextMenu={showEventsContextMenu} + onContextMenu={handleEventsContextMenu} onHeaderContextMenu={showEventsHeaderContextMenu} searchQuery={eventFilterQuery} caseInsensitive={!caseSensitive} diff --git a/cardinal/src/__tests__/App.contextMenu.test.tsx b/cardinal/src/__tests__/App.contextMenu.test.tsx new file mode 100644 index 0000000..1217d21 --- /dev/null +++ b/cardinal/src/__tests__/App.contextMenu.test.tsx @@ -0,0 +1,329 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { forwardRef } from 'react'; +import type { CSSProperties } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App'; + +const mocks = vi.hoisted(() => ({ + showFilesContextMenu: vi.fn(), + showEventsContextMenu: vi.fn(), + selectSingleRow: vi.fn(), + useContextMenuMock: vi.fn(), +})); + +const testState = vi.hoisted(() => ({ + activeTab: 'files' as 'files' | 'events', + selectedIndices: [0] as number[], + selectedPaths: ['/stale-a', '/stale-b'] as string[], +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + language: 'en-US', + changeLanguage: vi.fn().mockResolvedValue(undefined), + }, + }), +})); + +vi.mock('../components/SearchBar', () => ({ + SearchBar: ({ inputRef }: { inputRef: React.Ref }) => ( + + ), +})); + +vi.mock('../components/FileRow', () => ({ + FileRow: ({ + item, + rowIndex, + onContextMenu, + }: { + item: { path: string }; + rowIndex: number; + onContextMenu?: ( + event: React.MouseEvent, + path: string, + rowIndex: number, + ) => void; + }) => ( +
onContextMenu?.(event, item.path, rowIndex)} + /> + ), +})); + +vi.mock('../components/FilesTabContent', () => ({ + FilesTabContent: ({ + renderRow, + }: { + renderRow: ( + rowIndex: number, + item: { path: string } | undefined, + rowStyle: CSSProperties, + ) => React.ReactNode; + }) => ( +
+ {renderRow(1, { path: '/clicked' }, {} as CSSProperties)} +
+ ), +})); + +vi.mock('../components/PermissionOverlay', () => ({ + PermissionOverlay: () => null, +})); + +vi.mock('../components/PreferencesOverlay', () => ({ + default: () => null, +})); + +vi.mock('../components/StatusBar', () => ({ + default: () => null, +})); + +vi.mock('../components/FSEventsPanel', () => ({ + default: forwardRef(function MockFSEventsPanel( + { + onContextMenu, + }: { + onContextMenu: (event: React.MouseEvent, path: string) => void; + }, + _ref: React.ForwardedRef, + ) { + return ( +
{ + onContextMenu(event, '/event-path'); + }} + /> + ); + }), +})); + +vi.mock('../hooks/useFileSearch', () => ({ + useFileSearch: () => ({ + state: { + results: [101, 202], + resultsVersion: 1, + scannedFiles: 0, + processedEvents: 0, + rescanErrors: 0, + currentQuery: '', + highlightTerms: [], + showLoadingUI: false, + initialFetchCompleted: true, + durationMs: 0, + resultCount: 2, + searchError: null, + lifecycleState: 'Ready', + }, + searchParams: { + query: '', + caseSensitive: false, + }, + updateSearchParams: vi.fn(), + queueSearch: vi.fn(), + handleStatusUpdate: vi.fn(), + setLifecycleState: vi.fn(), + requestRescan: vi.fn(), + }), +})); + +vi.mock('../hooks/useColumnResize', () => ({ + useColumnResize: () => ({ + colWidths: { + filename: 200, + path: 300, + size: 100, + modified: 120, + created: 120, + }, + onResizeStart: vi.fn(() => vi.fn()), + autoFitColumns: vi.fn(), + }), +})); + +vi.mock('../hooks/useEventColumnWidths', () => ({ + useEventColumnWidths: () => ({ + eventColWidths: { + time: 120, + event: 180, + name: 180, + path: 260, + }, + onEventResizeStart: vi.fn(), + autoFitEventColumns: vi.fn(), + }), +})); + +vi.mock('../hooks/useRecentFSEvents', () => ({ + useRecentFSEvents: () => ({ + filteredEvents: [], + eventFilterQuery: '', + setEventFilterQuery: vi.fn(), + }), +})); + +vi.mock('../hooks/useRemoteSort', () => ({ + DEFAULT_SORTABLE_RESULT_THRESHOLD: 20000, + useRemoteSort: () => ({ + sortState: null, + displayedResults: [101, 202], + displayedResultsVersion: 1, + sortThreshold: 20000, + setSortThreshold: vi.fn(), + canSort: true, + isSorting: false, + sortDisabledTooltip: null, + sortButtonsDisabled: false, + handleSortToggle: vi.fn(), + }), +})); + +vi.mock('../hooks/useSelection', () => ({ + useSelection: () => ({ + selectedIndices: testState.selectedIndices, + selectedIndicesRef: { current: testState.selectedIndices }, + activeRowIndex: null, + selectedPaths: testState.selectedPaths, + handleRowSelect: vi.fn(), + selectSingleRow: mocks.selectSingleRow, + clearSelection: vi.fn(), + moveSelection: vi.fn(), + }), +})); + +vi.mock('../hooks/useQuickLook', () => ({ + useQuickLook: () => ({ + toggleQuickLook: vi.fn(), + updateQuickLook: vi.fn(), + closeQuickLook: vi.fn(), + }), +})); + +vi.mock('../hooks/useSearchHistory', () => ({ + useSearchHistory: () => ({ + handleInputChange: vi.fn(), + navigate: vi.fn(), + ensureTailValue: vi.fn(), + resetCursorToTail: vi.fn(), + }), +})); + +vi.mock('../hooks/useFullDiskAccessPermission', () => ({ + useFullDiskAccessPermission: () => ({ + status: 'granted', + isChecking: false, + requestPermission: vi.fn(), + }), +})); + +vi.mock('../hooks/useAppPreferences', () => ({ + useAppPreferences: () => ({ + isPreferencesOpen: false, + closePreferences: vi.fn(), + trayIconEnabled: false, + setTrayIconEnabled: vi.fn(), + watchRoot: '/', + defaultWatchRoot: '/', + ignorePaths: ['/Volumes'], + defaultIgnorePaths: ['/Volumes'], + preferencesResetToken: 0, + handleWatchConfigChange: vi.fn(), + handleResetPreferences: vi.fn(), + }), +})); + +vi.mock('../hooks/useAppWindowListeners', () => ({ + useAppWindowListeners: () => ({ isWindowFocused: true }), +})); + +vi.mock('../hooks/useAppHotkeys', () => ({ + useAppHotkeys: () => undefined, +})); + +vi.mock('../hooks/useFilesTabState', () => ({ + useFilesTabState: () => ({ + activeTab: testState.activeTab, + setActiveTab: vi.fn(), + isSearchFocused: false, + handleSearchFocus: vi.fn(), + handleSearchBlur: vi.fn(), + }), +})); + +vi.mock('../hooks/useContextMenu', () => ({ + useContextMenu: (...args: unknown[]) => mocks.useContextMenuMock(...args), +})); + +vi.mock('../hooks/useStableEvent', () => ({ + useStableEvent: any>(handler: T): T => handler, +})); + +describe('App context menu regression', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.activeTab = 'files'; + testState.selectedIndices = [0]; + testState.selectedPaths = ['/stale-a', '/stale-b']; + + mocks.useContextMenuMock + .mockReturnValueOnce({ + showContextMenu: mocks.showFilesContextMenu, + showHeaderContextMenu: vi.fn(), + }) + .mockReturnValueOnce({ + showContextMenu: mocks.showEventsContextMenu, + showHeaderContextMenu: vi.fn(), + }); + }); + + it('uses clicked row path for context menu when row is not already selected', () => { + render(); + + fireEvent.contextMenu(screen.getByTestId('file-row')); + + expect(mocks.selectSingleRow).toHaveBeenCalledWith(1); + expect(mocks.showFilesContextMenu).toHaveBeenCalledTimes(1); + expect(mocks.showFilesContextMenu.mock.calls[0][1]).toEqual(['/clicked']); + }); + + it('uses selected paths for context menu when clicked row is already selected', () => { + testState.selectedIndices = [1]; + testState.selectedPaths = ['/selected-a', '/selected-b']; + + render(); + + fireEvent.contextMenu(screen.getByTestId('file-row')); + + expect(mocks.selectSingleRow).not.toHaveBeenCalled(); + expect(mocks.showFilesContextMenu).toHaveBeenCalledTimes(1); + expect(mocks.showFilesContextMenu.mock.calls[0][1]).toEqual(['/selected-a', '/selected-b']); + }); + + it('falls back to clicked row path when selected row has no selected paths snapshot', () => { + testState.selectedIndices = [1]; + testState.selectedPaths = []; + + render(); + + fireEvent.contextMenu(screen.getByTestId('file-row')); + + expect(mocks.selectSingleRow).not.toHaveBeenCalled(); + expect(mocks.showFilesContextMenu).toHaveBeenCalledTimes(1); + expect(mocks.showFilesContextMenu.mock.calls[0][1]).toEqual(['/clicked']); + }); + + it('passes event path to events context menu as a single target item', () => { + testState.activeTab = 'events'; + + render(); + + fireEvent.contextMenu(screen.getByTestId('event-row')); + + expect(mocks.showEventsContextMenu).toHaveBeenCalledTimes(1); + expect(mocks.showEventsContextMenu.mock.calls[0][1]).toEqual(['/event-path']); + }); +}); diff --git a/cardinal/src/hooks/__tests__/useContextMenu.test.tsx b/cardinal/src/hooks/__tests__/useContextMenu.test.tsx index 2883091..dc68298 100644 --- a/cardinal/src/hooks/__tests__/useContextMenu.test.tsx +++ b/cardinal/src/hooks/__tests__/useContextMenu.test.tsx @@ -49,12 +49,9 @@ describe('useContextMenu', () => { }); it('uses plural Copy Paths label and shortcut when multiple paths are selected', async () => { - const getSelectedPaths = () => ['/a', '/b']; - const { result } = renderHook(() => useContextMenu(null, undefined, getSelectedPaths), { - wrapper, - }); + const { result } = renderHook(() => useContextMenu(null), { wrapper }); - result.current.showContextMenu(createEvent(), '/a'); + result.current.showContextMenu(createEvent(), ['/a', '/b']); await waitFor(() => { expect(mocks.menuNewMock).toHaveBeenCalled(); @@ -71,12 +68,9 @@ describe('useContextMenu', () => { }); it('uses singular Copy Path label when a single path is targeted', async () => { - const getSelectedPaths = () => []; - const { result } = renderHook(() => useContextMenu(null, undefined, getSelectedPaths), { - wrapper, - }); + const { result } = renderHook(() => useContextMenu(null), { wrapper }); - result.current.showContextMenu(createEvent(), '/a'); + result.current.showContextMenu(createEvent(), ['/a']); await waitFor(() => { expect(mocks.menuNewMock).toHaveBeenCalled(); @@ -91,19 +85,43 @@ describe('useContextMenu', () => { expect(copyPaths?.text).toBe('Copy Path'); }); - it('copies all selected paths to the clipboard', async () => { - const getSelectedPaths = () => ['/a', '/b']; + it('uses provided target paths and ignores empty entries', async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(globalThis.navigator, 'clipboard', { value: { writeText }, configurable: true, }); - const { result } = renderHook(() => useContextMenu(null, undefined, getSelectedPaths), { - wrapper, + const { result } = renderHook(() => useContextMenu(null), { wrapper }); + + result.current.showContextMenu(createEvent(), ['', '/clicked']); + + await waitFor(() => { + expect(mocks.menuNewMock).toHaveBeenCalled(); + }); + + const items = mocks.menuNewMock.mock.calls[0][0].items as Array<{ + id: string; + text?: string; + action?: () => void; + }>; + const copyPaths = items.find((item) => item.id === 'context_menu.copy_paths'); + expect(copyPaths?.text).toBe('Copy Path'); + copyPaths?.action?.(); + + expect(writeText).toHaveBeenCalledWith('/clicked'); + }); + + it('copies all selected paths to the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { writeText }, + configurable: true, }); - result.current.showContextMenu(createEvent(), '/a'); + const { result } = renderHook(() => useContextMenu(null), { wrapper }); + + result.current.showContextMenu(createEvent(), ['/a', '/b']); await waitFor(() => { expect(mocks.menuNewMock).toHaveBeenCalled(); diff --git a/cardinal/src/hooks/__tests__/useRemoteSort.test.ts b/cardinal/src/hooks/__tests__/useRemoteSort.test.ts new file mode 100644 index 0000000..987e231 --- /dev/null +++ b/cardinal/src/hooks/__tests__/useRemoteSort.test.ts @@ -0,0 +1,192 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import type { SlabIndex } from '../../types/slab'; +import { useRemoteSort } from '../useRemoteSort'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +const mockedInvoke = vi.mocked(invoke); + +const toSlabIndices = (values: number[]): SlabIndex[] => values.map((value) => value as SlabIndex); + +const createDeferred = () => { + let resolve: (value: SlabIndex[]) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +describe('useRemoteSort', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + + mockedInvoke.mockImplementation((command: string) => { + if (command === 'get_sorted_view') { + return Promise.resolve(toSlabIndices([2, 1, 0])); + } + return Promise.resolve(null); + }); + }); + + it('bumps displayedResultsVersion when sort projection changes without backend version changes', async () => { + const results = toSlabIndices([0, 1, 2]); + const { result } = renderHook(() => useRemoteSort(results, 1, 'en-US', () => null)); + + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(0); + }); + const beforeSortVersion = result.current.displayedResultsVersion; + + act(() => { + result.current.handleSortToggle('filename'); + }); + + await waitFor(() => { + expect(mockedInvoke).toHaveBeenCalledWith('get_sorted_view', { + results, + sort: { key: 'filename', direction: 'asc' }, + }); + }); + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(beforeSortVersion); + }); + }); + + it('bumps displayedResultsVersion when backend resultsVersion increments', async () => { + const first = toSlabIndices([0, 1, 2]); + const next = toSlabIndices([10, 11, 12]); + + const { result, rerender } = renderHook( + ({ items, version }: { items: SlabIndex[]; version: number }) => + useRemoteSort(items, version, 'en-US', () => null), + { + initialProps: { + items: first, + version: 1, + }, + }, + ); + + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(0); + }); + const beforeRefreshVersion = result.current.displayedResultsVersion; + + act(() => { + rerender({ + items: next, + version: 2, + }); + }); + + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(beforeRefreshVersion); + }); + expect(result.current.displayedResults).toEqual(next); + }); + + it('does not sort remotely when the result count exceeds threshold', async () => { + window.localStorage.setItem('cardinal.sortThreshold', '2'); + const results = toSlabIndices([0, 1, 2]); + const { result } = renderHook(() => useRemoteSort(results, 1, 'en-US', () => null)); + + act(() => { + result.current.handleSortToggle('filename'); + }); + + await waitFor(() => { + expect(result.current.sortState).toBeNull(); + }); + expect(mockedInvoke).not.toHaveBeenCalledWith( + 'get_sorted_view', + expect.objectContaining({ results }), + ); + expect(result.current.displayedResults).toEqual(results); + }); + + it('bumps displayedResultsVersion when switching sorted projection on then off', async () => { + const results = toSlabIndices([0, 1, 2]); + const { result } = renderHook(() => useRemoteSort(results, 1, 'en-US', () => null)); + + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(0); + }); + const initialVersion = result.current.displayedResultsVersion; + + act(() => { + result.current.handleSortToggle('filename'); + }); + await waitFor(() => { + expect(result.current.sortState).toEqual({ key: 'filename', direction: 'asc' }); + }); + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(initialVersion); + }); + const sortedVersion = result.current.displayedResultsVersion; + + act(() => { + result.current.handleSortToggle('filename'); + }); + await waitFor(() => { + expect(result.current.sortState).toEqual({ key: 'filename', direction: 'desc' }); + }); + + act(() => { + result.current.handleSortToggle('filename'); + }); + await waitFor(() => { + expect(result.current.sortState).toBeNull(); + }); + await waitFor(() => { + expect(result.current.displayedResultsVersion).toBeGreaterThan(sortedVersion); + }); + }); + + it('ignores stale remote sort responses and applies only the latest request', async () => { + const results = toSlabIndices([0, 1, 2]); + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + + mockedInvoke.mockReset(); + mockedInvoke + .mockImplementationOnce((command: string) => { + if (command === 'get_sorted_view') return firstRequest.promise; + return Promise.resolve(null); + }) + .mockImplementationOnce((command: string) => { + if (command === 'get_sorted_view') return secondRequest.promise; + return Promise.resolve(null); + }); + + const { result } = renderHook(() => useRemoteSort(results, 1, 'en-US', () => null)); + + act(() => { + result.current.handleSortToggle('filename'); + }); + act(() => { + result.current.handleSortToggle('filename'); + }); + + await waitFor(() => { + expect(mockedInvoke).toHaveBeenCalledTimes(2); + }); + + act(() => { + firstRequest.resolve(toSlabIndices([2, 1, 0])); + }); + await Promise.resolve(); + + act(() => { + secondRequest.resolve(toSlabIndices([1, 2, 0])); + }); + + await waitFor(() => { + expect(result.current.displayedResults).toEqual(toSlabIndices([1, 2, 0])); + }); + }); +}); diff --git a/cardinal/src/hooks/useContextMenu.ts b/cardinal/src/hooks/useContextMenu.ts index af6d566..8303a81 100644 --- a/cardinal/src/hooks/useContextMenu.ts +++ b/cardinal/src/hooks/useContextMenu.ts @@ -7,14 +7,13 @@ import { useTranslation } from 'react-i18next'; import { openResultPath } from '../utils/openResultPath'; type UseContextMenuResult = { - showContextMenu: (event: ReactMouseEvent, path: string) => void; + showContextMenu: (event: ReactMouseEvent, targetPaths: string[]) => void; showHeaderContextMenu: (event: ReactMouseEvent) => void; }; export function useContextMenu( autoFitColumns: (() => void) | null = null, onQuickLookRequest?: () => void | Promise, - getSelectedPaths?: () => string[], ): UseContextMenuResult { const { t } = useTranslation(); const writeClipboard = useCallback((text: string) => { @@ -25,13 +24,11 @@ export function useContextMenu( }, []); const buildFileMenuItems = useCallback( - (path: string): MenuItemOptions[] => { - if (!path) { + (targetPathsInput: string[]): MenuItemOptions[] => { + const targetPaths = targetPathsInput.filter(Boolean); + if (targetPaths.length === 0) { return []; } - - const selected = getSelectedPaths?.().filter(Boolean) ?? []; - const targetPaths = selected.length > 0 ? selected : [path]; const copyLabel = targetPaths.length > 1 ? t('contextMenu.copyFiles') : t('contextMenu.copyFile'); const copyFilenameLabel = @@ -103,7 +100,7 @@ export function useContextMenu( return items; }, - [getSelectedPaths, onQuickLookRequest, t, writeClipboard], + [onQuickLookRequest, t, writeClipboard], ); const buildHeaderMenuItems = useCallback((): MenuItemOptions[] => { @@ -136,10 +133,10 @@ export function useContextMenu( }, []); const showContextMenu = useCallback( - (event: ReactMouseEvent, path: string) => { + (event: ReactMouseEvent, targetPaths: string[]) => { event.preventDefault(); event.stopPropagation(); - void showMenu(buildFileMenuItems(path)); + void showMenu(buildFileMenuItems(targetPaths)); }, [buildFileMenuItems, showMenu], );