Files
tolaria/src/components/NoteList.tsx
lucaronin 53072eb4f3 fix: Cmd+Option+arrow navigation now follows the visible note list order
The keyboard navigation was computing its own note list with hardcoded
sortByModified, which didn't match the actual UI order (Inbox sorts by
createdAt, custom types use configurable sorts, etc.). Now the NoteList
component writes its sorted list to a shared ref that the navigation
hook reads directly, ensuring navigation always matches visual order.

Also fixes navigation not wrapping at list boundaries per spec.

Syncs CodeScene thresholds with current remote scores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:27 +02:00

247 lines
15 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
import { NoteItem } from './NoteItem'
import { prefetchNoteContent } from '../hooks/useTabManagement'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import { NoteListHeader } from './note-list/NoteListHeader'
import { FilterPills } from './note-list/FilterPills'
import { EntityView, ListView } from './note-list/NoteListViews'
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
import {
useTypeEntryMap, useNoteListData, useNoteListSearch,
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
} from './note-list/noteListHooks'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
function useViewFlags(selection: SidebarSelection) {
const isSectionGroup = selection.kind === 'sectionGroup'
const isFolderView = selection.kind === 'folder'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const showFilterPills = isSectionGroup || isFolderView || isAllNotesView
return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills }
}
function useBulkActions(
multiSelect: ReturnType<typeof useMultiSelect>,
onBulkArchive: NoteListProps['onBulkArchive'],
onBulkTrash: NoteListProps['onBulkTrash'],
onBulkRestore: NoteListProps['onBulkRestore'],
onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'],
isTrashView: boolean,
isArchivedView: boolean,
) {
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
return { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete }
}
function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise<void>; modifiedFiles?: ModifiedFile[] }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (!isChangesView || !onDiscardFile) return
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
useEffect(() => {
if (!ctxMenu) return
const handler = (e: MouseEvent) => {
if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const contextMenuNode = ctxMenu ? (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
>
Discard changes
</button>
</div>
) : null
const dialogNode = (
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
return { handleNoteContextMenu, contextMenuNode, dialogNode }
}
interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
selectedNote: VaultEntry | null
noteListFilter: NoteListFilter
onNoteListFilterChange: (filter: NoteListFilter) => void
inboxPeriod?: InboxPeriod
onInboxPeriodChange?: (period: InboxPeriod) => void
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onCreateNote: (type?: string) => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
onBulkRestore?: (paths: string[]) => void
onBulkDeletePermanently?: (paths: string[]) => void
onEmptyTrash?: () => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
const subFilter = showFilterPills ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const changeStatusMap = useMemo(() => {
if (!isChangesView || !modifiedFiles) return undefined
const map = new Map<string, ModifiedFile['status']>()
for (const mf of modifiedFiles) {
map.set(mf.path, mf.status)
// Also index by suffix for matching (vault entries may use different path formats)
map.set('/' + mf.relativePath, mf.status)
}
return map
}, [isChangesView, modifiedFiles])
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
if (visibleNotesRef) {
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((g) => g.entries)
: searched
}
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],
)
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
if (isChangesView && onAutoTriggerDiff) {
// Small delay to let the tab open before triggering diff
setTimeout(onAutoTriggerDiff, 50)
}
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
const { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } = useBulkActions(multiSelect, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, isTrashView, isArchivedView)
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const getChangeStatus = useCallback((path: string) => {
if (!changeStatusMap) return undefined
const direct = changeStatusMap.get(path)
if (direct) return direct
// Try suffix match
for (const [key, status] of changeStatusMap) {
if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status
}
return undefined
}, [changeStatusMap])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
}, [onCreateNote, selection])
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
const title = resolveHeaderTitle(selection, typeDocument, views)
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} onUpdateTypeProperty={onUpdateTypeSort} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
)}
{contextMenuNode}
{dialogNode}
</div>
)
}
export const NoteList = memo(NoteListInner)