From 367a66f32aeb6b6186645b85317827bcedfce500 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 21 Apr 2026 00:47:45 +0200 Subject: [PATCH] fix: debounce note list search --- src-tauri/src/commands/system.rs | 4 + src-tauri/src/menu.rs | 27 ++ src/components/NoteList.behavior.test.tsx | 10 +- src/components/NoteList.rendering.test.tsx | 34 ++- src/components/note-list/NoteListHeader.tsx | 25 +- src/components/note-list/NoteListLayout.tsx | 12 + .../note-list/NoteListSearchKeyboard.test.tsx | 91 +++++++ src/components/note-list/noteListHooks.ts | 137 +++++++--- src/components/note-list/useNoteListModel.tsx | 63 ++++- .../note-list/useNoteListSearchState.ts | 70 +++++ .../useMenuEvents.noteListSearch.test.ts | 104 +++++++ src/hooks/useMenuEvents.ts | 32 ++- src/hooks/useNoteListKeyboard.ts | 253 ++++++++++++++---- src/utils/noteListSearchEvents.ts | 34 +++ .../note-list-search-keyboard-toggle.spec.ts | 41 +++ .../note-list-search-visible-content.spec.ts | 5 +- 16 files changed, 828 insertions(+), 114 deletions(-) create mode 100644 src/components/note-list/NoteListSearchKeyboard.test.tsx create mode 100644 src/components/note-list/useNoteListSearchState.ts create mode 100644 src/hooks/useMenuEvents.noteListSearch.test.ts create mode 100644 src/utils/noteListSearchEvents.ts create mode 100644 tests/smoke/note-list-search-keyboard-toggle.spec.ts diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 58fddcc8..c7a1d1fc 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -56,6 +56,7 @@ pub struct MenuStateUpdate { has_conflicts: Option, has_restorable_deleted_note: Option, has_no_remote: Option, + note_list_search_enabled: Option, } #[cfg(desktop)] @@ -77,6 +78,9 @@ pub fn update_menu_state( if let Some(v) = state.has_no_remote { menu::set_git_no_remote_items_enabled(&app_handle, v); } + if let Some(v) = state.note_list_search_enabled { + menu::set_note_list_search_items_enabled(&app_handle, v); + } Ok(()) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index ae335e1c..59153494 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -14,6 +14,7 @@ const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias"; const FILE_SAVE: &str = "file-save"; const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; +const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search"; const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor"; const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff"; @@ -62,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[ FILE_QUICK_OPEN_ALIAS, FILE_SAVE, EDIT_FIND_IN_VAULT, + EDIT_TOGGLE_NOTE_LIST_SEARCH, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, VIEW_EDITOR_ONLY, @@ -110,6 +112,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[ NOTE_OPEN_IN_NEW_WINDOW, ]; +/// IDs of menu items that depend on the note list being the active surface. +const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH]; + /// IDs of menu items that depend on a deleted-note preview being active. const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED]; @@ -185,6 +190,11 @@ fn build_edit_menu(app: &App) -> MenuResult { .id(EDIT_FIND_IN_VAULT) .accelerator("CmdOrCtrl+Shift+F") .build(app)?; + let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search") + .id(EDIT_TOGGLE_NOTE_LIST_SEARCH) + .accelerator("CmdOrCtrl+F") + .enabled(false) + .build(app)?; let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode") .id(EDIT_TOGGLE_DIFF) .build(app)?; @@ -200,6 +210,7 @@ fn build_edit_menu(app: &App) -> MenuResult { .select_all() .separator() .item(&find_in_vault) + .item(&toggle_note_list_search) .item(&toggle_diff) .build()?) } @@ -452,6 +463,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled); } +/// Enable or disable menu items that depend on the note list being the active surface. +pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled); +} + /// Enable or disable menu items that depend on having uncommitted changes. pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled); @@ -486,6 +502,7 @@ mod tests { FILE_QUICK_OPEN, FILE_SAVE, EDIT_FIND_IN_VAULT, + EDIT_TOGGLE_NOTE_LIST_SEARCH, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, VIEW_EDITOR_ONLY, @@ -532,6 +549,16 @@ mod tests { } } + #[test] + fn note_list_search_dependent_ids_are_subset_of_custom_ids() { + for id in NOTE_LIST_SEARCH_DEPENDENT_IDS { + assert!( + CUSTOM_IDS.contains(id), + "note-list-search-dependent ID {id} not in CUSTOM_IDS" + ); + } + } + #[test] fn git_dependent_ids_are_subset_of_custom_ids() { for id in GIT_COMMIT_DEPENDENT_IDS { diff --git a/src/components/NoteList.behavior.test.tsx b/src/components/NoteList.behavior.test.tsx index 1567df88..56c94d1b 100644 --- a/src/components/NoteList.behavior.test.tsx +++ b/src/components/NoteList.behavior.test.tsx @@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => { expect(screen.getByText('Note 499')).toBeInTheDocument() }) - it('filters large datasets by search query', () => { + it('filters large datasets by search query', async () => { const entries = [ makeIndexedEntry(0, { title: 'Alpha Strategy' }), ...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })), @@ -67,9 +67,11 @@ describe('NoteList virtualized datasets', () => { fireEvent.click(screen.getByTitle('Search notes')) fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) - expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() - expect(screen.getByText('Beta Strategy')).toBeInTheDocument() - expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() + expect(screen.getByText('Beta Strategy')).toBeInTheDocument() + expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() + }) }) it('sorts large datasets correctly', () => { diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx index ab32ac42..515de246 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -84,10 +84,16 @@ function renderManagedViewNoteList({ } } -function searchNoteList(query: string) { +async function searchNoteList(query: string) { const searchInput = screen.queryByPlaceholderText('Search notes...') if (!searchInput) fireEvent.click(screen.getByTitle('Search notes')) fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } }) + await waitFor(() => { + expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument() + }) + await waitFor(() => { + expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument() + }) } function renderBookNoteList({ @@ -117,11 +123,11 @@ function renderBookNoteList({ }) } -function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) { - searchNoteList(matchingQuery) +async function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) { + await searchNoteList(matchingQuery) expect(screen.getByText(title)).toBeInTheDocument() - searchNoteList(hiddenQuery) + await searchNoteList(hiddenQuery) expect(screen.queryByText(title)).not.toBeInTheDocument() expect(screen.getByText('No matching notes')).toBeInTheDocument() } @@ -191,14 +197,14 @@ describe('NoteList rendering', () => { expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument() }) - it('filters by a case-insensitive search query', () => { + it('filters by a case-insensitive search query', async () => { renderNoteList() - searchNoteList('facebook') + await searchNoteList('facebook') expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) - it('filters by snippet text when the title does not match', () => { + it('filters by snippet text when the title does not match', async () => { renderNoteList({ entries: [ makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }), @@ -206,13 +212,13 @@ describe('NoteList rendering', () => { ], }) - searchNoteList('nebula-only') + await searchNoteList('nebula-only') expect(screen.getByText('Beta Note')).toBeInTheDocument() expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument() }) - it('filters by visible property values and ignores hidden properties', () => { + it('filters by visible property values and ignores hidden properties', async () => { renderBookNoteList({ entryOverrides: { title: 'Property Search Note', @@ -221,10 +227,10 @@ describe('NoteList rendering', () => { allNotesNoteListProperties: null, }) - expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value') + await expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value') }) - it('uses the active all-notes columns when filtering by visible property values', () => { + it('uses the active all-notes columns when filtering by visible property values', async () => { renderBookNoteList({ entryOverrides: { title: 'Override Search Note', @@ -233,7 +239,7 @@ describe('NoteList rendering', () => { allNotesNoteListProperties: ['Owner'], }) - expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority') + await expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority') }) it('sorts entries by last modified descending by default', () => { @@ -322,7 +328,7 @@ describe('NoteList rendering', () => { expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument() }) - it('keeps existing neighborhood groups visible at zero after search filters them out', () => { + it('keeps existing neighborhood groups visible at zero after search filters them out', async () => { const parent = makeEntry({ path: '/vault/parent.md', filename: 'parent.md', @@ -344,7 +350,7 @@ describe('NoteList rendering', () => { expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument() - searchNoteList('missing-neighborhood-match') + await searchNoteList('missing-neighborhood-match') expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument() diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx index 0749f3c8..30cc9c3b 100644 --- a/src/components/note-list/NoteListHeader.tsx +++ b/src/components/note-list/NoteListHeader.tsx @@ -1,4 +1,5 @@ import { MagnifyingGlass, Plus } from '@phosphor-icons/react' +import { Loader2 } from 'lucide-react' import type { VaultEntry } from '../../types' import type { SortOption, SortDirection } from '../../utils/noteListHelpers' import { Button } from '@/components/ui/button' @@ -9,7 +10,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4' -export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: { +export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: { title: string typeDocument: VaultEntry | null isEntityView: boolean @@ -19,12 +20,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li sidebarCollapsed?: boolean searchVisible: boolean search: string + isSearching: boolean + searchInputRef: React.RefObject propertyPicker?: ListPropertiesPopoverProps | null onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void onCreateNote: () => void onOpenType: (entry: VaultEntry) => void onToggleSearch: () => void onSearchChange: (value: string) => void + onSearchKeyDown: (event: React.KeyboardEvent) => void }) { const { onMouseDown: onDragMouseDown } = useDragRegion() return ( @@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li {searchVisible && (
- onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus /> +
+ onSearchChange(e.target.value)} + onKeyDown={onSearchKeyDown} + className="h-8 text-[13px]" + /> +
+ {isSearching && ( + + + Searching... + + )} +
+
)} diff --git a/src/components/note-list/NoteListLayout.tsx b/src/components/note-list/NoteListLayout.tsx index 27a416ce..30f1bdf7 100644 --- a/src/components/note-list/NoteListLayout.tsx +++ b/src/components/note-list/NoteListLayout.tsx @@ -192,12 +192,18 @@ export function NoteListLayout({ sidebarCollapsed, searchVisible, search, + isSearching, + searchInputRef, propertyPicker, handleSortChange, handleCreateNote, onOpenType, toggleSearch, setSearch, + handleSearchKeyDown, + noteListPanelRef, + handleNoteListPanelBlurCapture, + handleNoteListPanelFocusCapture, handleListKeyDown, noteListContainerRef, handleNoteListBlur, @@ -230,8 +236,11 @@ export function NoteListLayout({ }: NoteListLayoutProps) { return (
() + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextId++ + callbacks.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + callbacks.delete(id) + }) + + return { + flushAnimationFrame: () => { + const pending = [...callbacks.values()] + callbacks.clear() + pending.forEach((callback) => callback(0)) + }, + } +} + +describe('NoteList search keyboard behavior', () => { + let flushAnimationFrame: () => void + + beforeEach(() => { + vi.useFakeTimers() + ;({ flushAnimationFrame } = installAnimationFrameStub()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('toggles note-list search with Cmd+F while the note list is active', () => { + renderNoteList() + const noteList = screen.getByTestId('note-list-container') + + act(() => { + noteList.focus() + fireEvent.focus(noteList) + fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true }) + }) + + const searchInput = screen.getByPlaceholderText('Search notes...') + act(() => { + flushAnimationFrame() + }) + expect(searchInput).toHaveFocus() + + act(() => { + fireEvent.keyDown(window, { key: 'f', code: 'KeyF', metaKey: true }) + flushAnimationFrame() + }) + + expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument() + expect(noteList).toHaveFocus() + }) + + it('debounces note-list filtering and shows loading feedback while waiting', () => { + const entries = [ + makeIndexedEntry(0, { title: 'Alpha Strategy' }), + ...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(index + 1)), + makeIndexedEntry(999, { title: 'Beta Strategy' }), + ] + + renderNoteList({ entries }) + fireEvent.click(screen.getByTitle('Search notes')) + + const searchInput = screen.getByPlaceholderText('Search notes...') + fireEvent.change(searchInput, { target: { value: 'Strategy' } }) + + expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument() + expect(screen.getByText('Note 1')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(180) + flushAnimationFrame() + }) + + expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument() + expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() + expect(screen.getByText('Beta Strategy')).toBeInTheDocument() + expect(screen.queryByText('Note 1')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index fe0dad91..22bdda63 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -264,22 +264,27 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) } -function persistOrSaveGroupSort( - groupLabel: string, - option: SortOption, - direction: SortDirection, - setSortPrefs: React.Dispatch>>, - typeDocument: VaultEntry | null, - selectedView: ViewFile | null, - persistence: SortPersistence | null, -) { - const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence) - if (!persistenceTarget || !persistence) { - saveGroupSort(groupLabel, option, direction, setSortPrefs) +function persistOrSaveGroupSort(params: { + groupLabel: string + option: SortOption + direction: SortDirection + setSortPrefs: React.Dispatch>> + typeDocument: VaultEntry | null + selectedView: ViewFile | null + persistence: SortPersistence | null +}) { + const persistenceTarget = resolveSortPersistenceTarget( + params.groupLabel, + params.typeDocument, + params.selectedView, + params.persistence, + ) + if (!persistenceTarget || !params.persistence) { + saveGroupSort(params.groupLabel, params.option, params.direction, params.setSortPrefs) return } - persistListSort(persistenceTarget, { option, direction }, persistence) + persistListSort(persistenceTarget, { option: params.option, direction: params.direction }, params.persistence) } function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption { @@ -332,7 +337,7 @@ export function useNoteListSort({ }, [typeDocument, sortPrefs, persistence]) const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { - persistOrSaveGroupSort( + persistOrSaveGroupSort({ groupLabel, option, direction, @@ -340,7 +345,7 @@ export function useNoteListSort({ typeDocument, selectedView, persistence, - ) + }) }, [typeDocument, selectedView, persistence]) const filteredEntries = useFilteredEntries({ @@ -735,6 +740,59 @@ interface UseListPropertyPickerParams { views?: ViewFile[] } +function resolvePropertyPicker(options: { + selectedView: ViewFile | null + viewAvailableProperties: string[] + viewDefaultDisplay: string[] + onUpdateViewDefinition?: (filename: string, patch: Partial) => void + isAllNotesView: boolean + allNotesAvailableProperties: string[] + hasCustomAllNotesProperties: boolean + allNotesNoteListProperties?: string[] | null + allNotesDefaultDisplay: string[] + onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void + isInboxView: boolean + inboxAvailableProperties: string[] + hasCustomInboxProperties: boolean + inboxNoteListProperties?: string[] | null + inboxDefaultDisplay: string[] + onUpdateInboxNoteListProperties?: (value: string[] | null) => void + isSectionGroup: boolean + typeDocument: VaultEntry | null + onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void + typeAvailableProperties: string[] +}) { + return buildViewPropertyPicker({ + selectedView: options.selectedView, + availableProperties: options.viewAvailableProperties, + defaultDisplay: options.viewDefaultDisplay, + onUpdateViewDefinition: options.onUpdateViewDefinition, + }) ?? buildFilterPropertyPicker({ + scope: 'all', + isActive: options.isAllNotesView, + availableProperties: options.allNotesAvailableProperties, + hasCustomProperties: options.hasCustomAllNotesProperties, + noteListProperties: options.allNotesNoteListProperties, + defaultDisplay: options.allNotesDefaultDisplay, + onSave: options.onUpdateAllNotesNoteListProperties, + triggerTitle: 'Customize All Notes columns', + }) ?? buildFilterPropertyPicker({ + scope: 'inbox', + isActive: options.isInboxView, + availableProperties: options.inboxAvailableProperties, + hasCustomProperties: options.hasCustomInboxProperties, + noteListProperties: options.inboxNoteListProperties, + defaultDisplay: options.inboxDefaultDisplay, + onSave: options.onUpdateInboxNoteListProperties, + triggerTitle: 'Customize Inbox columns', + }) ?? buildTypePropertyPicker({ + isSectionGroup: options.isSectionGroup, + typeDocument: options.typeDocument, + onUpdateTypeSort: options.onUpdateTypeSort, + typeAvailableProperties: options.typeAvailableProperties, + }) +} + export function useListPropertyPicker({ entries, selection, @@ -773,30 +831,23 @@ export function useListPropertyPicker({ }) const propertyPicker = useMemo(() => { - return buildViewPropertyPicker({ + return resolvePropertyPicker({ selectedView: viewState.selectedView, - availableProperties: viewState.availableProperties, - defaultDisplay: viewState.defaultDisplay, + viewAvailableProperties: viewState.availableProperties, + viewDefaultDisplay: viewState.defaultDisplay, onUpdateViewDefinition, - }) ?? buildFilterPropertyPicker({ - scope: 'all', - isActive: isAllNotesView, - availableProperties: allNotesState.availableProperties, - hasCustomProperties: hasCustomAllNotesProperties, - noteListProperties: allNotesNoteListProperties, - defaultDisplay: allNotesState.defaultDisplay, - onSave: onUpdateAllNotesNoteListProperties, - triggerTitle: 'Customize All Notes columns', - }) ?? buildFilterPropertyPicker({ - scope: 'inbox', - isActive: isInboxView, - availableProperties: inboxState.availableProperties, - hasCustomProperties: hasCustomInboxProperties, - noteListProperties: inboxNoteListProperties, - defaultDisplay: inboxState.defaultDisplay, - onSave: onUpdateInboxNoteListProperties, - triggerTitle: 'Customize Inbox columns', - }) ?? buildTypePropertyPicker({ + isAllNotesView, + allNotesAvailableProperties: allNotesState.availableProperties, + hasCustomAllNotesProperties, + allNotesNoteListProperties, + allNotesDefaultDisplay: allNotesState.defaultDisplay, + onUpdateAllNotesNoteListProperties, + isInboxView, + inboxAvailableProperties: inboxState.availableProperties, + hasCustomInboxProperties, + inboxNoteListProperties, + inboxDefaultDisplay: inboxState.defaultDisplay, + onUpdateInboxNoteListProperties, isSectionGroup, typeDocument, onUpdateTypeSort, @@ -838,6 +889,8 @@ interface UseNoteListInteractionsParams { noteListFilter: NoteListFilter isChangesView: boolean entityEntry: VaultEntry | null + searchVisible: boolean + toggleSearch: () => void onReplaceActiveTab: (entry: VaultEntry) => void onEnterNeighborhood?: (entry: VaultEntry) => void onOpenDeletedNote?: (entry: DeletedNoteEntry) => void @@ -886,6 +939,8 @@ function useKeyboardInteractionState({ searchedGroups, entityEntry, selectedNotePath, + searchVisible, + toggleSearch, onReplaceActiveTab, onEnterNeighborhood, onOpenDeletedNote, @@ -895,6 +950,8 @@ function useKeyboardInteractionState({ | 'searchedGroups' | 'entityEntry' | 'selectedNotePath' + | 'searchVisible' + | 'toggleSearch' | 'onReplaceActiveTab' | 'onEnterNeighborhood' | 'onOpenDeletedNote' @@ -928,6 +985,8 @@ function useKeyboardInteractionState({ onOpen: handleKeyboardOpen, onEnterNeighborhood: handleNeighborhoodOpen, onPrefetch: handleKeyboardPrefetch, + searchVisible, + toggleSearch, enabled: true, }) const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath) @@ -1025,6 +1084,8 @@ export function useNoteListInteractions({ noteListFilter, isChangesView, entityEntry, + searchVisible, + toggleSearch, onReplaceActiveTab, onEnterNeighborhood, onOpenDeletedNote, @@ -1040,6 +1101,8 @@ export function useNoteListInteractions({ searchedGroups, entityEntry, selectedNotePath, + searchVisible, + toggleSearch, onReplaceActiveTab, onEnterNeighborhood, onOpenDeletedNote, diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 8709c920..666148c6 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from 'react' +import { useEffect, useMemo, useCallback } from 'react' import type { VaultEntry, SidebarSelection, @@ -15,18 +15,19 @@ import { prefetchNoteContent } from '../../hooks/useTabManagement' import type { MultiSelectState } from '../../hooks/useMultiSelect' import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils' import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch' +import { useNoteListSearchState } from './useNoteListSearchState' import { useChangeStatusResolver, useListPropertyPicker, useModifiedFilesState, useNoteListData, useNoteListInteractions, - useNoteListSearch, useNoteListSort, useTypeEntryMap, useVisibleNotesSync, } from './noteListHooks' import { useChangesContextMenu } from './NoteListChangesMenu' +import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents' type EntitySelection = Extract @@ -138,7 +139,16 @@ function useNoteListContent({ onUpdateViewDefinition, updateEntry, }) - const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() + const { + closeSearch, + isSearching, + query, + search, + searchInputRef, + searchVisible, + setSearch, + toggleSearch, + } = useNoteListSearchState() const typeEntryMap = useTypeEntryMap(entries) const { displayPropsOverride, propertyPicker } = useListPropertyPicker({ entries, @@ -191,15 +201,18 @@ function useNoteListContent({ entityEntry, handleSortChange, isArchivedView, + isSearching, isEntityView, listDirection, listSort, propertyPicker, query, search, + searchInputRef, searchVisible, searched, searchedGroups, + closeSearch, setSearch, sortPrefs, toggleSearch, @@ -217,6 +230,8 @@ interface UseNoteListInteractionStateParams { isArchivedView: boolean isChangesView: boolean entityEntry: VaultEntry | null + searchVisible: boolean + toggleSearch: () => void modifiedFiles?: ModifiedFile[] onReplaceActiveTab: (entry: VaultEntry) => void onEnterNeighborhood?: (entry: VaultEntry) => void @@ -238,6 +253,8 @@ function useNoteListInteractionState({ isArchivedView, isChangesView, entityEntry, + searchVisible, + toggleSearch, modifiedFiles, onReplaceActiveTab, onEnterNeighborhood, @@ -266,6 +283,8 @@ function useNoteListInteractionState({ noteListFilter, isChangesView, entityEntry, + searchVisible, + toggleSearch, onReplaceActiveTab, onEnterNeighborhood, onOpenDeletedNote, @@ -401,7 +420,9 @@ function buildNoteListLayoutModel(params: { filterCounts: ReturnType onNoteListFilterChange: (filter: NoteListFilter) => void onOpenType: (entry: VaultEntry) => void - content: ReturnType + content: ReturnType & { + handleSearchKeyDown: (event: React.KeyboardEvent) => void + } interaction: ReturnType & { renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode entitySelection: EntitySelection | null @@ -417,13 +438,19 @@ function buildNoteListLayoutModel(params: { sidebarCollapsed: params.sidebarCollapsed, searchVisible: params.content.searchVisible, search: params.content.search, + isSearching: params.content.isSearching, + searchInputRef: params.content.searchInputRef, propertyPicker: params.content.propertyPicker, handleSortChange: params.content.handleSortChange, handleCreateNote: params.interaction.handleCreateNote, onOpenType: params.onOpenType, toggleSearch: params.content.toggleSearch, setSearch: params.content.setSearch, + handleSearchKeyDown: params.content.handleSearchKeyDown, handleListKeyDown: params.interaction.handleListKeyDown, + noteListPanelRef: params.interaction.noteListKeyboard.panelRef, + handleNoteListPanelBlurCapture: params.interaction.noteListKeyboard.handlePanelBlurCapture, + handleNoteListPanelFocusCapture: params.interaction.noteListKeyboard.handlePanelFocusCapture, noteListContainerRef: params.interaction.noteListKeyboard.containerRef, handleNoteListBlur: params.interaction.noteListKeyboard.handleBlur, handleNoteListFocus: params.interaction.noteListKeyboard.handleFocus, @@ -518,6 +545,8 @@ export function useNoteListModel({ isArchivedView: content.isArchivedView, isChangesView: selection.kind === 'filter' && selection.filter === 'changes', entityEntry: content.entityEntry, + searchVisible: content.searchVisible, + toggleSearch: content.toggleSearch, modifiedFiles, onReplaceActiveTab, onEnterNeighborhood, @@ -543,6 +572,27 @@ export function useNoteListModel({ multiSelect: interaction.multiSelect, noteListKeyboard: interaction.noteListKeyboard, }) + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Escape') return + + event.preventDefault() + content.closeSearch() + requestAnimationFrame(() => { + interaction.noteListKeyboard.focusList() + }) + } + + useEffect(() => { + dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive) + return () => dispatchNoteListSearchAvailability(false) + }, [interaction.noteListKeyboard.isPanelActive]) + + useEffect(() => { + return addNoteListSearchToggleListener(() => { + if (!interaction.noteListKeyboard.isPanelActive) return + interaction.noteListKeyboard.toggleSearchShortcut() + }) + }, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut]) return buildNoteListLayoutModel({ selection, @@ -553,7 +603,10 @@ export function useNoteListModel({ noteListFilter, filterCounts, onNoteListFilterChange, - content, + content: { + ...content, + handleSearchKeyDown, + }, interaction: { ...interaction, renderItem, diff --git a/src/components/note-list/useNoteListSearchState.ts b/src/components/note-list/useNoteListSearchState.ts new file mode 100644 index 00000000..2fbe5931 --- /dev/null +++ b/src/components/note-list/useNoteListSearchState.ts @@ -0,0 +1,70 @@ +import { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react' + +const NOTE_LIST_SEARCH_DEBOUNCE_MS = 180 + +function normalizeSearch(search: string): string { + return search.trim().toLowerCase() +} + +export function useNoteListSearchState() { + const [search, setSearch] = useState('') + const [searchVisible, setSearchVisible] = useState(false) + const [debouncedQuery, setDebouncedQuery] = useState('') + const searchInputRef = useRef(null) + const normalizedSearch = normalizeSearch(search) + const query = useDeferredValue(debouncedQuery) + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedQuery(normalizedSearch) + }, NOTE_LIST_SEARCH_DEBOUNCE_MS) + + return () => window.clearTimeout(timeoutId) + }, [normalizedSearch]) + + useEffect(() => { + if (!searchVisible) return + + const frameId = requestAnimationFrame(() => { + searchInputRef.current?.focus() + }) + + return () => cancelAnimationFrame(frameId) + }, [searchVisible]) + + const clearSearch = useCallback(() => { + setSearch('') + setDebouncedQuery('') + }, []) + + const openSearch = useCallback(() => { + setSearchVisible(true) + }, []) + + const closeSearch = useCallback(() => { + setSearchVisible(false) + clearSearch() + }, [clearSearch]) + + const toggleSearch = useCallback(() => { + setSearchVisible((visible) => { + if (visible) clearSearch() + return !visible + }) + }, [clearSearch]) + + const isSearching = normalizedSearch.length > 0 + && (normalizedSearch !== debouncedQuery || debouncedQuery !== query) + + return { + closeSearch, + isSearching, + openSearch, + query, + search, + searchInputRef, + searchVisible, + setSearch, + toggleSearch, + } +} diff --git a/src/hooks/useMenuEvents.noteListSearch.test.ts b/src/hooks/useMenuEvents.noteListSearch.test.ts new file mode 100644 index 00000000..05da8139 --- /dev/null +++ b/src/hooks/useMenuEvents.noteListSearch.test.ts @@ -0,0 +1,104 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dispatchMenuEvent, useMenuEvents, type MenuEventHandlers } from './useMenuEvents' + +const isTauriMock = vi.fn(() => false) +const listenMock = vi.fn() +const invokeMock = vi.fn().mockResolvedValue(undefined) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => isTauriMock(), +})) + +vi.mock('@tauri-apps/api/event', () => ({ + listen: (...args: unknown[]) => listenMock(...args), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})) + +function makeHandlers(): MenuEventHandlers { + return { + onSetViewMode: vi.fn(), + onCreateNote: vi.fn(), + onCreateType: vi.fn(), + onQuickOpen: vi.fn(), + onSave: vi.fn(), + onOpenSettings: vi.fn(), + onToggleInspector: vi.fn(), + onCommandPalette: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onZoomReset: vi.fn(), + onToggleOrganized: vi.fn(), + onArchiveNote: vi.fn(), + onDeleteNote: vi.fn(), + onSearch: vi.fn(), + onToggleRawEditor: vi.fn(), + onToggleDiff: vi.fn(), + onToggleAIChat: vi.fn(), + onGoBack: vi.fn(), + onGoForward: vi.fn(), + onCheckForUpdates: vi.fn(), + onSelectFilter: vi.fn(), + onOpenVault: vi.fn(), + onRemoveActiveVault: vi.fn(), + onRestoreGettingStarted: vi.fn(), + onAddRemote: vi.fn(), + onCommitPush: vi.fn(), + onPull: vi.fn(), + onResolveConflicts: vi.fn(), + onViewChanges: vi.fn(), + onInstallMcp: vi.fn(), + onReloadVault: vi.fn(), + onOpenInNewWindow: vi.fn(), + onRestoreDeletedNote: vi.fn(), + activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, + multiSelectionCommandRef: { current: null }, + activeTabPath: '/vault/test.md', + hasRestorableDeletedNote: false, + hasNoRemote: false, + } +} + +describe('useMenuEvents note-list search bridge', () => { + beforeEach(() => { + vi.clearAllMocks() + isTauriMock.mockReturnValue(false) + }) + + it('dispatches the note-list search toggle event for the native Cmd+F menu item', () => { + const listener = vi.fn() + window.addEventListener('laputa:toggle-note-list-search', listener) + + dispatchMenuEvent('edit-toggle-note-list-search', makeHandlers()) + + expect(listener).toHaveBeenCalledTimes(1) + window.removeEventListener('laputa:toggle-note-list-search', listener) + }) + + it('syncs note-list search availability into the native menu state', async () => { + isTauriMock.mockReturnValue(true) + listenMock.mockResolvedValue(vi.fn()) + + renderHook(() => useMenuEvents(makeHandlers())) + await vi.dynamicImportSettled() + + expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({ + state: expect.objectContaining({ noteListSearchEnabled: false }), + })) + + act(() => { + window.dispatchEvent(new CustomEvent('laputa:note-list-search-availability', { + detail: { enabled: true }, + })) + }) + + await waitFor(() => { + expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({ + state: expect.objectContaining({ noteListSearchEnabled: true }), + })) + }) + }) +}) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index e6b393e5..f3e77880 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { isTauri } from '../mock-tauri' import { APP_COMMAND_EVENT_NAME, @@ -6,6 +6,13 @@ import { isAppCommandId, type AppCommandHandlers, } from './appCommandDispatcher' +import { + NOTE_LIST_SEARCH_AVAILABILITY_EVENT, + dispatchNoteListSearchToggle, + readNoteListSearchAvailability, +} from '../utils/noteListSearchEvents' + +const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search' export interface MenuEventHandlers extends AppCommandHandlers { activeTabPath: string | null @@ -21,6 +28,7 @@ interface MenuStatePayload { hasConflicts?: boolean hasRestorableDeletedNote?: boolean hasNoRemote?: boolean + noteListSearchEnabled?: boolean } function readCustomEventDetail(event: Event): string | null { @@ -118,8 +126,28 @@ function useNativeMenuStateSync(state: MenuStatePayload) { }, [state]) } +function useNoteListSearchMenuState() { + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + const handleAvailabilityEvent = (event: Event) => { + const nextEnabled = readNoteListSearchAvailability(event) + if (nextEnabled !== null) setEnabled(nextEnabled) + } + + window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent) + return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent) + }, []) + + return enabled +} + /** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void { + if (id === NOTE_LIST_SEARCH_MENU_ID) { + dispatchNoteListSearchToggle() + return + } if (!isAppCommandId(id)) return executeAppCommand(id, h, 'native-menu') } @@ -127,6 +155,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void { /** Listen for native macOS menu events and dispatch them to the appropriate handlers. */ export function useMenuEvents(handlers: MenuEventHandlers) { const ref = useRef(handlers) + const noteListSearchEnabled = useNoteListSearchMenuState() const hasActiveNote = handlers.activeTabPath !== null const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined @@ -146,5 +175,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) { hasConflicts, hasRestorableDeletedNote, hasNoRemote, + noteListSearchEnabled, }) } diff --git a/src/hooks/useNoteListKeyboard.ts b/src/hooks/useNoteListKeyboard.ts index 9a2fa902..d6fab755 100644 --- a/src/hooks/useNoteListKeyboard.ts +++ b/src/hooks/useNoteListKeyboard.ts @@ -9,6 +9,8 @@ interface NoteListKeyboardOptions { onOpen: (entry: VaultEntry) => void onEnterNeighborhood?: (entry: VaultEntry) => void | Promise onPrefetch?: (entry: VaultEntry) => void + searchVisible?: boolean + toggleSearch?: () => void enabled: boolean } @@ -55,6 +57,12 @@ function isListActive(container: HTMLDivElement | null): boolean { return activeElement instanceof Node && container.contains(activeElement) } +function isPanelActive(panel: HTMLDivElement | null): boolean { + if (!panel) return false + const activeElement = document.activeElement + return activeElement instanceof Node && panel.contains(activeElement) +} + function isEditableElement(element: Element | null): boolean { if (!element) return false if ( @@ -119,6 +127,13 @@ function usesCommandModifier(event: Pick): return event.metaKey || event.ctrlKey } +function isToggleSearchShortcut( + event: Pick, +): boolean { + if (!usesCommandModifier(event) || event.altKey || event.shiftKey) return false + return event.code === 'KeyF' || event.key.toLowerCase() === 'f' +} + function isNeighborhoodKey(event: Pick): boolean { return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey } @@ -334,6 +349,7 @@ function useProcessKeyDown({ flushOpen, cancelOpen, onEnterNeighborhood, + onToggleSearchShortcut, }: { enabled: boolean items: VaultEntry[] @@ -342,34 +358,25 @@ function useProcessKeyDown({ flushOpen: (entry?: VaultEntry) => void cancelOpen: () => void onEnterNeighborhood?: (entry: VaultEntry) => void | Promise + onToggleSearchShortcut?: () => void }) { - return useCallback((event: Pick) => { - if (!enabled || items.length === 0) return + return useCallback((event: Pick) => { + if (!enabled) return - if (isNeighborhoodKey(event)) { - handleNeighborhoodActivation({ - event, - items, - highlightedPathRef, - cancelOpen, - onEnterNeighborhood, - }) - return - } - - if (usesCommandModifier(event) || event.altKey) return - - if (handleArrowNavigation(event, moveHighlight)) return - - if (event.key !== 'Enter') return - - handleHighlightedOpen({ + if (handleSearchShortcutEvent(event, onToggleSearchShortcut)) return + if (items.length === 0) return + if (handleNeighborhoodShortcutEvent({ event, items, highlightedPathRef, - flushOpen, - }) - }, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood]) + cancelOpen, + onEnterNeighborhood, + })) return + if (shouldIgnoreListKeyboardEvent(event)) return + if (handleArrowNavigation(event, moveHighlight)) return + + handleEnterShortcutEvent(event, items, highlightedPathRef, flushOpen) + }, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood, onToggleSearchShortcut]) } function useFocusHandlers({ @@ -402,44 +409,194 @@ function useFocusHandlers({ return { focusList, handleBlur, handleFocus } } +function usePanelFocusState(panelRef: React.RefObject) { + const [isPanelActiveState, setIsPanelActiveState] = useState(false) + + const syncPanelState = useCallback(() => { + setIsPanelActiveState(isPanelActive(panelRef.current)) + }, [panelRef]) + + const handlePanelFocusCapture = useCallback(() => { + setIsPanelActiveState(true) + }, []) + + const handlePanelBlurCapture = useCallback(() => { + requestAnimationFrame(syncPanelState) + }, [syncPanelState]) + + return { + handlePanelBlurCapture, + handlePanelFocusCapture, + isPanelActive: isPanelActiveState, + } +} + function useGlobalKeyboardHandling({ enabled, + panelRef, containerRef, processKeyDown, }: { enabled: boolean + panelRef: React.RefObject containerRef: React.RefObject processKeyDown: (event: KeyboardEvent) => void }) { + const shouldSkipGlobalKeyDown = useCallback((activeElement: Element | null) => { + if (isEditableElement(activeElement)) return true + return ( + activeElement !== containerRef.current + && containerRef.current?.contains(activeElement) + && isInteractiveElement(activeElement) + ) + }, [containerRef]) + useEffect(() => { if (!enabled) return - - const handleWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return - const activeElement = document.activeElement - if (isEditableElement(activeElement)) return - if ( - activeElement !== containerRef.current - && containerRef.current?.contains(activeElement) - && isInteractiveElement(activeElement) - ) return - processKeyDown(event) - } + const handleWindowKeyDown = createGlobalKeyDownHandler(panelRef, shouldSkipGlobalKeyDown, processKeyDown) window.addEventListener('keydown', handleWindowKeyDown) return () => window.removeEventListener('keydown', handleWindowKeyDown) - }, [containerRef, enabled, processKeyDown]) + }, [enabled, panelRef, processKeyDown, shouldSkipGlobalKeyDown]) +} + +function useSearchToggleShortcut({ + toggleSearch, + searchVisible, + focusList, +}: { + toggleSearch?: () => void + searchVisible: boolean + focusList: () => void +}) { + return useCallback(() => { + if (!toggleSearch) return + + toggleSearch() + if (!searchVisible) return + + requestAnimationFrame(() => { + focusList() + }) + }, [focusList, searchVisible, toggleSearch]) +} + +function useDirectKeyDownHandler( + processKeyDown: (event: React.KeyboardEvent) => void, +) { + return useCallback((event: React.KeyboardEvent) => { + if (isNestedInteractiveTarget(event.target, event.currentTarget)) return + processKeyDown(event) + }, [processKeyDown]) +} + +function resolveStableHighlightedPath(items: VaultEntry[], highlightedPathState: string | null): string | null { + return getItemIndex(items).entryByPath.has(highlightedPathState ?? '') + ? highlightedPathState + : null +} + +function handleSearchShortcutEvent( + event: Pick, + onToggleSearchShortcut?: () => void, +): boolean { + if (!isToggleSearchShortcut(event) || !onToggleSearchShortcut) return false + event.preventDefault() + onToggleSearchShortcut() + return true +} + +function handleNeighborhoodShortcutEvent(options: { + event: Pick + items: VaultEntry[] + highlightedPathRef: React.RefObject + cancelOpen: () => void + onEnterNeighborhood?: (entry: VaultEntry) => void | Promise +}): boolean { + const { + event, + items, + highlightedPathRef, + cancelOpen, + onEnterNeighborhood, + } = options + + if (!isNeighborhoodKey(event)) return false + handleNeighborhoodActivation({ + event, + items, + highlightedPathRef, + cancelOpen, + onEnterNeighborhood, + }) + return true +} + +function shouldIgnoreListKeyboardEvent( + event: Pick, +): boolean { + return usesCommandModifier(event) || event.altKey +} + +function handleEnterShortcutEvent( + event: Pick, + items: VaultEntry[], + highlightedPathRef: React.RefObject, + flushOpen: (entry?: VaultEntry) => void, +) { + if (event.key !== 'Enter') return + handleHighlightedOpen({ + event, + items, + highlightedPathRef, + flushOpen, + }) +} + +function createGlobalKeyDownHandler( + panelRef: React.RefObject, + shouldSkipGlobalKeyDown: (activeElement: Element | null) => boolean, + processKeyDown: (event: KeyboardEvent) => void, +) { + return (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (isToggleSearchShortcut(event) && isPanelActive(panelRef.current)) { + processKeyDown(event) + return + } + if (shouldSkipGlobalKeyDown(document.activeElement)) return + processKeyDown(event) + } } export function useNoteListKeyboard({ - items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled, + items, + selectedNotePath, + onOpen, + onEnterNeighborhood, + onPrefetch, + searchVisible = false, + toggleSearch, + enabled, }: NoteListKeyboardOptions) { const virtuosoRef = useRef(null) + const panelRef = useRef(null) const containerRef = useRef(null) const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath) const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath() const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath) const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled) + const { focusList, handleBlur, handleFocus } = useFocusHandlers({ + containerRef, + syncToCurrentSelection, + syncHighlightedPath, + }) + const { handlePanelBlurCapture, handlePanelFocusCapture, isPanelActive: isPanelActiveState } = usePanelFocusState(panelRef) + const handleToggleSearchShortcut = useSearchToggleShortcut({ + focusList, + searchVisible, + toggleSearch, + }) const moveHighlight = useMoveHighlight({ items, selectedNotePath, @@ -457,32 +614,28 @@ export function useNoteListKeyboard({ flushOpen, cancelOpen, onEnterNeighborhood, + onToggleSearchShortcut: handleToggleSearchShortcut, }) - const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - if (isNestedInteractiveTarget(event.target, event.currentTarget)) return - processKeyDown(event) - }, [processKeyDown]) - const { focusList, handleBlur, handleFocus } = useFocusHandlers({ - containerRef, - syncToCurrentSelection, - syncHighlightedPath, - }) - useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown }) + const handleKeyDown = useDirectKeyDownHandler(processKeyDown) + useGlobalKeyboardHandling({ enabled, panelRef, containerRef, processKeyDown }) useEffect(() => { cancelOpen() }, [cancelOpen, selectedNotePath]) - const highlightedPath = getItemIndex(items).entryByPath.has(highlightedPathState ?? '') - ? highlightedPathState - : null + const highlightedPath = resolveStableHighlightedPath(items, highlightedPathState) return { containerRef, focusList, + handlePanelBlurCapture, + handlePanelFocusCapture, highlightedPath, handleBlur, handleKeyDown, handleFocus, + isPanelActive: isPanelActiveState, + panelRef, + toggleSearchShortcut: handleToggleSearchShortcut, virtuosoRef, } } diff --git a/src/utils/noteListSearchEvents.ts b/src/utils/noteListSearchEvents.ts new file mode 100644 index 00000000..0dd41956 --- /dev/null +++ b/src/utils/noteListSearchEvents.ts @@ -0,0 +1,34 @@ +export const NOTE_LIST_SEARCH_AVAILABILITY_EVENT = 'laputa:note-list-search-availability' +export const NOTE_LIST_SEARCH_TOGGLE_EVENT = 'laputa:toggle-note-list-search' + +interface NoteListSearchAvailabilityDetail { + enabled: boolean +} + +function isAvailabilityDetail(detail: unknown): detail is NoteListSearchAvailabilityDetail { + return typeof detail === 'object' + && detail !== null + && 'enabled' in detail + && typeof (detail as { enabled?: unknown }).enabled === 'boolean' +} + +export function dispatchNoteListSearchAvailability(enabled: boolean) { + window.dispatchEvent(new CustomEvent( + NOTE_LIST_SEARCH_AVAILABILITY_EVENT, + { detail: { enabled } }, + )) +} + +export function readNoteListSearchAvailability(event: Event): boolean | null { + if (!(event instanceof CustomEvent) || !isAvailabilityDetail(event.detail)) return null + return event.detail.enabled +} + +export function dispatchNoteListSearchToggle() { + window.dispatchEvent(new Event(NOTE_LIST_SEARCH_TOGGLE_EVENT)) +} + +export function addNoteListSearchToggleListener(listener: () => void): () => void { + window.addEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener) + return () => window.removeEventListener(NOTE_LIST_SEARCH_TOGGLE_EVENT, listener) +} diff --git a/tests/smoke/note-list-search-keyboard-toggle.spec.ts b/tests/smoke/note-list-search-keyboard-toggle.spec.ts new file mode 100644 index 00000000..5d2c0967 --- /dev/null +++ b/tests/smoke/note-list-search-keyboard-toggle.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' + +const FIND_SHORTCUT = process.platform === 'darwin' ? 'Meta+F' : 'Control+F' + +let tempVaultDir: string + +test.describe('Note-list search keyboard toggle', () => { + test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await page.setViewportSize({ width: 1600, height: 900 }) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('Cmd+F toggles note-list search and debounces filtering @smoke', async ({ page }) => { + const noteList = page.getByTestId('note-list-container') + + await noteList.focus() + await page.keyboard.press(FIND_SHORTCUT) + + const searchInput = page.getByPlaceholder('Search notes...') + await expect(searchInput).toBeFocused() + + await page.keyboard.type('Team') + await expect(page.getByTestId('note-list-search-loading')).toBeVisible() + await expect(noteList.getByText('Team Meeting', { exact: true })).toBeVisible({ timeout: 5_000 }) + + await page.keyboard.press(FIND_SHORTCUT) + await expect(searchInput).toHaveCount(0) + await expect(noteList).toBeFocused() + }) +}) diff --git a/tests/smoke/note-list-search-visible-content.spec.ts b/tests/smoke/note-list-search-visible-content.spec.ts index 1df4dbe2..c2649b76 100644 --- a/tests/smoke/note-list-search-visible-content.spec.ts +++ b/tests/smoke/note-list-search-visible-content.spec.ts @@ -31,11 +31,14 @@ async function searchAndOpenByKeyboard( expectedTitle: string, expectedFilenameStem: string, ) { + const noteList = page.getByTestId('note-list-container') const searchInput = page.getByPlaceholder('Search notes...') await searchInput.focus() await page.keyboard.press(SELECT_ALL_SHORTCUT) await page.keyboard.type(query) - await expect(page.getByTestId('note-list-container').getByText(expectedTitle, { exact: true })).toBeVisible() + await expect(page.getByTestId('note-list-search-loading')).toBeVisible() + await expect(page.getByTestId('note-list-search-loading')).toHaveCount(0) + await expect(noteList.getByText(expectedTitle, { exact: true })).toBeVisible() await page.keyboard.press('Tab') await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter')