fix: debounce note list search

This commit is contained in:
lucaronin
2026-04-21 00:47:45 +02:00
parent 6a4046915c
commit 367a66f32a
16 changed files with 828 additions and 114 deletions

View File

@@ -56,6 +56,7 @@ pub struct MenuStateUpdate {
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
note_list_search_enabled: Option<bool>,
}
#[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(())
}

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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<HTMLInputElement | null>
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<HTMLInputElement>) => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
return (
@@ -51,7 +55,24 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
</div>
{searchVisible && (
<div className="border-b border-border px-3 py-2">
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
<div className="flex items-center gap-3">
<Input
ref={searchInputRef}
placeholder="Search notes..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={onSearchKeyDown}
className="h-8 text-[13px]"
/>
<div className="min-w-[92px] text-[12px] text-muted-foreground" aria-live="polite">
{isSearching && (
<span className="flex items-center gap-1" data-testid="note-list-search-loading">
<Loader2 size={12} className="animate-spin" />
Searching...
</span>
)}
</div>
</div>
</div>
)}
</>

View File

@@ -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 (
<div
ref={noteListPanelRef}
className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground"
style={{ height: '100%' }}
onBlurCapture={handleNoteListPanelBlurCapture}
onFocusCapture={handleNoteListPanelFocusCapture}
>
<NoteListHeader
title={title}
@@ -243,12 +252,15 @@ export function NoteListLayout({
sidebarCollapsed={sidebarCollapsed}
searchVisible={searchVisible}
search={search}
isSearching={isSearching}
searchInputRef={searchInputRef}
propertyPicker={propertyPicker}
onSortChange={handleSortChange}
onCreateNote={handleCreateNote}
onOpenType={onOpenType}
onToggleSearch={toggleSearch}
onSearchChange={setSearch}
onSearchKeyDown={handleSearchKeyDown}
/>
<NoteListBody
handleListKeyDown={handleListKeyDown}

View File

@@ -0,0 +1,91 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeIndexedEntry, renderNoteList } from '../../test-utils/noteListTestUtils'
function installAnimationFrameStub() {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
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()
})
})

View File

@@ -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<React.SetStateAction<Record<string, SortConfig>>>,
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<React.SetStateAction<Record<string, SortConfig>>>
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<ViewDefinition>) => 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<NoteListPropertyPicker | null>(() => {
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,

View File

@@ -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<SidebarSelection, { kind: 'entity' }>
@@ -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<typeof useFilterCounts>
onNoteListFilterChange: (filter: NoteListFilter) => void
onOpenType: (entry: VaultEntry) => void
content: ReturnType<typeof useNoteListContent>
content: ReturnType<typeof useNoteListContent> & {
handleSearchKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}
interaction: ReturnType<typeof useNoteListInteractionState> & {
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<HTMLInputElement>) => {
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,

View File

@@ -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<HTMLInputElement>(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,
}
}

View File

@@ -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<string | null>,
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 }),
}))
})
})
})

View File

@@ -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,
})
}

View File

@@ -9,6 +9,8 @@ interface NoteListKeyboardOptions {
onOpen: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
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<KeyboardEvent, 'metaKey' | 'ctrlKey'>):
return event.metaKey || event.ctrlKey
}
function isToggleSearchShortcut(
event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
): boolean {
if (!usesCommandModifier(event) || event.altKey || event.shiftKey) return false
return event.code === 'KeyF' || event.key.toLowerCase() === 'f'
}
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): 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<void>
onToggleSearchShortcut?: () => void
}) {
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
if (!enabled || items.length === 0) return
return useCallback((event: Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>) => {
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<HTMLDivElement | null>) {
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<HTMLDivElement | null>
containerRef: React.RefObject<HTMLDivElement | null>
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<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'preventDefault'>,
onToggleSearchShortcut?: () => void,
): boolean {
if (!isToggleSearchShortcut(event) || !onToggleSearchShortcut) return false
event.preventDefault()
onToggleSearchShortcut()
return true
}
function handleNeighborhoodShortcutEvent(options: {
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>
items: VaultEntry[]
highlightedPathRef: React.RefObject<string | null>
cancelOpen: () => void
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
}): 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<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey'>,
): boolean {
return usesCommandModifier(event) || event.altKey
}
function handleEnterShortcutEvent(
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
items: VaultEntry[],
highlightedPathRef: React.RefObject<string | null>,
flushOpen: (entry?: VaultEntry) => void,
) {
if (event.key !== 'Enter') return
handleHighlightedOpen({
event,
items,
highlightedPathRef,
flushOpen,
})
}
function createGlobalKeyDownHandler(
panelRef: React.RefObject<HTMLDivElement | null>,
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<VirtuosoHandle>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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,
}
}

View File

@@ -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<NoteListSearchAvailabilityDetail>(
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)
}

View File

@@ -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()
})
})

View File

@@ -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')