diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f2107ea6..5fe5ff96 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -177,7 +177,7 @@ flowchart TD ``` - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. -- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. +- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs index ea8b7b1f..4e1d44a8 100644 --- a/src-tauri/src/vault/views.rs +++ b/src-tauri/src/vault/views.rs @@ -17,6 +17,12 @@ pub struct ViewDefinition { pub color: Option, #[serde(default)] pub sort: Option, + #[serde( + default, + rename = "listPropertiesDisplay", + skip_serializing_if = "Vec::is_empty" + )] + pub list_properties_display: Vec, pub filters: FilterGroup, } @@ -195,6 +201,37 @@ pub fn migrate_views(vault_path: &Path) { } /// Scan all `.yml` files from `vault_path/views/` and return parsed views. +fn is_view_definition_file(path: &Path) -> bool { + path.extension().and_then(|ext| ext.to_str()) == Some("yml") +} + +fn read_view_file(path: &Path) -> Option { + if !is_view_definition_file(path) { + return None; + } + + let filename = path.file_name()?.to_string_lossy().to_string(); + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + log::warn!("Failed to read view file {}: {}", filename, error); + return None; + } + }; + let definition = match serde_yaml::from_str::(&content) { + Ok(definition) => definition, + Err(error) => { + log::warn!("Failed to parse view {}: {}", filename, error); + return None; + } + }; + + Some(ViewFile { + filename, + definition, + }) +} + pub fn scan_views(vault_path: &Path) -> Vec { migrate_views(vault_path); let views_dir = vault_path.join("views"); @@ -212,20 +249,8 @@ pub fn scan_views(vault_path: &Path) -> Vec { }; for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("yml") { - continue; - } - let filename = entry.file_name().to_string_lossy().to_string(); - match fs::read_to_string(&path) { - Ok(content) => match serde_yaml::from_str::(&content) { - Ok(definition) => views.push(ViewFile { - filename, - definition, - }), - Err(e) => log::warn!("Failed to parse view {}: {}", filename, e), - }, - Err(e) => log::warn!("Failed to read view file {}: {}", filename, e), + if let Some(view) = read_view_file(&entry.path()) { + views.push(view); } } @@ -656,6 +681,22 @@ mod tests { entry } + fn make_project_view(name: &str) -> ViewDefinition { + ViewDefinition { + name: name.to_string(), + icon: None, + color: None, + sort: None, + list_properties_display: Vec::new(), + filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { + field: "type".to_string(), + op: FilterOp::Equals, + value: Some(serde_yaml::Value::String("Project".to_string())), + regex: false, + })]), + } + } + #[test] fn test_parse_simple_view() { let yaml = r#" @@ -670,6 +711,7 @@ filters: let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap(); assert_eq!(def.name, "Active Projects"); assert_eq!(def.icon.as_deref(), Some("rocket")); + assert!(def.list_properties_display.is_empty()); match &def.filters { FilterGroup::All(nodes) => { assert_eq!(nodes.len(), 1); @@ -945,18 +987,10 @@ filters: fn test_save_and_read_view() { let dir = tempfile::TempDir::new().unwrap(); - let def = ViewDefinition { - name: "Test View".to_string(), - icon: Some("star".to_string()), - color: None, - sort: Some("modified:desc".to_string()), - filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { - field: "type".to_string(), - op: FilterOp::Equals, - value: Some(serde_yaml::Value::String("Project".to_string())), - regex: false, - })]), - }; + let mut def = make_project_view("Test View"); + def.icon = Some("star".to_string()); + def.sort = Some("modified:desc".to_string()); + def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()]; save_view(dir.path(), "test.yml", &def).unwrap(); @@ -964,6 +998,10 @@ filters: assert_eq!(views.len(), 1); assert_eq!(views[0].definition.name, "Test View"); assert_eq!(views[0].definition.icon.as_deref(), Some("star")); + assert_eq!( + views[0].definition.list_properties_display, + vec!["Priority".to_string(), "Owner".to_string()] + ); delete_view(dir.path(), "test.yml").unwrap(); let views = scan_views(dir.path()); @@ -974,18 +1012,8 @@ filters: fn test_save_and_read_view_with_emoji_icon() { let dir = tempfile::TempDir::new().unwrap(); - let def = ViewDefinition { - name: "Monday".to_string(), - icon: Some("🗂️".to_string()), - color: None, - sort: None, - filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { - field: "type".to_string(), - op: FilterOp::Equals, - value: Some(serde_yaml::Value::String("Project".to_string())), - regex: false, - })]), - }; + let mut def = make_project_view("Monday"); + def.icon = Some("🗂️".to_string()); save_view(dir.path(), "monday.yml", &def).unwrap(); diff --git a/src/App.tsx b/src/App.tsx index 84ec487f..bb0c8199 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,7 +69,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection, InboxPeriod, VaultEntry } from './types' +import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openNoteInNewWindow } from './utils/openNoteWindow' @@ -561,6 +561,11 @@ function App() { }, [setInspectorCollapsed]) const handleCustomizeNoteListColumns = useCallback(() => { + if (effectiveSelection.kind === 'view') { + openNoteListPropertiesPicker('view') + return + } + if (effectiveSelection.kind !== 'filter') return if (effectiveSelection.filter === 'all') { openNoteListPropertiesPicker('all') @@ -800,21 +805,35 @@ function App() { ) }, [notes, setToastMessage, vault.entries]) - const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => { + const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => { const editing = dialogs.editingView const filename = editing ? editing.filename : definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml' + const nextDefinition = editing ? { ...editing.definition, ...definition } : definition const target = isTauri() ? invoke : mockInvoke - await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition }) + await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition }) trackEvent(editing ? 'view_updated' : 'view_created') await vault.reloadViews() await vault.reloadVault() vault.reloadFolders() - setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`) + setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`) handleSetSelection({ kind: 'view', filename }) }, [resolvedPath, vault, handleSetSelection, dialogs.editingView]) + const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial) => { + const existing = vault.views.find((view) => view.filename === filename) + if (!existing) return + + const target = isTauri() ? invoke : mockInvoke + await target('save_view_cmd', { + vaultPath: resolvedPath, + filename, + definition: { ...existing.definition, ...patch }, + }) + await vault.reloadViews() + }, [resolvedPath, vault]) + const handleEditView = useCallback((filename: string) => { const view = vault.views.find((v) => v.filename === filename) if (view) dialogs.openEditView(filename, view.definition) @@ -962,6 +981,17 @@ function App() { && activeCommandEntry.filename.toLowerCase().endsWith('.md') && !activeDeletedFile + const noteListColumnsLabel = useMemo(() => { + if (effectiveSelection.kind === 'view') { + const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename) + return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns' + } + + return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all' + ? 'Customize All Notes columns' + : 'Customize Inbox columns' + }, [effectiveSelection, vault.views]) + const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, entries: vault.entries, @@ -1026,8 +1056,12 @@ function App() { onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined, onCustomizeNoteListColumns: handleCustomizeNoteListColumns, - canCustomizeNoteListColumns: effectiveSelection.kind === 'filter' - && (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')), + canCustomizeNoteListColumns: effectiveSelection.kind === 'view' + || ( + effectiveSelection.kind === 'filter' + && (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')) + ), + noteListColumnsLabel, onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, canRestoreDeletedNote: !!activeDeletedFile, }) @@ -1135,7 +1169,7 @@ function App() { {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( handleSetViewMode('all')} /> ) : ( - 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} /> + 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 b0c94566..d4d85ee3 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -1,13 +1,17 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { NoteList } from './NoteList' import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents' import { allSelection, + buildNoteListProps, makeEntry, makeTypeDefinition, mockEntries, renderNoteList, } from '../test-utils/noteListTestUtils' +import type { ViewFile } from '../types' function makeBookTypeEntries( displayProps: string[] = [], @@ -28,6 +32,58 @@ function makeBookTypeEntries( const noop = () => undefined +function makeViewDefinition(overrides: Partial = {}): ViewFile { + return { + filename: 'active-books.yml', + definition: { + name: 'Active Books', + icon: null, + color: null, + sort: null, + filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] }, + ...overrides.definition, + }, + ...overrides, + } +} + +function renderManagedViewNoteList({ + entries, + view = makeViewDefinition(), +}: { + entries: Parameters[0]['entries'] + view?: ViewFile +}) { + const built = buildNoteListProps({ + entries, + selection: { kind: 'view', filename: view.filename }, + views: [view], + }) + + function ManagedViewNoteList() { + const [views, setViews] = useState([view]) + + return ( + { + setViews((currentViews) => currentViews.map((currentView) => ( + currentView.filename === filename + ? { ...currentView, definition: { ...currentView.definition, ...patch } } + : currentView + ))) + }} + /> + ) + } + + return { + ...render(), + ...built, + } +} + function searchNoteList(query: string) { const searchInput = screen.queryByPlaceholderText('Search notes...') if (!searchInput) fireEvent.click(screen.getByTitle('Search notes')) @@ -354,6 +410,44 @@ describe('NoteList rendering', () => { expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) }) + it('opens the view column picker from the global event and applies the saved columns', () => { + renderManagedViewNoteList({ + entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }), + }) + + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.queryByText('Luca')).not.toBeInTheDocument() + + act(() => { + openNoteListPropertiesPicker('view') + }) + + expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) + + expect(screen.getByText('Luca')).toBeInTheDocument() + }) + + it('shows an empty-state picker for views with no matching properties', () => { + renderManagedViewNoteList({ + entries: makeBookTypeEntries(), + view: makeViewDefinition({ + filename: 'empty-view.yml', + definition: { + name: 'Empty View', + filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] }, + }, + }), + }) + + act(() => { + openNoteListPropertiesPicker('view') + }) + + expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByText('No properties match this search.')).toBeInTheDocument() + }) + it('shows status in the type column picker when at least one note has it set', () => { renderNoteList({ entries: makeBookTypeEntries([], { status: 'Active' }), diff --git a/src/components/NoteList.sorting.test.tsx b/src/components/NoteList.sorting.test.tsx index fe723892..48686fab 100644 --- a/src/components/NoteList.sorting.test.tsx +++ b/src/components/NoteList.sorting.test.tsx @@ -1,8 +1,11 @@ +import { useState } from 'react' import { beforeEach, describe, expect, it } from 'vitest' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { NoteList } from './NoteList' import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage' import { getSortComparator } from '../utils/noteListHelpers' -import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' +import { buildNoteListProps, makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils' +import type { ViewFile } from '../types' describe('getSortComparator', () => { it('sorts by modified date descending', () => { @@ -121,6 +124,52 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), ] + function makeView(overrides: Partial = {}): ViewFile { + return { + filename: 'rated-books.yml', + definition: { + name: 'Rated Books', + icon: null, + color: null, + sort: null, + filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] }, + ...overrides.definition, + }, + ...overrides, + } + } + + function renderManagedViewSort(entries: typeof zamEntries, view = makeView()) { + const built = buildNoteListProps({ + entries, + selection: { kind: 'view', filename: view.filename }, + views: [view], + }) + + function ManagedViewNoteList() { + const [views, setViews] = useState([view]) + + return ( + { + setViews((currentViews) => currentViews.map((currentView) => ( + currentView.filename === filename + ? { ...currentView, definition: { ...currentView.definition, ...patch } } + : currentView + ))) + }} + /> + ) + } + + return { + ...render(), + ...built, + } + } + function openListSortMenu(entries = mockEntries) { renderNoteList({ entries }) fireEvent.click(screen.getByTestId('sort-button-__list__')) @@ -294,4 +343,38 @@ describe('NoteList sort controls', () => { const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent) expect(titles).toEqual(['A', 'C', 'B']) }) + + it('loads view sort properties from the current view results only', () => { + const entries = [ + makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', properties: { Rating: 3 } }), + makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', properties: { Priority: 'High' } }), + makeEntry({ path: '/project-a.md', title: 'Project A', isA: 'Project', properties: { Owner: 'Luca' } }), + ] + + renderManagedViewSort(entries) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + + expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument() + expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument() + expect(screen.queryByTestId('sort-option-property:Owner')).not.toBeInTheDocument() + }) + + it('supports keyboard selection for view sorting and persists the chosen property', () => { + const entries = [ + makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', modifiedAt: 1000, properties: { Rating: 3 } }), + makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', modifiedAt: 3000, properties: { Rating: 1 } }), + makeEntry({ path: '/book-c.md', title: 'Book C', isA: 'Book', modifiedAt: 2000, properties: { Rating: 5 } }), + ] + + renderManagedViewSort(entries) + fireEvent.click(screen.getByTestId('sort-button-__list__')) + fireEvent.keyDown(screen.getByTestId('sort-menu-__list__'), { key: 'End' }) + expect(screen.getByTestId('sort-option-property:Rating')).toHaveFocus() + + fireEvent.keyDown(screen.getByTestId('sort-option-property:Rating'), { key: 'Enter' }) + + const titles = screen.getAllByText(/^Book [ABC]$/).map((element) => element.textContent) + expect(titles).toEqual(['Book B', 'Book A', 'Book C']) + expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument() + }) }) diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 455543b1..1d61b3a2 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -1,8 +1,237 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { cn } from '@/lib/utils' import { ArrowUp, ArrowDown } from '@phosphor-icons/react' import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers' +interface SortItem { + value: SortOption + label: string +} + +type SortMenuAction = + | { type: 'close' } + | { type: 'focus'; index: number } + +function buildSortItems(customProperties?: string[]): SortItem[] { + const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label })) + const customItems = (customProperties ?? []).map((key) => ({ + value: `property:${key}` as SortOption, + label: key, + })) + return [...builtInItems, ...customItems] +} + +function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) { + const activeElement = document.activeElement as HTMLElement | null + const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1) + if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex + + const currentIndex = sortItems.findIndex((item) => item.value === current) + return currentIndex >= 0 ? currentIndex : 0 +} + +function focusSortItem(sortButtonRefs: React.MutableRefObject>, index: number) { + sortButtonRefs.current[index]?.focus() +} + +function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null { + const lastIndex = itemCount - 1 + + switch (key) { + case 'Escape': + return { type: 'close' } + case 'ArrowDown': + return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) } + case 'ArrowUp': + return { type: 'focus', index: Math.max(0, focusIndex - 1) } + case 'Home': + return { type: 'focus', index: 0 } + case 'End': + return { type: 'focus', index: lastIndex } + default: + return null + } +} + +function selectOnKeyboard( + event: React.KeyboardEvent, + value: SortOption, + direction: SortDirection, + onSelect: (opt: SortOption, dir: SortDirection) => void, +) { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + onSelect(value, direction) +} + +function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) { + return cn( + 'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40', + ) +} + +function useSortDropdownState({ + groupLabel, + current, + sortItems, + onChange, +}: { + groupLabel: string + current: SortOption + sortItems: SortItem[] + onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void +}) { + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + const triggerRef = useRef(null) + const sortButtonRefs = useRef>([]) + + useEffect(() => { + if (!open) return + + function handlePointerDown(event: MouseEvent) { + if (containerRef.current?.contains(event.target as Node)) return + setOpen(false) + } + + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, [open]) + + useEffect(() => { + if (!open) return + focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems)) + }, [current, groupLabel, open, sortItems]) + + const closeMenu = useCallback(() => { + setOpen(false) + triggerRef.current?.focus() + }, []) + + const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => { + onChange(groupLabel, option, nextDirection) + closeMenu() + }, [closeMenu, groupLabel, onChange]) + + const handleMenuKeyDown = useCallback((event: React.KeyboardEvent) => { + const action = resolveSortMenuAction( + event.key, + resolveFocusedIndex(groupLabel, current, sortItems), + sortItems.length, + ) + if (!action) return + + event.preventDefault() + if (action.type === 'close') { + closeMenu() + return + } + + focusSortItem(sortButtonRefs, action.index) + }, [closeMenu, current, groupLabel, sortItems]) + + return { + open, + setOpen, + containerRef, + triggerRef, + sortButtonRefs, + handleSelect, + handleMenuKeyDown, + } +} + +function SortDropdownTrigger({ + triggerRef, + open, + current, + groupLabel, + direction, + onToggle, +}: { + triggerRef: React.RefObject + open: boolean + current: SortOption + groupLabel: string + direction: SortDirection + onToggle: () => void +}) { + const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown + + return ( + + ) +} + +function SortDropdownMenu({ + open, + groupLabel, + current, + direction, + sortItems, + sortButtonRefs, + onKeyDown, + onSelect, +}: { + open: boolean + groupLabel: string + current: SortOption + direction: SortDirection + sortItems: SortItem[] + sortButtonRefs: React.MutableRefObject> + onKeyDown: (event: React.KeyboardEvent) => void + onSelect: (option: SortOption, nextDirection: SortDirection) => void +}) { + if (!open) return null + + const hasCustom = sortItems.length > SORT_OPTIONS.length + const builtInOptionCount = SORT_OPTIONS.length + + return ( +
+ {sortItems.map((item, index) => ( + { + sortButtonRefs.current[index] = node + }} + showSeparator={hasCustom && index === builtInOptionCount} + onSelect={onSelect} + /> + ))} +
+ ) +} + export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: { groupLabel: string current: SortOption @@ -10,99 +239,142 @@ export function SortDropdown({ groupLabel, current, direction, customProperties, customProperties?: string[] onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void }) { - const [open, setOpen] = useState(false) - const ref = useRef(null) - - useEffect(() => { - if (!open) return - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, [open]) - - const handleSelect = (opt: SortOption, dir: SortDirection) => { - onChange(groupLabel, opt, dir) - setOpen(false) - } - - const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown - const hasCustom = customProperties && customProperties.length > 0 + const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties]) + const { + open, + setOpen, + containerRef, + triggerRef, + sortButtonRefs, + handleSelect, + handleMenuKeyDown, + } = useSortDropdownState({ + groupLabel, + current, + sortItems, + onChange, + }) return ( -
- - {open && ( -
- {SORT_OPTIONS.map((opt) => ( - - ))} - {hasCustom && ( - <> -
- {customProperties.map((key) => { - const value: SortOption = `property:${key}` - return - })} - - )} -
- )} +
+ setOpen((value) => !value)} + /> +
) } -function SortRow({ value, label, current, direction, onSelect }: { +function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: { + index: number + groupLabel: string value: SortOption label: string current: SortOption direction: SortDirection + buttonRef: (node: HTMLButtonElement | null) => void + showSeparator: boolean onSelect: (opt: SortOption, dir: SortDirection) => void }) { const isActive = value === current + const defaultDirection = isActive ? direction : getDefaultDirection(value) + const itemData = { + 'data-sort-group-label': groupLabel, + 'data-sort-item-index': String(index), + } + return ( -
{ e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }} - > - - {label} - - + <> + {showSeparator &&
} +
- - -
+ + } + itemData={itemData} + /> + } + itemData={itemData} + /> + +
+ + ) +} + +function SortDirectionButton({ + value, + direction, + activeDirection, + isActive, + onSelect, + icon, + itemData, +}: { + value: SortOption + direction: SortDirection + activeDirection: SortDirection + isActive: boolean + onSelect: (opt: SortOption, dir: SortDirection) => void + icon: React.ReactNode + itemData: Record +}) { + return ( + ) } diff --git a/src/components/note-list/ListPropertiesPopover.tsx b/src/components/note-list/ListPropertiesPopover.tsx index f69967b8..21bd1ffe 100644 --- a/src/components/note-list/ListPropertiesPopover.tsx +++ b/src/components/note-list/ListPropertiesPopover.tsx @@ -372,8 +372,6 @@ export function ListPropertiesPopover({ handleToggle, } = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave }) - if (availableProperties.length === 0) return null - return ( diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index 06f10525..14e1549d 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react' -import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types' +import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewDefinition, ViewFile } from '../../types' import { type SortOption, type SortDirection, type SortConfig, type NoteListFilter, getSortComparator, extractSortableProperties, @@ -156,29 +156,99 @@ export function useNoteListSearch() { const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' } -function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record): SortConfig { +function findSelectedViewFile(selection: SidebarSelection, views?: ViewFile[]): ViewFile | null { + if (selection.kind !== 'view') return null + return views?.find((candidate) => candidate.filename === selection.filename) ?? null +} + +function findSelectedTypeDocument(entries: VaultEntry[], selection: SidebarSelection): VaultEntry | null { + if (selection.kind !== 'sectionGroup') return null + return entries.find((entry) => entry.isA === 'Type' && entry.title === selection.type) ?? null +} + +function resolveListSortConfig( + typeDocument: VaultEntry | null, + selectedView: ViewFile | null, + sortPrefs: Record, +): SortConfig { if (typeDocument?.sort) { const parsed = parseSortConfig(typeDocument.sort) if (parsed) return parsed } - return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG + + if (selectedView?.definition.sort) { + const parsed = parseSortConfig(selectedView.definition.sort) + if (parsed) return parsed + } + + return selectedView ? DEFAULT_LIST_CONFIG : (sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG) } interface SortPersistence { - onUpdateTypeSort: (path: string, key: string, value: string) => void - updateEntry: (path: string, patch: Partial) => void + onUpdateTypeSort?: (path: string, key: string, value: string) => void + updateEntry?: (path: string, patch: Partial) => void + onUpdateViewDefinition?: (filename: string, patch: Partial) => void +} + +function createSortPersistence( + onUpdateTypeSort?: SortPersistence['onUpdateTypeSort'], + updateEntry?: SortPersistence['updateEntry'], + onUpdateViewDefinition?: SortPersistence['onUpdateViewDefinition'], +): SortPersistence | null { + if (!onUpdateViewDefinition && !(onUpdateTypeSort && updateEntry)) return null + return { onUpdateTypeSort, updateEntry, onUpdateViewDefinition } } function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) { const serialized = serializeSortConfig(config) - persistence.onUpdateTypeSort(path, 'sort', serialized) - persistence.updateEntry(path, { sort: serialized }) + persistence.onUpdateTypeSort?.(path, 'sort', serialized) + persistence.updateEntry?.(path, { sort: serialized }) clearListSortFromLocalStorage() } -function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) { - if (groupLabel !== '__list__' || !typeDocument || !persistence) return null - return { path: typeDocument.path, persistence } +function persistSortToView( + filename: string, + config: SortConfig, + onUpdateViewDefinition: NonNullable, +) { + onUpdateViewDefinition(filename, { sort: serializeSortConfig(config) }) +} + +type SortPersistenceTarget = + | { kind: 'type'; path: string } + | { kind: 'view'; filename: string } + +function canPersistTypeSort( + persistence: SortPersistence, +): persistence is SortPersistence & Required> { + return Boolean(persistence.onUpdateTypeSort && persistence.updateEntry) +} + +function resolveSortPersistenceTarget( + groupLabel: string, + typeDocument: VaultEntry | null, + selectedView: ViewFile | null, + persistence: SortPersistence | null, +): SortPersistenceTarget | null { + if (groupLabel !== '__list__' || !persistence) return null + if (typeDocument && canPersistTypeSort(persistence)) { + return { kind: 'type', path: typeDocument.path } + } + if (selectedView && persistence.onUpdateViewDefinition) { + return { kind: 'view', filename: selectedView.filename } + } + return null +} + +function persistListSort(target: SortPersistenceTarget, config: SortConfig, persistence: SortPersistence) { + if (target.kind === 'type') { + persistSortToType(target.path, config, persistence) + return + } + + if (persistence.onUpdateViewDefinition) { + persistSortToView(target.filename, config, persistence.onUpdateViewDefinition) + } } function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record, migrationDone: Set, persistence: SortPersistence) { @@ -193,6 +263,24 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) } +function persistOrSaveGroupSort( + groupLabel: string, + option: SortOption, + direction: SortDirection, + setSortPrefs: React.Dispatch>>, + typeDocument: VaultEntry | null, + selectedView: ViewFile | null, + persistence: SortPersistence | null, +) { + const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence) + if (!persistenceTarget || !persistence) { + saveGroupSort(groupLabel, option, direction, setSortPrefs) + return + } + + persistListSort(persistenceTarget, { option, direction }, persistence) +} + function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption { if (!configOption.startsWith('property:')) return configOption return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified' @@ -205,22 +293,35 @@ export interface UseNoteListSortParams { modifiedSuffixes: string[] subFilter?: NoteListFilter inboxPeriod?: InboxPeriod + views?: ViewFile[] onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void + onUpdateViewDefinition?: (filename: string, patch: Partial) => void updateEntry?: (path: string, patch: Partial) => void } -export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) { +export function useNoteListSort({ + entries, + selection, + modifiedPathSet, + modifiedSuffixes, + subFilter, + inboxPeriod, + views, + onUpdateTypeSort, + onUpdateViewDefinition, + updateEntry, +}: UseNoteListSortParams) { const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) + const typeDocument = useMemo(() => findSelectedTypeDocument(entries, selection), [entries, selection]) + const selectedView = useMemo( + () => findSelectedViewFile(selection, views), + [selection, views], + ) - const typeDocument = useMemo(() => { - if (selection.kind !== 'sectionGroup') return null - return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null - }, [selection, entries]) - - const listConfig = resolveListSortConfig(typeDocument, sortPrefs) + const listConfig = resolveListSortConfig(typeDocument, selectedView, sortPrefs) const persistence = useMemo( - () => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null, - [onUpdateTypeSort, updateEntry], + () => createSortPersistence(onUpdateTypeSort, updateEntry, onUpdateViewDefinition), + [onUpdateTypeSort, onUpdateViewDefinition, updateEntry], ) const migrationDoneRef = useRef>(new Set()) @@ -230,10 +331,16 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS }, [typeDocument, sortPrefs, persistence]) const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { - const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence) - if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs) - persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence) - }, [typeDocument, persistence]) + persistOrSaveGroupSort( + groupLabel, + option, + direction, + setSortPrefs, + typeDocument, + selectedView, + persistence, + ) + }, [typeDocument, selectedView, persistence]) const filteredEntries = useFilteredEntries({ entries, @@ -242,6 +349,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS modifiedSuffixes, subFilter, inboxPeriod, + views, }) const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) const listSort = useMemo(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) @@ -393,6 +501,96 @@ function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record, +): ScopedPropertyPickerState { + const allNotesEntries = useMemo( + () => isAllNotesView + ? [ + ...filterEntries(entries, selection, 'open'), + ...filterEntries(entries, selection, 'archived'), + ] + : [], + [entries, isAllNotesView, selection], + ) + + return { + availableProperties: useMemo( + () => collectAvailableProperties(allNotesEntries), + [allNotesEntries], + ), + defaultDisplay: useMemo( + () => deriveDefaultDisplay(allNotesEntries, typeEntryMap), + [allNotesEntries, typeEntryMap], + ), + } +} + +function useInboxPropertyPickerState( + entries: VaultEntry[], + inboxPeriod: InboxPeriod, + isInboxView: boolean, + typeEntryMap: Record, +): ScopedPropertyPickerState { + const inboxEntries = useMemo( + () => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [], + [entries, inboxPeriod, isInboxView], + ) + + return { + availableProperties: useMemo( + () => collectAvailableProperties(inboxEntries), + [inboxEntries], + ), + defaultDisplay: useMemo( + () => deriveDefaultDisplay(inboxEntries, typeEntryMap), + [inboxEntries, typeEntryMap], + ), + } +} + +interface ViewPropertyPickerState extends ScopedPropertyPickerState { + selectedView: ViewFile | null + hasCustomProperties: boolean +} + +function useViewPropertyPickerState( + entries: VaultEntry[], + selection: SidebarSelection, + views: ViewFile[] | undefined, + typeEntryMap: Record, +): ViewPropertyPickerState { + const selectedView = useMemo( + () => findSelectedViewFile(selection, views), + [selection, views], + ) + const viewEntries = useMemo( + () => selectedView ? filterEntries(entries, selection, undefined, views) : [], + [entries, selection, selectedView, views], + ) + + return { + selectedView, + availableProperties: useMemo( + () => collectAvailableProperties(viewEntries), + [viewEntries], + ), + defaultDisplay: useMemo( + () => deriveDefaultDisplay(viewEntries, typeEntryMap), + [viewEntries, typeEntryMap], + ), + hasCustomProperties: Boolean(selectedView?.definition.listPropertiesDisplay?.length), + } +} + export interface NoteListPropertyPicker { scope: NoteListPropertiesScope availableProperties: string[] @@ -457,6 +655,61 @@ function buildTypePropertyPicker({ } } +interface BuildViewPropertyPickerParams { + selectedView: ViewFile | null + availableProperties: string[] + defaultDisplay: string[] + onUpdateViewDefinition?: (filename: string, patch: Partial) => void +} + +function buildViewPropertyPicker({ + selectedView, + availableProperties, + defaultDisplay, + onUpdateViewDefinition, +}: BuildViewPropertyPickerParams): NoteListPropertyPicker | null { + if (!selectedView || !onUpdateViewDefinition) return null + + const currentDisplay = (selectedView.definition.listPropertiesDisplay?.length ?? 0) > 0 + ? selectedView.definition.listPropertiesDisplay ?? [] + : defaultDisplay + + return { + scope: 'view', + availableProperties, + currentDisplay, + onSave: (value: string[] | null) => onUpdateViewDefinition(selectedView.filename, { listPropertiesDisplay: value ?? [] }), + triggerTitle: `Customize ${selectedView.definition.name} columns`, + } +} + +function resolveDisplayPropsOverride({ + isAllNotesView, + hasCustomAllNotesProperties, + allNotesNoteListProperties, + isInboxView, + hasCustomInboxProperties, + inboxNoteListProperties, + selectedView, + hasCustomViewProperties, +}: { + isAllNotesView: boolean + hasCustomAllNotesProperties: boolean + allNotesNoteListProperties?: string[] | null + isInboxView: boolean + hasCustomInboxProperties: boolean + inboxNoteListProperties?: string[] | null + selectedView: ViewFile | null + hasCustomViewProperties: boolean +}) { + if (selectedView && hasCustomViewProperties) { + return selectedView.definition.listPropertiesDisplay ?? null + } + if (isAllNotesView && hasCustomAllNotesProperties) return allNotesNoteListProperties ?? null + if (isInboxView && hasCustomInboxProperties) return inboxNoteListProperties ?? null + return null +} + interface UseListPropertyPickerParams { entries: VaultEntry[] selection: SidebarSelection @@ -467,7 +720,9 @@ interface UseListPropertyPickerParams { onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null onUpdateInboxNoteListProperties?: (value: string[] | null) => void + onUpdateViewDefinition?: (filename: string, patch: Partial) => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void + views?: ViewFile[] } export function useListPropertyPicker({ @@ -480,68 +735,55 @@ export function useListPropertyPicker({ onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, + onUpdateViewDefinition, onUpdateTypeSort, + views, }: 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 allNotesState = useAllNotesPropertyPickerState(entries, selection, isAllNotesView, typeEntryMap) + const inboxState = useInboxPropertyPickerState(entries, inboxPeriod, isInboxView, typeEntryMap) + const viewState = useViewPropertyPickerState(entries, selection, views, typeEntryMap) const typeAvailableProperties = useMemo( () => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [], [entries, typeDocument], ) - const inboxAvailableProperties = useMemo( - () => collectAvailableProperties(inboxEntries), - [inboxEntries], - ) - const inboxDefaultDisplay = useMemo( - () => deriveDefaultDisplay(inboxEntries, typeEntryMap), - [inboxEntries, typeEntryMap], - ) const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0) const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0) - const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties - ? allNotesNoteListProperties - : (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null) + const displayPropsOverride = resolveDisplayPropsOverride({ + isAllNotesView, + hasCustomAllNotesProperties, + allNotesNoteListProperties, + isInboxView, + hasCustomInboxProperties, + inboxNoteListProperties, + selectedView: viewState.selectedView, + hasCustomViewProperties: viewState.hasCustomProperties, + }) const propertyPicker = useMemo(() => { - return buildFilterPropertyPicker({ + return buildViewPropertyPicker({ + selectedView: viewState.selectedView, + availableProperties: viewState.availableProperties, + defaultDisplay: viewState.defaultDisplay, + onUpdateViewDefinition, + }) ?? buildFilterPropertyPicker({ scope: 'all', isActive: isAllNotesView, - availableProperties: allNotesAvailableProperties, + availableProperties: allNotesState.availableProperties, hasCustomProperties: hasCustomAllNotesProperties, noteListProperties: allNotesNoteListProperties, - defaultDisplay: allNotesDefaultDisplay, + defaultDisplay: allNotesState.defaultDisplay, onSave: onUpdateAllNotesNoteListProperties, triggerTitle: 'Customize All Notes columns', }) ?? buildFilterPropertyPicker({ scope: 'inbox', isActive: isInboxView, - availableProperties: inboxAvailableProperties, + availableProperties: inboxState.availableProperties, hasCustomProperties: hasCustomInboxProperties, noteListProperties: inboxNoteListProperties, - defaultDisplay: inboxDefaultDisplay, + defaultDisplay: inboxState.defaultDisplay, onSave: onUpdateInboxNoteListProperties, triggerTitle: 'Customize Inbox columns', }) ?? buildTypePropertyPicker({ @@ -551,15 +793,19 @@ export function useListPropertyPicker({ typeAvailableProperties, }) }, [ - allNotesAvailableProperties, - allNotesDefaultDisplay, + allNotesState.availableProperties, + allNotesState.defaultDisplay, allNotesNoteListProperties, hasCustomAllNotesProperties, - isAllNotesView, - onUpdateAllNotesNoteListProperties, hasCustomInboxProperties, - inboxAvailableProperties, - inboxDefaultDisplay, + isAllNotesView, + inboxState.availableProperties, + inboxState.defaultDisplay, + viewState.availableProperties, + viewState.defaultDisplay, + viewState.selectedView, + onUpdateViewDefinition, + onUpdateAllNotesNoteListProperties, inboxNoteListProperties, isInboxView, isSectionGroup, diff --git a/src/components/note-list/noteListPropertiesEvents.ts b/src/components/note-list/noteListPropertiesEvents.ts index 9def51b2..49d70494 100644 --- a/src/components/note-list/noteListPropertiesEvents.ts +++ b/src/components/note-list/noteListPropertiesEvents.ts @@ -1,4 +1,4 @@ -export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' +export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view' export interface OpenListPropertiesEventDetail { scope: NoteListPropertiesScope diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index ecf049e4..58a834e8 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -5,6 +5,7 @@ import type { ModifiedFile, NoteStatus, InboxPeriod, + ViewDefinition, ViewFile, } from '../../types' import type { NoteListFilter } from '../../utils/noteListHelpers' @@ -94,6 +95,7 @@ interface UseNoteListContentParams { onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null onUpdateInboxNoteListProperties?: (value: string[] | null) => void + onUpdateViewDefinition?: (filename: string, patch: Partial) => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void views?: ViewFile[] @@ -113,6 +115,7 @@ function useNoteListContent({ onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, + onUpdateViewDefinition, onUpdateTypeSort, updateEntry, views, @@ -129,7 +132,9 @@ function useNoteListContent({ modifiedSuffixes, subFilter, inboxPeriod: effectiveInboxPeriod, + views, onUpdateTypeSort, + onUpdateViewDefinition, updateEntry, }) const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() @@ -144,7 +149,9 @@ function useNoteListContent({ onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, + onUpdateViewDefinition, onUpdateTypeSort, + views, }) const { isEntityView, @@ -372,6 +379,7 @@ export interface NoteListProps { onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void inboxNoteListProperties?: string[] | null onUpdateInboxNoteListProperties?: (value: string[] | null) => void + onUpdateViewDefinition?: (filename: string, patch: Partial) => void views?: ViewFile[] visibleNotesRef?: React.MutableRefObject } @@ -466,6 +474,7 @@ export function useNoteListModel({ onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, + onUpdateViewDefinition, views, visibleNotesRef, }: NoteListProps) { @@ -486,6 +495,7 @@ export function useNoteListModel({ onUpdateAllNotesNoteListProperties, inboxNoteListProperties, onUpdateInboxNoteListProperties, + onUpdateViewDefinition, onUpdateTypeSort, updateEntry, views, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index c855613c..f3c9c8cd 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -82,6 +82,7 @@ interface AppCommandsConfig { onToggleOrganized?: (path: string) => void onCustomizeNoteListColumns?: () => void canCustomizeNoteListColumns?: boolean + noteListColumnsLabel?: string onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean } @@ -224,6 +225,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters void onCustomizeNoteListColumns?: () => void canCustomizeNoteListColumns?: boolean + noteListColumnsLabel?: string onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean onQuickOpen: () => void @@ -119,9 +120,11 @@ 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 noteListColumnsLabel = config.noteListColumnsLabel ?? ( + selection?.kind === 'filter' && selection.filter === 'all' + ? 'Customize All Notes columns' + : 'Customize Inbox columns' + ) const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries]) diff --git a/src/types.ts b/src/types.ts index 2a03ee08..ae104390 100644 --- a/src/types.ts +++ b/src/types.ts @@ -201,6 +201,7 @@ export interface ViewDefinition { icon: string | null color: string | null sort: string | null + listPropertiesDisplay?: string[] filters: FilterGroup }