fix: stabilize note list hotspots

This commit is contained in:
Test
2026-04-08 09:18:59 +02:00
parent 0cf2467fbc
commit 2d90b61a70
6 changed files with 126 additions and 167 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`,

View File

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