diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7f5205d..f498be9f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -426,6 +426,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri - `tag_colors`, `status_colors`: Custom color overrides - `property_display_modes`: Property display preferences - `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list +- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list - `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection ### Getting Started Vault diff --git a/src/App.tsx b/src/App.tsx index f631c5a9..482933bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -505,9 +505,23 @@ function App() { }) }, [setInspectorCollapsed]) - const handleCustomizeInboxColumns = useCallback(() => { - openNoteListPropertiesPicker('inbox') - }, []) + const handleCustomizeNoteListColumns = useCallback(() => { + if (effectiveSelection.kind !== 'filter') return + if (effectiveSelection.filter === 'all') { + openNoteListPropertiesPicker('all') + return + } + if (effectiveSelection.filter === 'inbox') { + openNoteListPropertiesPicker('inbox') + } + }, [effectiveSelection]) + + const handleUpdateAllNotesNoteListProperties = useCallback((value: string[] | null) => { + updateConfig('allNotes', { + ...(vaultConfig.allNotes ?? { noteListProperties: null }), + noteListProperties: value && value.length > 0 ? value : null, + }) + }, [updateConfig, vaultConfig.allNotes]) const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => { updateConfig('inbox', { @@ -796,8 +810,9 @@ function App() { onOpenInNewWindow: handleOpenInNewWindow, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined, - onCustomizeInboxColumns: handleCustomizeInboxColumns, - canCustomizeInboxColumns: explicitOrganizationEnabled && effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox', + onCustomizeNoteListColumns: handleCustomizeNoteListColumns, + canCustomizeNoteListColumns: effectiveSelection.kind === 'filter' + && (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')), onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, canRestoreDeletedNote: !!activeDeletedFile, }) @@ -891,7 +906,7 @@ function App() { {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} /> + diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} /> )} diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx index 697d1c48..9ad3530c 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -196,6 +196,52 @@ describe('NoteList rendering', () => { expect(screen.queryByText('Luca')).not.toBeInTheDocument() }) + it('shows the all-notes customize-columns action and falls back to type-defined chips', () => { + renderNoteList({ + entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), + selection: allSelection, + allNotesNoteListProperties: null, + onUpdateAllNotesNoteListProperties: () => undefined, + }) + + expect(screen.getByTitle('Customize All Notes columns')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.queryByText('Luca')).not.toBeInTheDocument() + }) + + it('opens the all-notes column picker from the global event and saves new columns', () => { + const onUpdateAllNotesNoteListProperties = vi.fn() + const archivedOwnerEntry = makeEntry({ + path: '/vault/book-archive.md', + filename: 'book-archive.md', + title: 'Archived Book', + isA: 'Book', + archived: true, + properties: { Owner: 'Luca' }, + }) + + renderNoteList({ + entries: [ + ...makeBookTypeEntries(['Priority'], { properties: { Priority: 'High' } }), + archivedOwnerEntry, + ], + selection: allSelection, + allNotesNoteListProperties: null, + onUpdateAllNotesNoteListProperties, + }) + + act(() => { + openNoteListPropertiesPicker('all') + }) + + expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) + expect(onUpdateAllNotesNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) + }) + it('opens the inbox column picker from the global event and saves new columns', () => { const onUpdateInboxNoteListProperties = vi.fn() diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index 6f13aa85..06f10525 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -17,6 +17,7 @@ import type { DeletedNoteEntry } from './noteListUtils' import { useMultiSelect, type MultiSelectState } from '../../hooks/useMultiSelect' import { useNoteListKeyboard } from '../../hooks/useNoteListKeyboard' import { prefetchNoteContent } from '../../hooks/useTabManagement' +import type { NoteListPropertiesScope } from './noteListPropertiesEvents' // --- useTypeEntryMap --- @@ -26,19 +27,71 @@ export function useTypeEntryMap(entries: VaultEntry[]) { // --- useFilteredEntries --- -export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[], modifiedFiles?: ModifiedFile[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) { +interface FilteredEntriesParams { + entries: VaultEntry[] + selection: SidebarSelection + modifiedPathSet: Set + modifiedSuffixes: string[] + modifiedFiles?: ModifiedFile[] + subFilter?: NoteListFilter + inboxPeriod?: InboxPeriod + views?: ViewFile[] +} + +function buildFilteredEntries({ + entries, + selection, + isEntityView, + isChangesView, + isInboxView, + modifiedPathSet, + modifiedSuffixes, + modifiedFiles, + subFilter, + inboxPeriod, + views, +}: FilteredEntriesParams & { + isEntityView: boolean + isChangesView: boolean + isInboxView: boolean +}) { + if (isEntityView) return [] + if (isChangesView) { + if (modifiedFiles) return buildChangesEntries(entries, modifiedFiles) + return entries.filter((entry) => isModifiedEntry(entry.path, modifiedPathSet, modifiedSuffixes)) + } + if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month') + return filterEntries(entries, selection, subFilter, views) +} + +export function useFilteredEntries({ + entries, + selection, + modifiedPathSet, + modifiedSuffixes, + modifiedFiles, + subFilter, + inboxPeriod, + views, +}: FilteredEntriesParams) { const isEntityView = selection.kind === 'entity' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' return useMemo(() => { - if (isEntityView) return [] - if (isChangesView) { - if (modifiedFiles) return buildChangesEntries(entries, modifiedFiles) - return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) - } - if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month') - return filterEntries(entries, selection, subFilter, views) - }, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views]) + return buildFilteredEntries({ + entries, + selection, + isEntityView, + isChangesView, + isInboxView, + modifiedPathSet, + modifiedSuffixes, + modifiedFiles, + subFilter, + inboxPeriod, + views, + }) + }, [entries, inboxPeriod, isChangesView, isEntityView, isInboxView, modifiedFiles, modifiedPathSet, modifiedSuffixes, selection, subFilter, views]) } // --- useNoteListData --- @@ -57,7 +110,16 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec const isEntityView = selection.kind === 'entity' const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived' - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views) + const filteredEntries = useFilteredEntries({ + entries, + selection, + modifiedPathSet, + modifiedSuffixes, + modifiedFiles, + subFilter, + inboxPeriod, + views, + }) const searched = useMemo(() => { const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection)) @@ -114,6 +176,11 @@ function persistSortToType(path: string, config: SortConfig, persistence: SortPe clearListSortFromLocalStorage() } +function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) { + if (groupLabel !== '__list__' || !typeDocument || !persistence) return null + return { path: typeDocument.path, persistence } +} + function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record, migrationDone: Set, persistence: SortPersistence) { if (typeDoc.sort || migrationDone.has(typeDoc.path)) return const lsConfig = sortPrefs['__list__'] @@ -163,14 +230,19 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS }, [typeDocument, sortPrefs, persistence]) const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { - if (groupLabel === '__list__' && typeDocument && persistence) { - persistSortToType(typeDocument.path, { option, direction }, persistence) - } else { - saveGroupSort(groupLabel, option, direction, setSortPrefs) - } + const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence) + if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs) + persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence) }, [typeDocument, persistence]) - const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, undefined, subFilter, inboxPeriod) + const filteredEntries = useFilteredEntries({ + entries, + selection, + modifiedPathSet, + modifiedSuffixes, + subFilter, + inboxPeriod, + }) const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) const listSort = useMemo(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' @@ -306,7 +378,7 @@ function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string) return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName)) } -function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record): string[] { +function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record): string[] { const ordered: string[] = [] const seen = new Set() @@ -322,38 +394,42 @@ function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record void triggerTitle: string } -interface BuildInboxPropertyPickerParams { - isInboxView: boolean - onUpdateInboxNoteListProperties?: (value: string[] | null) => void - inboxAvailableProperties: string[] - hasCustomInboxProperties: boolean - inboxNoteListProperties?: string[] | null - inboxDefaultDisplay: string[] +interface BuildFilterPropertyPickerParams { + scope: Exclude + isActive: boolean + availableProperties: string[] + hasCustomProperties: boolean + noteListProperties?: string[] | null + defaultDisplay: string[] + onSave?: (value: string[] | null) => void + triggerTitle: string } -function buildInboxPropertyPicker({ - isInboxView, - onUpdateInboxNoteListProperties, - inboxAvailableProperties, - hasCustomInboxProperties, - inboxNoteListProperties, - inboxDefaultDisplay, -}: BuildInboxPropertyPickerParams): NoteListPropertyPicker | null { - if (!isInboxView || !onUpdateInboxNoteListProperties) return null +function buildFilterPropertyPicker({ + scope, + isActive, + availableProperties, + hasCustomProperties, + noteListProperties, + defaultDisplay, + onSave, + triggerTitle, +}: BuildFilterPropertyPickerParams): NoteListPropertyPicker | null { + if (!isActive || !onSave) return null return { - scope: 'inbox', - availableProperties: inboxAvailableProperties, - currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay, - onSave: onUpdateInboxNoteListProperties, - triggerTitle: 'Customize Inbox columns', + scope, + availableProperties, + currentDisplay: hasCustomProperties ? noteListProperties ?? [] : defaultDisplay, + onSave, + triggerTitle, } } @@ -387,6 +463,8 @@ interface UseListPropertyPickerParams { inboxPeriod: InboxPeriod typeDocument: VaultEntry | null typeEntryMap: Record + allNotesNoteListProperties?: string[] | null + onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null onUpdateInboxNoteListProperties?: (value: string[] | null) => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void @@ -398,17 +476,37 @@ export function useListPropertyPicker({ inboxPeriod, typeDocument, typeEntryMap, + allNotesNoteListProperties, + onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, onUpdateTypeSort, }: UseListPropertyPickerParams) { + const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all' const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' const isSectionGroup = selection.kind === 'sectionGroup' + const allNotesEntries = useMemo( + () => isAllNotesView + ? [ + ...filterEntries(entries, selection, 'open'), + ...filterEntries(entries, selection, 'archived'), + ] + : [], + [entries, isAllNotesView, selection], + ) const inboxEntries = useMemo( () => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [], [entries, inboxPeriod, isInboxView], ) + const allNotesAvailableProperties = useMemo( + () => collectAvailableProperties(allNotesEntries), + [allNotesEntries], + ) + const allNotesDefaultDisplay = useMemo( + () => deriveDefaultDisplay(allNotesEntries, typeEntryMap), + [allNotesEntries, typeEntryMap], + ) const typeAvailableProperties = useMemo( () => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [], [entries, typeDocument], @@ -418,20 +516,34 @@ export function useListPropertyPicker({ [inboxEntries], ) const inboxDefaultDisplay = useMemo( - () => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap), + () => deriveDefaultDisplay(inboxEntries, typeEntryMap), [inboxEntries, typeEntryMap], ) + const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0) const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0) - const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null + const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties + ? allNotesNoteListProperties + : (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null) const propertyPicker = useMemo(() => { - return buildInboxPropertyPicker({ - isInboxView, - onUpdateInboxNoteListProperties, - inboxAvailableProperties, - hasCustomInboxProperties, - inboxNoteListProperties, - inboxDefaultDisplay, + return buildFilterPropertyPicker({ + scope: 'all', + isActive: isAllNotesView, + availableProperties: allNotesAvailableProperties, + hasCustomProperties: hasCustomAllNotesProperties, + noteListProperties: allNotesNoteListProperties, + defaultDisplay: allNotesDefaultDisplay, + onSave: onUpdateAllNotesNoteListProperties, + triggerTitle: 'Customize All Notes columns', + }) ?? buildFilterPropertyPicker({ + scope: 'inbox', + isActive: isInboxView, + availableProperties: inboxAvailableProperties, + hasCustomProperties: hasCustomInboxProperties, + noteListProperties: inboxNoteListProperties, + defaultDisplay: inboxDefaultDisplay, + onSave: onUpdateInboxNoteListProperties, + triggerTitle: 'Customize Inbox columns', }) ?? buildTypePropertyPicker({ isSectionGroup, typeDocument, @@ -439,6 +551,12 @@ export function useListPropertyPicker({ typeAvailableProperties, }) }, [ + allNotesAvailableProperties, + allNotesDefaultDisplay, + allNotesNoteListProperties, + hasCustomAllNotesProperties, + isAllNotesView, + onUpdateAllNotesNoteListProperties, hasCustomInboxProperties, inboxAvailableProperties, inboxDefaultDisplay, @@ -451,7 +569,7 @@ export function useListPropertyPicker({ typeDocument, ]) - return { inboxDisplayOverride, propertyPicker } + return { displayPropsOverride, propertyPicker } } // --- useNoteListInteractions --- @@ -473,6 +591,29 @@ interface UseNoteListInteractionsParams { onCreateNote: (type?: string) => void } +function resolveChangesContextMenuEntry( + event: React.KeyboardEvent, + isChangesView: boolean, + onDiscardFile: ((relativePath: string) => Promise) | undefined, + highlightedPath: string | null, + searched: VaultEntry[], +) { + if (!isChangesView || !onDiscardFile || !event.shiftKey || event.key !== 'F10' || !highlightedPath) return null + return searched.find((candidate) => candidate.path === highlightedPath) ?? null +} + +function openHighlightedChangesContextMenu( + entry: VaultEntry, + openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void, +) { + const row = document.querySelector(`[data-note-path="${entry.path}"]`) + const rect = row?.getBoundingClientRect() + openContextMenuForEntry(entry, { + x: rect ? rect.left + 24 : 160, + y: rect ? rect.bottom - 8 : 160, + }) +} + export function useNoteListInteractions({ searched, selectedNotePath, @@ -547,19 +688,17 @@ export function useNoteListInteractions({ ]) const handleListKeyDown = useCallback((event: React.KeyboardEvent) => { - if (isChangesView && onDiscardFile && event.shiftKey && event.key === 'F10' && noteListKeyboard.highlightedPath) { - const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath) - if (!entry) return - + const entry = resolveChangesContextMenuEntry( + event, + isChangesView, + onDiscardFile, + noteListKeyboard.highlightedPath, + searched, + ) + if (entry) { event.preventDefault() event.stopPropagation() - - const row = document.querySelector(`[data-note-path="${entry.path}"]`) - const rect = row?.getBoundingClientRect() - openContextMenuForEntry(entry, { - x: rect ? rect.left + 24 : 160, - y: rect ? rect.bottom - 8 : 160, - }) + openHighlightedChangesContextMenu(entry, openContextMenuForEntry) return } diff --git a/src/components/note-list/noteListPropertiesEvents.ts b/src/components/note-list/noteListPropertiesEvents.ts index d0d08649..9def51b2 100644 --- a/src/components/note-list/noteListPropertiesEvents.ts +++ b/src/components/note-list/noteListPropertiesEvents.ts @@ -1,4 +1,4 @@ -export type NoteListPropertiesScope = 'type' | 'inbox' +export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' export interface OpenListPropertiesEventDetail { scope: NoteListPropertiesScope diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index be1495b4..3338f950 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -69,6 +69,264 @@ function useBulkActions( } } +function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) { + return useMemo(() => { + if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type) + if (selection.kind === 'folder') return countAllByFilter(entries) + if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries) + return { open: 0, archived: 0 } + }, [entries, selection]) +} + +interface UseNoteListContentParams { + entries: VaultEntry[] + selection: SidebarSelection + noteListFilter: NoteListFilter + inboxPeriod: InboxPeriod + modifiedFiles?: ModifiedFile[] + modifiedSuffixes: string[] + modifiedPathSet: Set + isInboxView: boolean + allNotesNoteListProperties?: string[] | null + onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void + inboxNoteListProperties?: string[] | null + onUpdateInboxNoteListProperties?: (value: string[] | null) => void + onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void + updateEntry?: (path: string, patch: Partial) => void + views?: ViewFile[] + visibleNotesRef?: React.MutableRefObject +} + +function useNoteListContent({ + entries, + selection, + noteListFilter, + inboxPeriod, + modifiedFiles, + modifiedSuffixes, + modifiedPathSet, + isInboxView, + allNotesNoteListProperties, + onUpdateAllNotesNoteListProperties, + inboxNoteListProperties, + onUpdateInboxNoteListProperties, + onUpdateTypeSort, + updateEntry, + views, + visibleNotesRef, +}: UseNoteListContentParams) { + const subFilter = (selection.kind === 'sectionGroup' || selection.kind === 'folder') + ? noteListFilter + : undefined + const effectiveInboxPeriod = isInboxView ? inboxPeriod : undefined + const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ + entries, + selection, + modifiedPathSet, + modifiedSuffixes, + subFilter, + inboxPeriod: effectiveInboxPeriod, + onUpdateTypeSort, + updateEntry, + }) + const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() + const typeEntryMap = useTypeEntryMap(entries) + const { displayPropsOverride, propertyPicker } = useListPropertyPicker({ + entries, + selection, + inboxPeriod, + typeDocument, + typeEntryMap, + allNotesNoteListProperties, + onUpdateAllNotesNoteListProperties, + inboxNoteListProperties, + onUpdateInboxNoteListProperties, + onUpdateTypeSort, + }) + const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ + entries, + selection, + query, + listSort, + listDirection, + modifiedPathSet, + modifiedSuffixes, + modifiedFiles, + subFilter, + inboxPeriod: effectiveInboxPeriod, + views, + }) + useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }) + + return { + customProperties, + displayPropsOverride, + handleSortChange, + isArchivedView, + isEntityView, + listDirection, + listSort, + propertyPicker, + query, + search, + searchVisible, + searched, + searchedGroups, + setSearch, + sortPrefs, + toggleSearch, + typeDocument, + typeEntryMap, + } +} + +interface UseNoteListInteractionStateParams { + searched: VaultEntry[] + selectedNotePath: string | null + selection: SidebarSelection + noteListFilter: NoteListFilter + isArchivedView: boolean + isChangesView: boolean + isEntityView: boolean + modifiedFiles?: ModifiedFile[] + onReplaceActiveTab: (entry: VaultEntry) => void + onSelectNote: (entry: VaultEntry) => void + onOpenDeletedNote?: (entry: DeletedNoteEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void + onAutoTriggerDiff?: () => void + onDiscardFile?: (relativePath: string) => Promise + onCreateNote: (type?: string) => void + onBulkArchive?: (paths: string[]) => void + onBulkDeletePermanently?: (paths: string[]) => void +} + +function useNoteListInteractionState({ + searched, + selectedNotePath, + selection, + noteListFilter, + isArchivedView, + isChangesView, + isEntityView, + modifiedFiles, + onReplaceActiveTab, + onSelectNote, + onOpenDeletedNote, + onOpenInNewWindow, + onAutoTriggerDiff, + onDiscardFile, + onCreateNote, + onBulkArchive, + onBulkDeletePermanently, +}: UseNoteListInteractionStateParams) { + const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) + const { + collapsedGroups, + handleClickNote, + handleCreateNote, + handleListKeyDown, + multiSelect, + noteListKeyboard, + toggleGroup, + } = useNoteListInteractions({ + searched, + selectedNotePath, + selection, + noteListFilter, + isEntityView, + isChangesView, + onReplaceActiveTab, + onSelectNote, + onOpenDeletedNote, + onOpenInNewWindow, + onAutoTriggerDiff, + onDiscardFile, + openContextMenuForEntry: changesContextMenu.openContextMenuForEntry, + onCreateNote, + }) + const getChangeStatus = useChangeStatusResolver(isChangesView, modifiedFiles) + const { + handleBulkArchive, + handleBulkDeletePermanently, + handleBulkUnarchive, + } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView) + + return { + changesContextMenu, + collapsedGroups, + getChangeStatus, + handleBulkArchive, + handleBulkDeletePermanently, + handleBulkUnarchive, + handleClickNote, + handleCreateNote, + handleListKeyDown, + multiSelect, + noteListKeyboard, + toggleGroup, + } +} + +interface UseRenderItemParams { + entries: VaultEntry[] + selectedNotePath: string | null + typeEntryMap: Record + displayPropsOverride?: string[] | null + isChangesView: boolean + onDiscardFile?: (relativePath: string) => Promise + resolvedGetNoteStatus: (path: string) => NoteStatus + getChangeStatus: (path: string) => string | undefined + handleClickNote: (entry: VaultEntry, event?: React.MouseEvent) => void + noteContextMenu?: ((entry: VaultEntry, event: React.MouseEvent) => void) | undefined + multiSelect: MultiSelectState + noteListKeyboard: { highlightedPath: string | null } +} + +function useRenderItem({ + entries, + selectedNotePath, + typeEntryMap, + displayPropsOverride, + isChangesView, + onDiscardFile, + resolvedGetNoteStatus, + getChangeStatus, + handleClickNote, + noteContextMenu, + multiSelect, + noteListKeyboard, +}: UseRenderItemParams) { + const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined + + return useCallback((entry: VaultEntry) => ( + + ), [ + contextMenuHandler, + displayPropsOverride, + entries, + getChangeStatus, + handleClickNote, + multiSelect.selectedPaths, + noteListKeyboard.highlightedPath, + resolvedGetNoteStatus, + selectedNotePath, + typeEntryMap, + ]) +} + export interface NoteListProps { entries: VaultEntry[] selection: SidebarSelection @@ -92,6 +350,8 @@ export interface NoteListProps { onDiscardFile?: (relativePath: string) => Promise onAutoTriggerDiff?: () => void onOpenDeletedNote?: (entry: DeletedNoteEntry) => void + allNotesNoteListProperties?: string[] | null + onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null onUpdateInboxNoteListProperties?: (value: string[] | null) => void views?: ViewFile[] @@ -120,123 +380,99 @@ export function useNoteListModel({ onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, + allNotesNoteListProperties, + onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, 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 }, - [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 typeEntryMap = useTypeEntryMap(entries) - const { inboxDisplayOverride, propertyPicker } = useListPropertyPicker({ - entries, - selection, - inboxPeriod, + const { isInboxView, isChangesView, showFilterPills } = useViewFlags(selection) + const filterCounts = useFilterCounts(entries, selection) + const { + customProperties, + displayPropsOverride, + handleSortChange, + isArchivedView, + isEntityView, + listDirection, + listSort, + propertyPicker, + query, + search, + searchVisible, + searched, + searchedGroups, + setSearch, + sortPrefs, + toggleSearch, typeDocument, typeEntryMap, + } = useNoteListContent({ + entries, + selection, + noteListFilter, + inboxPeriod, + modifiedFiles, + modifiedSuffixes, + modifiedPathSet, + isInboxView, + allNotesNoteListProperties, + onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, onUpdateTypeSort, - }) - const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ - entries, - selection, - query, - listSort, - listDirection, - modifiedPathSet, - modifiedSuffixes, - modifiedFiles, - subFilter, - inboxPeriod: isInboxView ? inboxPeriod : undefined, + updateEntry, views, + visibleNotesRef, }) - useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }) - - const changesContextMenu = useChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) const { + changesContextMenu, collapsedGroups, + getChangeStatus, + handleBulkArchive, + handleBulkDeletePermanently, + handleBulkUnarchive, handleClickNote, handleCreateNote, handleListKeyDown, multiSelect, noteListKeyboard, toggleGroup, - } = useNoteListInteractions({ + } = useNoteListInteractionState({ searched, selectedNotePath: selectedNote?.path ?? null, selection, noteListFilter, + isArchivedView, isEntityView, isChangesView, + modifiedFiles, onReplaceActiveTab, onSelectNote, onOpenDeletedNote, onOpenInNewWindow, onAutoTriggerDiff, onDiscardFile, - openContextMenuForEntry: changesContextMenu.openContextMenuForEntry, onCreateNote, + onBulkArchive, + onBulkDeletePermanently, }) - const getChangeStatus = useChangeStatusResolver(isChangesView, modifiedFiles) - - const { - handleBulkArchive, - handleBulkDeletePermanently, - handleBulkUnarchive, - } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView) - - const renderItem = useCallback((entry: VaultEntry) => ( - - ), [ + const renderItem = useRenderItem({ entries, - selectedNote?.path, - multiSelect.selectedPaths, - noteListKeyboard.highlightedPath, - resolvedGetNoteStatus, - getChangeStatus, + selectedNotePath: selectedNote?.path ?? null, typeEntryMap, - inboxDisplayOverride, - handleClickNote, + displayPropsOverride, isChangesView, onDiscardFile, - changesContextMenu.handleNoteContextMenu, - ]) + resolvedGetNoteStatus, + getChangeStatus, + handleClickNote, + noteContextMenu: changesContextMenu.handleNoteContextMenu, + multiSelect, + noteListKeyboard, + }) return { title: resolveHeaderTitle(selection, typeDocument, views), diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 32939588..57124fe7 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -13,15 +13,17 @@ interface ViewCommandsConfig { onZoomIn: () => void onZoomOut: () => void onZoomReset: () => void - onCustomizeInboxColumns?: () => void - canCustomizeInboxColumns?: boolean + onCustomizeNoteListColumns?: () => void + canCustomizeNoteListColumns?: boolean + noteListColumnsLabel: string } export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { const { hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, - zoomLevel, onZoomIn, onZoomOut, onZoomReset, onCustomizeInboxColumns, canCustomizeInboxColumns, + zoomLevel, onZoomIn, onZoomOut, onZoomReset, + onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, } = config return [ @@ -33,7 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { { id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() }, { id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() }, { id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector }, - { id: 'customize-inbox-columns', label: 'Customize Inbox columns', group: 'View', keywords: ['inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeInboxColumns && onCustomizeInboxColumns), execute: () => onCustomizeInboxColumns?.() }, + { id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() }, { id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn }, { id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut }, { id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index bc1b828d..c855613c 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -80,8 +80,8 @@ interface AppCommandsConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void - onCustomizeInboxColumns?: () => void - canCustomizeInboxColumns?: boolean + onCustomizeNoteListColumns?: () => void + canCustomizeNoteListColumns?: boolean onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean } @@ -222,8 +222,8 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters { }) it('includes Customize Inbox columns when the Inbox action is available', () => { - const onCustomizeInboxColumns = vi.fn() + const onCustomizeNoteListColumns = vi.fn() const config = makeConfig({ selection: { kind: 'filter', filter: 'inbox' }, - onCustomizeInboxColumns, - canCustomizeInboxColumns: true, + onCustomizeNoteListColumns, + canCustomizeNoteListColumns: true, }) const { result } = renderHook(() => useCommandRegistry(config)) - const cmd = findCommand(result.current, 'customize-inbox-columns') + const cmd = findCommand(result.current, 'customize-note-list-columns') expect(cmd).toBeDefined() expect(cmd!.enabled).toBe(true) + expect(cmd!.label).toBe('Customize Inbox columns') cmd!.execute() - expect(onCustomizeInboxColumns).toHaveBeenCalled() + expect(onCustomizeNoteListColumns).toHaveBeenCalled() }) - it('disables Customize Inbox columns outside the Inbox view', () => { + it('includes Customize All Notes columns in the all-notes view', () => { const config = makeConfig({ selection: { kind: 'filter', filter: 'all' }, - onCustomizeInboxColumns: vi.fn(), - canCustomizeInboxColumns: false, + onCustomizeNoteListColumns: vi.fn(), + canCustomizeNoteListColumns: true, }) const { result } = renderHook(() => useCommandRegistry(config)) - const cmd = findCommand(result.current, 'customize-inbox-columns') + const cmd = findCommand(result.current, 'customize-note-list-columns') + expect(cmd).toBeDefined() + expect(cmd!.enabled).toBe(true) + expect(cmd!.label).toBe('Customize All Notes columns') + }) + + it('disables note-list column customization outside supported views', () => { + const config = makeConfig({ + selection: { kind: 'sectionGroup', type: 'Book' }, + onCustomizeNoteListColumns: vi.fn(), + canCustomizeNoteListColumns: false, + }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'customize-note-list-columns') expect(cmd!.enabled).toBe(false) }) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index dff86a8f..2b83e559 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -43,8 +43,8 @@ interface CommandRegistryConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void - onCustomizeInboxColumns?: () => void - canCustomizeInboxColumns?: boolean + onCustomizeNoteListColumns?: () => void + canCustomizeNoteListColumns?: boolean onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean onQuickOpen: () => void @@ -105,7 +105,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, onToggleOrganized, - onCustomizeInboxColumns, canCustomizeInboxColumns, + onCustomizeNoteListColumns, canCustomizeNoteListColumns, onRestoreDeletedNote, canRestoreDeletedNote, selection, noteListFilter, onSetNoteListFilter, } = config @@ -119,6 +119,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const isArchived = activeEntry?.archived ?? false const isFavorite = activeEntry?.favorite ?? false const isSectionGroup = selection?.kind === 'sectionGroup' + const noteListColumnsLabel = selection?.kind === 'filter' && selection.filter === 'all' + ? 'Customize All Notes columns' + : 'Customize Inbox columns' const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries]) @@ -136,7 +139,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com ...buildViewCommands({ hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, - onCustomizeInboxColumns, canCustomizeInboxColumns, + onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, }), ...buildSettingsCommands({ mcpStatus, vaultCount, isGettingStartedHidden, @@ -173,7 +176,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, onOpenInNewWindow, onToggleFavorite, isFavorite, - onToggleOrganized, onCustomizeInboxColumns, canCustomizeInboxColumns, + onToggleOrganized, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, onRestoreDeletedNote, canRestoreDeletedNote, activeEntry, ]) } diff --git a/src/types.ts b/src/types.ts index 0d978c39..47440fb1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -132,6 +132,11 @@ export interface InboxConfig { explicitOrganization?: boolean | null } +/** Vault-scoped UI configuration stored locally per vault path. */ +export interface AllNotesConfig { + noteListProperties: string[] | null +} + /** Vault-scoped UI configuration stored locally per vault path. */ export interface VaultConfig { zoom: number | null @@ -141,6 +146,7 @@ export interface VaultConfig { status_colors: Record | null property_display_modes: Record | null inbox?: InboxConfig | null + allNotes?: AllNotesConfig | null } export interface PulseFile {