fix: stabilize note list hotspots
This commit is contained in:
@@ -3,6 +3,7 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPerio
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import type { MultiSelectState } from '../hooks/useMultiSelect'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
@@ -37,7 +38,7 @@ function useViewFlags(selection: SidebarSelection) {
|
||||
}
|
||||
|
||||
function useBulkActions(
|
||||
multiSelect: ReturnType<typeof useMultiSelect>,
|
||||
multiSelect: MultiSelectState,
|
||||
onBulkArchive: NoteListProps['onBulkArchive'],
|
||||
onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'],
|
||||
isArchivedView: boolean,
|
||||
|
||||
@@ -231,14 +231,15 @@ describe('SearchPanel', () => {
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={onSelectNote} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { useRef, useEffect, useCallback, useLayoutEffect } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SearchResult, VaultEntry } from '../types'
|
||||
@@ -23,10 +23,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}, [open])
|
||||
const resultsRef = useRef(results)
|
||||
const selectedIndexRef = useRef(selectedIndex)
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
@@ -42,26 +40,38 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
}
|
||||
}, [entries, onSelectNote, onClose])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resultsRef.current = results
|
||||
selectedIndexRef.current = selectedIndex
|
||||
}, [results, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback((e: { key: string; preventDefault: () => void }) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.min(i + 1, resultsRef.current.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const result = resultsRef.current[selectedIndexRef.current]
|
||||
if (result) handleSelect(result)
|
||||
}
|
||||
}, [handleSelect, onClose, setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (results[selectedIndex]) handleSelect(results[selectedIndex])
|
||||
}
|
||||
}
|
||||
const handleKey = (e: KeyboardEvent) => handleKeyDown(e)
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, results, selectedIndex, handleSelect, setSelectedIndex, onClose])
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const entryLookup = useMemo(() => {
|
||||
@@ -86,6 +96,7 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
query={query}
|
||||
loading={loading}
|
||||
onChange={setQuery}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<SearchContent
|
||||
query={query}
|
||||
@@ -110,10 +121,11 @@ interface SearchInputProps {
|
||||
query: string
|
||||
loading: boolean
|
||||
onChange: (value: string) => void
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
function SearchInput({ query, loading, onChange }, ref) {
|
||||
function SearchInput({ query, loading, onChange, onKeyDown }, ref) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -127,6 +139,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
placeholder="Search in all notes..."
|
||||
value={query}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{loading && (
|
||||
<svg
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver,
|
||||
isDeletedNoteEntry, isModifiedEntry, routeNoteClick, toggleSetMember,
|
||||
} from './noteListUtils'
|
||||
import type { DeletedNoteEntry } from './noteListUtils'
|
||||
import { useMultiSelect, type MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../../hooks/useNoteListKeyboard'
|
||||
import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
@@ -415,7 +416,7 @@ interface UseNoteListInteractionsParams {
|
||||
isChangesView: boolean
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: VaultEntry) => void
|
||||
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onAutoTriggerDiff?: () => void
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
|
||||
@@ -9,144 +9,6 @@ type NoteListProps = ComponentProps<typeof NoteList>
|
||||
|
||||
export const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
export const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
|
||||
filename: '26q1-laputa-app.md',
|
||||
title: 'Build Laputa App',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: ['[[topic/software-development]]'],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a personal knowledge management app.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
filename: 'facebook-ads-strategy.md',
|
||||
title: 'Facebook Ads Strategy',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: ['[[project/26q1-laputa-app]]'],
|
||||
relatedTo: ['[[topic/growth]]'],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
snippet: 'Lookalike audiences convert 3x better.',
|
||||
wordCount: 0,
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
filename: 'matteo-cellini.md',
|
||||
title: 'Matteo Cellini',
|
||||
isA: 'Person',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
|
||||
filename: '2026-02-14-kickoff.md',
|
||||
title: 'Kickoff Meeting',
|
||||
isA: 'Event',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: 'Project kickoff meeting notes.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
filename: 'software-development.md',
|
||||
title: 'Software Development',
|
||||
isA: 'Topic',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Frontend, backend, and systems programming.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
export const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/test.md',
|
||||
filename: 'test.md',
|
||||
@@ -171,11 +33,80 @@ export const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const mockEntries: VaultEntry[] = [
|
||||
makeEntry({
|
||||
path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
|
||||
filename: '26q1-laputa-app.md',
|
||||
title: 'Build Laputa App',
|
||||
isA: 'Project',
|
||||
relatedTo: ['[[topic/software-development]]'],
|
||||
status: 'Active',
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: 'Build a personal knowledge management app.',
|
||||
relationships: {
|
||||
'Related to': ['[[topic/software-development]]'],
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
filename: 'facebook-ads-strategy.md',
|
||||
title: 'Facebook Ads Strategy',
|
||||
isA: 'Note',
|
||||
belongsTo: ['[[project/26q1-laputa-app]]'],
|
||||
relatedTo: ['[[topic/growth]]'],
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
snippet: 'Lookalike audiences convert 3x better.',
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Related to': ['[[topic/growth]]'],
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
filename: 'matteo-cellini.md',
|
||||
title: 'Matteo Cellini',
|
||||
isA: 'Person',
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
snippet: 'Sponsorship manager.',
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
|
||||
filename: '2026-02-14-kickoff.md',
|
||||
title: 'Kickoff Meeting',
|
||||
isA: 'Event',
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
snippet: 'Project kickoff meeting notes.',
|
||||
}),
|
||||
makeEntry({
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
filename: 'software-development.md',
|
||||
title: 'Software Development',
|
||||
isA: 'Topic',
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Frontend, backend, and systems programming.',
|
||||
}),
|
||||
]
|
||||
|
||||
export const makeIndexedEntry = (index: number, overrides?: Partial<VaultEntry>): VaultEntry =>
|
||||
makeEntry({
|
||||
path: `/vault/note/note-${index}.md`,
|
||||
|
||||
@@ -63,7 +63,17 @@ vi.mock('react-day-picker', () => ({
|
||||
getDefaultClassNames: () => ({}),
|
||||
}))
|
||||
|
||||
// Mock react-virtuoso: JSDOM has no real viewport, so render all items directly
|
||||
function getVirtualizedIndexes(length: number): number[] {
|
||||
if (length <= 200) return Array.from({ length }, (_, index) => index)
|
||||
|
||||
const edgeSize = 50
|
||||
return [
|
||||
...Array.from({ length: edgeSize }, (_, index) => index),
|
||||
...Array.from({ length: edgeSize }, (_, index) => length - edgeSize + index),
|
||||
]
|
||||
}
|
||||
|
||||
// Mock react-virtuoso: JSDOM has no real viewport, so render a representative window for large lists.
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
Virtuoso: ({ data, itemContent, components }: {
|
||||
data?: unknown[]
|
||||
@@ -71,10 +81,12 @@ vi.mock('react-virtuoso', () => ({
|
||||
components?: { Header?: ComponentType }
|
||||
}) => {
|
||||
const Header = components?.Header
|
||||
const resolvedData = data ?? []
|
||||
const renderedIndexes = getVirtualizedIndexes(resolvedData.length)
|
||||
return createElement('div', { 'data-testid': 'virtuoso-mock' },
|
||||
Header ? createElement(Header) : null,
|
||||
data?.map((item: unknown, index: number) =>
|
||||
createElement('div', { key: index }, itemContent?.(index, item))
|
||||
renderedIndexes.map((index) =>
|
||||
createElement('div', { key: index }, itemContent?.(index, resolvedData[index]))
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user