2026-04-04 18:16:52 +02:00
|
|
|
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
|
2026-04-02 16:09:43 +02:00
|
|
|
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
|
2026-03-18 03:43:23 +01:00
|
|
|
import type { NoteListFilter } from '../utils/noteListHelpers'
|
2026-04-07 20:31:08 +02:00
|
|
|
import { countByFilter, countAllByFilter, filterInboxEntries } from '../utils/noteListHelpers'
|
2026-03-16 19:36:00 +01:00
|
|
|
import { NoteItem } from './NoteItem'
|
2026-03-09 13:05:18 +01:00
|
|
|
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
2026-02-27 15:14:21 +01:00
|
|
|
import { BulkActionBar } from './BulkActionBar'
|
2026-03-16 19:36:00 +01:00
|
|
|
import { useMultiSelect } from '../hooks/useMultiSelect'
|
feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter (#158)
* feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter
- Add missing menu bar items: Archive Note (⌘E), Trash Note (⌘⌫),
Find in Vault (⌘⇧F), Go Back (⌘[), Go Forward (⌘])
- Wire new menu events through useMenuEvents → useAppCommands
- Note list: ↑↓ moves highlight, Enter opens selected note (useNoteListKeyboard hook)
- Inspector properties: Tab between rows, Enter to start editing
- Settings panel: auto-focus first input on open
- Extract StatusDot, StateBadge, noteItemStyle from NoteItem (reduces complexity)
- Extract dispatchActiveTabEvent/dispatchOptionalEvent from useMenuEvents
- 21 new tests (useNoteListKeyboard + useMenuEvents for new events)
- All 1275 frontend tests pass, 319 Rust tests pass
- CodeScene quality gates: passed (NoteItem improved from 22→18 complexity)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: keyboard-first-nav wireframes (note list highlight, menu shortcuts, inspector tab nav)
* test: add search.rs coverage for fallback branches (qmd_uri_to_vault_path, extract_clean_snippet)
* chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup code)
---------
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:32:15 +01:00
|
|
|
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
2026-03-16 19:36:00 +01:00
|
|
|
import { NoteListHeader } from './note-list/NoteListHeader'
|
2026-03-18 03:43:23 +01:00
|
|
|
import { FilterPills } from './note-list/FilterPills'
|
2026-03-16 19:36:00 +01:00
|
|
|
import { EntityView, ListView } from './note-list/NoteListViews'
|
2026-04-07 20:09:04 +02:00
|
|
|
import { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
2026-02-22 10:18:05 +01:00
|
|
|
import {
|
2026-03-16 19:36:00 +01:00
|
|
|
useTypeEntryMap, useNoteListData, useNoteListSearch,
|
|
|
|
|
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
|
|
|
|
|
} from './note-list/noteListHooks'
|
2026-04-04 18:16:52 +02:00
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle,
|
|
|
|
|
DialogDescription, DialogFooter,
|
|
|
|
|
} from '@/components/ui/dialog'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
2026-02-17 18:45:34 +01:00
|
|
|
|
2026-04-05 01:38:18 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:31:08 +02:00
|
|
|
function collectAvailableProperties(entries: VaultEntry[]): string[] {
|
|
|
|
|
const keys = new Set<string>()
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
|
|
|
|
|
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
|
|
|
|
|
}
|
|
|
|
|
return [...keys].sort((a, b) => a.localeCompare(b))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
|
|
|
|
|
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
|
|
|
|
|
const ordered: string[] = []
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
|
|
|
|
|
if (seen.has(key)) continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
ordered.push(key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 01:38:18 +02:00
|
|
|
function useBulkActions(
|
|
|
|
|
multiSelect: ReturnType<typeof useMultiSelect>,
|
|
|
|
|
onBulkArchive: NoteListProps['onBulkArchive'],
|
|
|
|
|
onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'],
|
|
|
|
|
isArchivedView: boolean,
|
|
|
|
|
) {
|
|
|
|
|
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
|
|
|
|
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
|
2026-04-06 12:21:56 +02:00
|
|
|
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
|
|
|
|
const bulkArchiveOrUnarchive = isArchivedView ? handleBulkUnarchive : handleBulkArchive
|
|
|
|
|
return { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive }
|
2026-04-05 01:38:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-07 20:09:04 +02:00
|
|
|
const [actionTarget, setActionTarget] = useState<{ entry: VaultEntry; action: 'discard' | 'restore'; relativePath: string } | null>(null)
|
2026-04-05 01:38:18 +02:00
|
|
|
const ctxMenuRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const resolveActionTarget = useCallback((entry: VaultEntry) => {
|
|
|
|
|
const file = modifiedFiles?.find((modified) => modified.path === entry.path || entry.path.endsWith('/' + modified.relativePath))
|
|
|
|
|
if (!file) return null
|
|
|
|
|
return {
|
|
|
|
|
entry,
|
|
|
|
|
action: file.status === 'deleted' ? 'restore' as const : 'discard' as const,
|
|
|
|
|
relativePath: file.relativePath,
|
|
|
|
|
}
|
|
|
|
|
}, [modifiedFiles])
|
|
|
|
|
|
|
|
|
|
const openContextMenuForEntry = useCallback((entry: VaultEntry, point: { x: number; y: number }) => {
|
2026-04-05 01:38:18 +02:00
|
|
|
if (!isChangesView || !onDiscardFile) return
|
2026-04-07 20:09:04 +02:00
|
|
|
setCtxMenu({ x: point.x, y: point.y, entry })
|
|
|
|
|
}, [isChangesView, onDiscardFile])
|
|
|
|
|
|
|
|
|
|
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
2026-04-05 01:38:18 +02:00
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
2026-04-07 20:09:04 +02:00
|
|
|
openContextMenuForEntry(entry, { x: e.clientX, y: e.clientY })
|
|
|
|
|
}, [openContextMenuForEntry])
|
2026-04-05 01:38:18 +02:00
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const handleChangeConfirm = useCallback(async () => {
|
|
|
|
|
if (!actionTarget || !onDiscardFile) return
|
|
|
|
|
await onDiscardFile(actionTarget.relativePath)
|
|
|
|
|
setActionTarget(null)
|
|
|
|
|
}, [actionTarget, onDiscardFile])
|
|
|
|
|
|
|
|
|
|
const menuActionTarget = ctxMenu ? resolveActionTarget(ctxMenu.entry) : null
|
|
|
|
|
const menuActionLabel = menuActionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'
|
2026-04-05 01:38:18 +02:00
|
|
|
|
|
|
|
|
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"
|
2026-04-07 20:09:04 +02:00
|
|
|
onClick={() => {
|
|
|
|
|
if (!menuActionTarget) return
|
|
|
|
|
setActionTarget(menuActionTarget)
|
|
|
|
|
closeCtxMenu()
|
|
|
|
|
}}
|
|
|
|
|
data-testid={menuActionTarget?.action === 'restore' ? 'restore-note-button' : 'discard-changes-button'}
|
2026-04-05 01:38:18 +02:00
|
|
|
>
|
2026-04-07 20:09:04 +02:00
|
|
|
{menuActionLabel}
|
2026-04-05 01:38:18 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null
|
|
|
|
|
|
|
|
|
|
const dialogNode = (
|
2026-04-07 20:09:04 +02:00
|
|
|
<Dialog open={!!actionTarget} onOpenChange={(open) => { if (!open) setActionTarget(null) }}>
|
|
|
|
|
<DialogContent showCloseButton={false} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-dialog' : 'discard-confirm-dialog'}>
|
2026-04-05 01:38:18 +02:00
|
|
|
<DialogHeader>
|
2026-04-07 20:09:04 +02:00
|
|
|
<DialogTitle>{actionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'}</DialogTitle>
|
2026-04-05 01:38:18 +02:00
|
|
|
<DialogDescription>
|
2026-04-07 20:09:04 +02:00
|
|
|
{actionTarget?.action === 'restore'
|
|
|
|
|
? <>Restore <strong>{actionTarget?.entry.filename ?? 'this file'}</strong> from Git?</>
|
|
|
|
|
: <>Discard changes to <strong>{actionTarget?.entry.title ?? 'this file'}</strong>? This cannot be undone.</>
|
|
|
|
|
}
|
2026-04-05 01:38:18 +02:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<DialogFooter>
|
2026-04-07 20:09:04 +02:00
|
|
|
<Button variant="outline" onClick={() => setActionTarget(null)}>Cancel</Button>
|
|
|
|
|
<Button variant={actionTarget?.action === 'restore' ? 'default' : 'destructive'} onClick={handleChangeConfirm} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-button' : 'discard-confirm-button'}>
|
|
|
|
|
{actionTarget?.action === 'restore' ? 'Restore' : 'Discard'}
|
|
|
|
|
</Button>
|
2026-04-05 01:38:18 +02:00
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
return { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode }
|
2026-04-05 01:38:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 18:22:42 +01:00
|
|
|
interface NoteListProps {
|
|
|
|
|
entries: VaultEntry[]
|
2026-02-14 19:44:39 +01:00
|
|
|
selection: SidebarSelection
|
2026-02-14 20:07:23 +01:00
|
|
|
selectedNote: VaultEntry | null
|
2026-03-18 03:43:23 +01:00
|
|
|
noteListFilter: NoteListFilter
|
|
|
|
|
onNoteListFilterChange: (filter: NoteListFilter) => void
|
2026-03-19 06:41:40 +01:00
|
|
|
inboxPeriod?: InboxPeriod
|
|
|
|
|
onInboxPeriodChange?: (period: InboxPeriod) => void
|
2026-02-24 16:35:47 +01:00
|
|
|
modifiedFiles?: ModifiedFile[]
|
2026-03-02 23:25:54 +01:00
|
|
|
modifiedFilesError?: string | null
|
2026-02-24 15:54:24 +01:00
|
|
|
getNoteStatus?: (path: string) => NoteStatus
|
2026-02-27 22:43:19 +01:00
|
|
|
sidebarCollapsed?: boolean
|
2026-02-14 20:07:23 +01:00
|
|
|
onSelectNote: (entry: VaultEntry) => void
|
2026-02-23 20:12:54 +01:00
|
|
|
onReplaceActiveTab: (entry: VaultEntry) => void
|
2026-04-02 10:06:40 +02:00
|
|
|
onCreateNote: (type?: string) => void
|
2026-02-27 15:14:21 +01:00
|
|
|
onBulkArchive?: (paths: string[]) => void
|
2026-03-12 00:06:33 +01:00
|
|
|
onBulkDeletePermanently?: (paths: string[]) => void
|
2026-03-03 11:22:04 +01:00
|
|
|
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
|
|
|
|
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
2026-03-19 08:47:25 +01:00
|
|
|
onOpenInNewWindow?: (entry: VaultEntry) => void
|
2026-04-04 18:16:52 +02:00
|
|
|
onDiscardFile?: (relativePath: string) => Promise<void>
|
2026-04-05 02:58:26 +02:00
|
|
|
onAutoTriggerDiff?: () => void
|
2026-04-07 20:09:04 +02:00
|
|
|
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
2026-04-07 20:31:08 +02:00
|
|
|
inboxNoteListProperties?: string[] | null
|
|
|
|
|
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
2026-04-02 16:09:43 +02:00
|
|
|
views?: ViewFile[]
|
2026-04-06 10:29:27 +02:00
|
|
|
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
|
2026-02-14 19:44:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 20:31:08 +02:00
|
|
|
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, inboxNoteListProperties, onUpdateInboxNoteListProperties, views, visibleNotesRef }: NoteListProps) {
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
2026-03-18 03:43:23 +01:00
|
|
|
|
2026-04-05 01:38:18 +02:00
|
|
|
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
|
2026-03-20 23:03:17 +01:00
|
|
|
const subFilter = showFilterPills ? noteListFilter : undefined
|
2026-03-18 03:43:23 +01:00
|
|
|
|
|
|
|
|
const filterCounts = useMemo(
|
2026-04-06 12:21:56 +02:00
|
|
|
() => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0 },
|
2026-03-31 11:14:50 +02:00
|
|
|
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
2026-03-18 03:43:23 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-19 06:41:40 +01:00
|
|
|
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
|
|
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const typeEntryMap = useTypeEntryMap(entries)
|
2026-04-07 20:31:08 +02:00
|
|
|
const inboxEntries = useMemo(
|
|
|
|
|
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
|
|
|
|
|
[entries, inboxPeriod, isInboxView],
|
|
|
|
|
)
|
|
|
|
|
const typeAvailableProperties = useMemo(
|
|
|
|
|
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
|
|
|
|
|
[entries, typeDocument],
|
|
|
|
|
)
|
|
|
|
|
const inboxAvailableProperties = useMemo(
|
|
|
|
|
() => collectAvailableProperties(inboxEntries),
|
|
|
|
|
[inboxEntries],
|
|
|
|
|
)
|
|
|
|
|
const inboxDefaultDisplay = useMemo(
|
|
|
|
|
() => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap),
|
|
|
|
|
[inboxEntries, typeEntryMap],
|
|
|
|
|
)
|
|
|
|
|
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
|
|
|
|
|
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
|
|
|
|
|
const propertyPicker = useMemo(() => {
|
|
|
|
|
if (isInboxView && onUpdateInboxNoteListProperties) {
|
|
|
|
|
return {
|
|
|
|
|
scope: 'inbox' as const,
|
|
|
|
|
availableProperties: inboxAvailableProperties,
|
|
|
|
|
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
|
|
|
|
|
onSave: onUpdateInboxNoteListProperties,
|
|
|
|
|
triggerTitle: 'Customize Inbox columns',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
|
|
|
|
|
return {
|
|
|
|
|
scope: 'type' as const,
|
|
|
|
|
availableProperties: typeAvailableProperties,
|
|
|
|
|
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
|
|
|
|
|
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
|
|
|
|
|
triggerTitle: 'Customize columns',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}, [
|
|
|
|
|
hasCustomInboxProperties,
|
|
|
|
|
inboxAvailableProperties,
|
|
|
|
|
inboxDefaultDisplay,
|
|
|
|
|
inboxNoteListProperties,
|
|
|
|
|
isInboxView,
|
|
|
|
|
isSectionGroup,
|
|
|
|
|
onUpdateInboxNoteListProperties,
|
|
|
|
|
onUpdateTypeSort,
|
|
|
|
|
typeAvailableProperties,
|
|
|
|
|
typeDocument,
|
|
|
|
|
])
|
2026-04-05 02:58:26 +02:00
|
|
|
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])
|
2026-04-07 20:09:04 +02:00
|
|
|
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
2026-04-06 10:29:27 +02:00
|
|
|
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
|
|
|
|
|
if (visibleNotesRef) {
|
|
|
|
|
visibleNotesRef.current = isEntityView
|
2026-04-07 20:09:04 +02:00
|
|
|
? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry))
|
|
|
|
|
: searched.filter((entry) => !isDeletedNoteEntry(entry))
|
2026-04-06 10:29:27 +02:00
|
|
|
}
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
|
|
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
|
|
|
|
|
if (isDeletedNoteEntry(entry)) {
|
|
|
|
|
onOpenDeletedNote?.(entry)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
onReplaceActiveTab(entry)
|
|
|
|
|
}, [onOpenDeletedNote, onReplaceActiveTab])
|
|
|
|
|
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
|
|
|
|
|
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
|
|
|
|
|
}, [])
|
|
|
|
|
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView })
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
|
2026-03-20 23:03:17 +01:00
|
|
|
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
|
2026-02-16 16:56:44 +01:00
|
|
|
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
2026-04-07 20:09:04 +02:00
|
|
|
if (isDeletedNoteEntry(entry)) {
|
|
|
|
|
routeNoteClick(entry, e, {
|
|
|
|
|
onReplace: () => onOpenDeletedNote?.(entry),
|
|
|
|
|
onSelect: () => onOpenDeletedNote?.(entry),
|
|
|
|
|
multiSelect,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-19 08:47:25 +01:00
|
|
|
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
2026-04-05 02:58:26 +02:00
|
|
|
if (isChangesView && onAutoTriggerDiff) {
|
|
|
|
|
// Small delay to let the tab open before triggering diff
|
|
|
|
|
setTimeout(onAutoTriggerDiff, 50)
|
|
|
|
|
}
|
2026-04-07 20:09:04 +02:00
|
|
|
}, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
|
2026-04-06 12:21:56 +02:00
|
|
|
const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView)
|
|
|
|
|
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently)
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
|
2026-04-04 18:16:52 +02:00
|
|
|
|
2026-04-05 02:58:26 +02:00
|
|
|
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])
|
|
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const handleListKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) {
|
|
|
|
|
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
|
|
|
|
|
if (!entry) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
|
|
|
|
|
const rect = row?.getBoundingClientRect()
|
|
|
|
|
openContextMenuForEntry(entry, {
|
|
|
|
|
x: rect ? rect.left + 24 : 160,
|
|
|
|
|
y: rect ? rect.bottom - 8 : 160,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
noteListKeyboard.handleKeyDown(e)
|
|
|
|
|
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
|
|
|
|
|
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const renderItem = useCallback((entry: VaultEntry) => (
|
2026-04-07 22:09:21 +02:00
|
|
|
<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} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
|
|
|
|
|
), [entries, selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride])
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
|
2026-04-02 10:06:40 +02:00
|
|
|
const handleCreateNote = useCallback(() => {
|
|
|
|
|
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
|
|
|
|
}, [onCreateNote, selection])
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
2026-04-02 16:09:43 +02:00
|
|
|
const title = resolveHeaderTitle(selection, typeDocument, views)
|
refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create
Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.
NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:15 +01:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
2026-04-07 20:31:08 +02:00
|
|
|
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} propertyPicker={propertyPicker} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
|
2026-04-07 20:09:04 +02:00
|
|
|
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={handleListKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
2026-03-16 03:55:52 +01:00
|
|
|
<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} />
|
|
|
|
|
) : (
|
2026-04-07 20:09:04 +02:00
|
|
|
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
2026-03-16 03:55:52 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-31 11:38:13 +02:00
|
|
|
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
2026-02-14 18:22:42 +01:00
|
|
|
</div>
|
2026-02-27 15:14:21 +01:00
|
|
|
{multiSelect.isMultiSelecting && (
|
2026-04-06 12:21:56 +02:00
|
|
|
<BulkActionBar count={multiSelect.selectedPaths.size} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onDelete={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
2026-02-27 15:14:21 +01:00
|
|
|
)}
|
2026-04-05 01:38:18 +02:00
|
|
|
{contextMenuNode}
|
|
|
|
|
{dialogNode}
|
2026-02-14 18:22:42 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-17 17:14:36 +01:00
|
|
|
|
|
|
|
|
export const NoteList = memo(NoteListInner)
|