From 9cc3bcf758d87a0545affb1fab5756b5da2c0fb1 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 7 Apr 2026 20:31:08 +0200 Subject: [PATCH] feat: customize inbox note list columns --- docs/ABSTRACTIONS.md | 5 +- docs/ARCHITECTURE.md | 3 +- docs/GETTING-STARTED.md | 2 +- src/App.tsx | 18 ++- src/components/NoteItem.test.tsx | 15 ++ src/components/NoteItem.tsx | 13 +- src/components/NoteList.test.tsx | 111 +++++++++++++++ src/components/NoteList.tsx | 92 +++++++++++- .../note-list/ListPropertiesPopover.tsx | 131 ++++++++++-------- src/components/note-list/NoteListHeader.tsx | 12 +- .../note-list/noteListPropertiesEvents.ts | 13 ++ src/components/ui/checkbox.tsx | 52 +++++++ src/hooks/commands/viewCommands.ts | 5 +- src/hooks/useAppCommands.ts | 4 + src/hooks/useCommandRegistry.test.ts | 27 ++++ src/hooks/useCommandRegistry.ts | 7 +- src/hooks/useVaultConfig.ts | 1 + src/types.ts | 8 +- src/utils/configMigration.ts | 2 +- src/utils/vaultConfigStore.ts | 1 + 20 files changed, 440 insertions(+), 82 deletions(-) create mode 100644 src/components/note-list/noteListPropertiesEvents.ts create mode 100644 src/components/ui/checkbox.tsx diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index d9a43f6d..118dba51 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -496,10 +496,9 @@ No indexing step required — search runs directly against the filesystem. ### Vault Config -Per-vault settings stored in `ui.config.md` at vault root: -- Editable as a normal note (YAML frontmatter) +Per-vault settings stored locally and scoped by vault path: - Managed by `useVaultConfig` hook and `vaultConfigStore` -- Settings: zoom, view mode, tag colors, status colors, property display modes +- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides - One-time migration from localStorage (`configMigration.ts`) ### Getting Started / Onboarding diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d04496da..b7eb88ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -413,12 +413,13 @@ Managed by `useVaultSwitcher` hook. Switching vaults resets sidebar and clears t ### Vault Config -Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note): +Per-vault UI settings stored locally per vault path (currently in browser/Tauri localStorage, not synced via git): - `zoom`: Float zoom level (0.8–1.5) - `view_mode`: "all" | "editor-list" | "editor-only" - `editor_mode`: "raw" | "preview" (persists across note switches and sessions) - `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 ### Getting Started Vault diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index c36f0784..7ad8cccb 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -245,7 +245,7 @@ laputa-app/ | File | Why it matters | |------|---------------| | `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). | -| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). | +| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns). | | `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. | ## Architecture Patterns diff --git a/src/App.tsx b/src/App.tsx index 23b20946..3c397716 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,7 @@ import { openNoteInNewWindow } from './utils/openNoteWindow' import { isNoteWindow, getNoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' +import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents' import { trackEvent } from './lib/telemetry' import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils' import './App.css' @@ -120,7 +121,7 @@ function App() { }, [resolvedPath]) const vault = useVaultLoader(resolvedPath) - useVaultConfig(resolvedPath) + const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() useTelemetry(settings, settingsLoaded) @@ -287,6 +288,17 @@ function App() { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) }, []) + const handleCustomizeInboxColumns = useCallback(() => { + openNoteListPropertiesPicker('inbox') + }, []) + + const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => { + updateConfig('inbox', { + ...(vaultConfig.inbox ?? { noteListProperties: null }), + noteListProperties: value && value.length > 0 ? value : null, + }) + }, [updateConfig, vaultConfig.inbox]) + const handleCreateFolder = useCallback(async (name: string) => { try { if (isTauri()) { @@ -536,6 +548,8 @@ function App() { onOpenInNewWindow: handleOpenInNewWindow, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: entryActions.handleToggleOrganized, + onCustomizeInboxColumns: handleCustomizeInboxColumns, + canCustomizeInboxColumns: selection.kind === 'filter' && selection.filter === 'inbox', onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, canRestoreDeletedNote: !!activeDeletedFile, }) @@ -617,7 +631,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} views={vault.views} visibleNotesRef={visibleNotesRef} /> + diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} /> )} diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index d50c6ab1..abeb3d95 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -101,6 +101,21 @@ describe('NoteItem property chips', () => { expect(screen.getByText('www.example.com')).toBeInTheDocument() }) + it('prefers displayPropsOverride over the type defaults', () => { + const entry = makeEntry({ + properties: { rating: 4, Owner: 'Luca' }, + }) + const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] }) + + render( + + ) + + expect(screen.getByText('Luca')).toBeInTheDocument() + expect(screen.queryByText('4')).not.toBeInTheDocument() + }) + it('does not render chips for binary files', () => { const entry = makeEntry({ fileKind: 'binary', diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 6e2d3760..b0d25088 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -147,7 +147,12 @@ function getFileKindIcon(fileKind: string | undefined): ComponentType, displayPropsOverride?: string[] | null): string[] { + if (displayPropsOverride && displayPropsOverride.length > 0) return displayPropsOverride + return typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? [] +} + +export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: { entry: VaultEntry isSelected: boolean isMultiSelected?: boolean @@ -156,6 +161,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig /** When set, renders in Changes-view style: filename + change type icon */ changeStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' typeEntryMap: Record + displayPropsOverride?: string[] | null onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void onPrefetch?: (path: string) => void onContextMenu?: (entry: VaultEntry, e: React.MouseEvent) => void @@ -164,6 +170,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown' const isDeletedChange = changeStatus === 'deleted' const te = typeEntryMap[entry.isA ?? ''] + const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride) const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color) const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) const TypeIcon = useMemo(() => { @@ -227,8 +234,8 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig {entry.snippet} )} - {!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && ( - + {!isBinary && displayProps.length > 0 && ( + )} {!isBinary && (
{relativeDate(getDisplayDate(entry))}
diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index b080ac38..8d64a500 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -5,6 +5,7 @@ import { NoteItem } from './NoteItem' import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers' import type { NoteListFilter } from '../utils/noteListHelpers' import type { VaultEntry, SidebarSelection } from '../types' +import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents' const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' } const noopSelect = vi.fn() @@ -168,6 +169,15 @@ const makeIndexedEntry = (i: number, overrides?: Partial): VaultEntr ...overrides, }) +const makeTypeDefinition = (title: string, displayProps: string[] = []): VaultEntry => + makeEntry({ + path: `/vault/type/${title.toLowerCase()}.md`, + filename: `${title.toLowerCase()}.md`, + title, + isA: 'Type', + listPropertiesDisplay: displayProps, + }) + describe('NoteList', () => { it('shows empty state when no entries', () => { render() @@ -315,6 +325,107 @@ describe('NoteList', () => { // Snippet text appears in the prominent card expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() }) + + it('shows the Inbox customize columns button and falls back to type-defined chips', () => { + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + render( + , + ) + + expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.queryByText('Luca')).not.toBeInTheDocument() + }) + + it('opens the Inbox column picker from the global event and saves the selected columns', () => { + const onUpdateInboxNoteListProperties = vi.fn() + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + render( + , + ) + + act(() => { openNoteListPropertiesPicker('inbox') }) + + expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' })) + + expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner']) + }) + + it('uses the Inbox override chips when configured', () => { + const entries = [ + makeTypeDefinition('Book', ['Priority']), + makeEntry({ + path: '/vault/book.md', + filename: 'book.md', + title: 'Book Note', + isA: 'Book', + properties: { Priority: 'High', Owner: 'Luca' }, + createdAt: 1700000000, + }), + ] + + render( + , + ) + + expect(screen.getByText('Luca')).toBeInTheDocument() + expect(screen.queryByText('High')).not.toBeInTheDocument() + }) }) describe('NoteList click behavior', () => { diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index d398824d..471f3f1b 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' -import { countByFilter, countAllByFilter } from '../utils/noteListHelpers' +import { countByFilter, countAllByFilter, filterInboxEntries } from '../utils/noteListHelpers' import { NoteItem } from './NoteItem' import { prefetchNoteContent } from '../hooks/useTabManagement' import { BulkActionBar } from './BulkActionBar' @@ -31,6 +31,34 @@ function useViewFlags(selection: SidebarSelection) { return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } } +function collectAvailableProperties(entries: VaultEntry[]): string[] { + const keys = new Set() + 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[] { + const ordered: string[] = [] + const seen = new Set() + + 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 +} + function useBulkActions( multiSelect: ReturnType, onBulkArchive: NoteListProps['onBulkArchive'], @@ -154,11 +182,13 @@ interface NoteListProps { onDiscardFile?: (relativePath: string) => Promise onAutoTriggerDiff?: () => void onOpenDeletedNote?: (entry: DeletedNoteEntry) => void + inboxNoteListProperties?: string[] | null + onUpdateInboxNoteListProperties?: (value: string[] | null) => void views?: ViewFile[] visibleNotesRef?: React.MutableRefObject } -function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, views, visibleNotesRef }: NoteListProps) { +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) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection) @@ -174,6 +204,58 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const typeEntryMap = useTypeEntryMap(entries) + 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, + ]) const changeStatusMap = useMemo(() => { if (!isChangesView || !modifiedFiles) return undefined const map = new Map() @@ -257,8 +339,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot }, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry]) const renderItem = useCallback((entry: VaultEntry) => ( - - ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu]) + + ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride]) const handleCreateNote = useCallback(() => { onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined) @@ -268,7 +350,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot return (
- +
{entitySelection ? ( diff --git a/src/components/note-list/ListPropertiesPopover.tsx b/src/components/note-list/ListPropertiesPopover.tsx index 05a7304b..7ab8fb7a 100644 --- a/src/components/note-list/ListPropertiesPopover.tsx +++ b/src/components/note-list/ListPropertiesPopover.tsx @@ -1,38 +1,39 @@ -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useEffect } from 'react' import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' -import type { VaultEntry } from '../../types' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { - DndContext, closestCenter, KeyboardSensor, PointerSensor, + OPEN_NOTE_LIST_PROPERTIES_EVENT, + type NoteListPropertiesScope, + type OpenListPropertiesEventDetail, +} from './noteListPropertiesEvents' +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core' import { - SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, + SortableContext, useSortable, verticalListSortingStrategy, arrayMove, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -interface ListPropertiesPopoverProps { - typeDocument: VaultEntry - entries: VaultEntry[] - onSave: (path: string, key: string, value: string[] | null) => void +export interface ListPropertiesPopoverProps { + scope: NoteListPropertiesScope + availableProperties: string[] + currentDisplay: string[] + onSave: (value: string[] | null) => void + triggerTitle: string } -/** Collect all available property/relationship keys from notes of this type. */ -function collectAvailableProperties(entries: VaultEntry[], typeName: string): string[] { - const keys = new Set() - for (const entry of entries) { - if (entry.isA !== typeName) continue - for (const k of Object.keys(entry.properties)) keys.add(k) - for (const k of Object.keys(entry.relationships)) keys.add(k) - } - // Sort alphabetically for stable ordering - return [...keys].sort((a, b) => a.localeCompare(b)) +function propertyInputId(id: string): string { + return `list-prop-${id.replace(/[^a-z0-9_-]+/gi, '-')}` } function SortablePropertyItem({ id, checked, onToggle }: { id: string; checked: boolean; onToggle: (key: string) => void }) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) const style = { transform: CSS.Transform.toString(transform), transition } + const inputId = propertyInputId(id) return (
- - +
) } -export function ListPropertiesPopover({ typeDocument, entries, onSave }: ListPropertiesPopoverProps) { +export function ListPropertiesPopover({ + scope, + availableProperties, + currentDisplay, + onSave, + triggerTitle, +}: ListPropertiesPopoverProps) { const [open, setOpen] = useState(false) - const currentDisplay = typeDocument.listPropertiesDisplay ?? [] - const availableProperties = useMemo( - () => collectAvailableProperties(entries, typeDocument.title), - [entries, typeDocument.title], - ) - - // Merge: selected props first (in order), then unselected alphabetically const orderedItems = useMemo(() => { - const selected = currentDisplay.filter((p) => availableProperties.includes(p)) - const unselected = availableProperties.filter((p) => !selected.includes(p)) + const selected = currentDisplay.filter((property) => availableProperties.includes(property)) + const unselected = availableProperties.filter((property) => !selected.includes(property)) return [...selected, ...unselected] - }, [currentDisplay, availableProperties]) + }, [availableProperties, currentDisplay]) const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (detail?.scope === scope) setOpen(true) + } + window.addEventListener(OPEN_NOTE_LIST_PROPERTIES_EVENT, handler) + return () => window.removeEventListener(OPEN_NOTE_LIST_PROPERTIES_EVENT, handler) + }, [scope]) + const handleToggle = useCallback((key: string) => { - const newSelected = selectedSet.has(key) - ? currentDisplay.filter((k) => k !== key) + const nextSelected = selectedSet.has(key) + ? currentDisplay.filter((property) => property !== key) : [...currentDisplay, key] - onSave(typeDocument.path, '_list_properties_display', newSelected.length > 0 ? newSelected : null) - }, [selectedSet, currentDisplay, typeDocument.path, onSave]) + onSave(nextSelected.length > 0 ? nextSelected : null) + }, [currentDisplay, onSave, selectedSet]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return - // Only reorder within selected items - const selected = currentDisplay.filter((p) => availableProperties.includes(p)) + const selected = currentDisplay.filter((property) => availableProperties.includes(property)) const oldIndex = selected.indexOf(String(active.id)) const newIndex = selected.indexOf(String(over.id)) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(selected, oldIndex, newIndex) - onSave(typeDocument.path, '_list_properties_display', reordered) - }, [currentDisplay, availableProperties, typeDocument.path, onSave]) + onSave(reordered.length > 0 ? reordered : null) + }, [availableProperties, currentDisplay, onSave]) if (availableProperties.length === 0) return null return ( - +
diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx index 72a2926c..e020dc2a 100644 --- a/src/components/note-list/NoteListHeader.tsx +++ b/src/components/note-list/NoteListHeader.tsx @@ -4,9 +4,9 @@ import type { SortOption, SortDirection } from '../../utils/noteListHelpers' import { Input } from '@/components/ui/input' import { useDragRegion } from '../../hooks/useDragRegion' import { SortDropdown } from '../SortDropdown' -import { ListPropertiesPopover } from './ListPropertiesPopover' +import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPropertiesPopover' -export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onUpdateTypeProperty }: { +export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: { title: string typeDocument: VaultEntry | null isEntityView: boolean @@ -16,14 +16,12 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li sidebarCollapsed?: boolean searchVisible: boolean search: string - isSectionGroup?: boolean - entries?: VaultEntry[] + propertyPicker?: ListPropertiesPopoverProps | null onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void onCreateNote: () => void onOpenType: (entry: VaultEntry) => void onToggleSearch: () => void onSearchChange: (value: string) => void - onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void }) { const { onMouseDown: onDragMouseDown } = useDragRegion() return ( @@ -42,9 +40,7 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li - {isSectionGroup && typeDocument && entries && onUpdateTypeProperty && ( - - )} + {propertyPicker && } diff --git a/src/components/note-list/noteListPropertiesEvents.ts b/src/components/note-list/noteListPropertiesEvents.ts new file mode 100644 index 00000000..d0d08649 --- /dev/null +++ b/src/components/note-list/noteListPropertiesEvents.ts @@ -0,0 +1,13 @@ +export type NoteListPropertiesScope = 'type' | 'inbox' + +export interface OpenListPropertiesEventDetail { + scope: NoteListPropertiesScope +} + +export const OPEN_NOTE_LIST_PROPERTIES_EVENT = 'laputa:open-note-list-properties' + +export function openNoteListPropertiesPicker(scope: NoteListPropertiesScope): void { + window.dispatchEvent(new CustomEvent(OPEN_NOTE_LIST_PROPERTIES_EVENT, { + detail: { scope }, + })) +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..6c31705f --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { Check } from 'lucide-react' + +import { cn } from '@/lib/utils' + +export type CheckedState = boolean | 'indeterminate' + +interface CheckboxProps extends Omit, 'onChange'> { + checked?: CheckedState + onCheckedChange?: (checked: CheckedState) => void +} + +function Checkbox({ + className, + checked = false, + disabled = false, + onCheckedChange, + ...props +}: CheckboxProps) { + const isChecked = checked === true + + return ( + + ) +} + +export { Checkbox } diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 99c47cb2..32939588 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -13,13 +13,15 @@ interface ViewCommandsConfig { onZoomIn: () => void onZoomOut: () => void onZoomReset: () => void + onCustomizeInboxColumns?: () => void + canCustomizeInboxColumns?: boolean } export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { const { hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, - zoomLevel, onZoomIn, onZoomOut, onZoomReset, + zoomLevel, onZoomIn, onZoomOut, onZoomReset, onCustomizeInboxColumns, canCustomizeInboxColumns, } = config return [ @@ -31,6 +33,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: '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 afa0df97..b856579d 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -67,6 +67,8 @@ interface AppCommandsConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onCustomizeInboxColumns?: () => void + canCustomizeInboxColumns?: boolean onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean } @@ -209,6 +211,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onOpenInNewWindow: config.onOpenInNewWindow, onToggleFavorite: config.onToggleFavorite, onToggleOrganized: config.onToggleOrganized, + onCustomizeInboxColumns: config.onCustomizeInboxColumns, + canCustomizeInboxColumns: config.canCustomizeInboxColumns, onRestoreDeletedNote: config.onRestoreDeletedNote, canRestoreDeletedNote: config.canRestoreDeletedNote, }) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 009888de..05e2f758 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -165,6 +165,33 @@ describe('useCommandRegistry', () => { const cmd = findCommand(result.current, 'restore-deleted-note') expect(cmd!.enabled).toBe(false) }) + + it('includes Customize Inbox columns when the Inbox action is available', () => { + const onCustomizeInboxColumns = vi.fn() + const config = makeConfig({ + selection: { kind: 'filter', filter: 'inbox' }, + onCustomizeInboxColumns, + canCustomizeInboxColumns: true, + }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'customize-inbox-columns') + expect(cmd).toBeDefined() + expect(cmd!.enabled).toBe(true) + + cmd!.execute() + expect(onCustomizeInboxColumns).toHaveBeenCalled() + }) + + it('disables Customize Inbox columns outside the Inbox view', () => { + const config = makeConfig({ + selection: { kind: 'filter', filter: 'all' }, + onCustomizeInboxColumns: vi.fn(), + canCustomizeInboxColumns: false, + }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'customize-inbox-columns') + expect(cmd!.enabled).toBe(false) + }) }) describe('pluralizeType', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 21e8ddaa..dc974172 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -30,6 +30,8 @@ interface CommandRegistryConfig { onOpenInNewWindow?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onCustomizeInboxColumns?: () => void + canCustomizeInboxColumns?: boolean onRestoreDeletedNote?: () => void canRestoreDeletedNote?: boolean onQuickOpen: () => void @@ -87,6 +89,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, onToggleOrganized, + onCustomizeInboxColumns, canCustomizeInboxColumns, onRestoreDeletedNote, canRestoreDeletedNote, selection, noteListFilter, onSetNoteListFilter, } = config @@ -117,6 +120,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com ...buildViewCommands({ hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, + onCustomizeInboxColumns, canCustomizeInboxColumns, }), ...buildSettingsCommands({ mcpStatus, vaultCount, isGettingStartedHidden, @@ -141,6 +145,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, onOpenInNewWindow, onToggleFavorite, isFavorite, - onToggleOrganized, onRestoreDeletedNote, canRestoreDeletedNote, activeEntry, + onToggleOrganized, onCustomizeInboxColumns, canCustomizeInboxColumns, + onRestoreDeletedNote, canRestoreDeletedNote, activeEntry, ]) } diff --git a/src/hooks/useVaultConfig.ts b/src/hooks/useVaultConfig.ts index b1517e7c..d059bdc1 100644 --- a/src/hooks/useVaultConfig.ts +++ b/src/hooks/useVaultConfig.ts @@ -22,6 +22,7 @@ function loadFromStorage(vaultPath: string): VaultConfig { const DEFAULT: VaultConfig = { zoom: null, view_mode: null, editor_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, + inbox: null, } try { const raw = localStorage.getItem(storageKey(vaultPath)) diff --git a/src/types.ts b/src/types.ts index 1394d183..c5804917 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,7 +152,12 @@ export interface SearchResponse { export type SearchMode = 'keyword' | 'semantic' | 'hybrid' -/** Vault-wide UI configuration stored in ui.config.md at vault root. */ +/** Vault-scoped UI configuration stored locally per vault path. */ +export interface InboxConfig { + noteListProperties: string[] | null +} + +/** Vault-scoped UI configuration stored locally per vault path. */ export interface VaultConfig { zoom: number | null view_mode: string | null @@ -160,6 +165,7 @@ export interface VaultConfig { tag_colors: Record | null status_colors: Record | null property_display_modes: Record | null + inbox?: InboxConfig | null } export interface PulseFile { diff --git a/src/utils/configMigration.ts b/src/utils/configMigration.ts index 0530b4af..9b5ab9a4 100644 --- a/src/utils/configMigration.ts +++ b/src/utils/configMigration.ts @@ -28,7 +28,7 @@ function readJson(key: string): T | null { export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig { const base: VaultConfig = loaded ?? { zoom: null, view_mode: null, editor_mode: null, tag_colors: null, - status_colors: null, property_display_modes: null, + status_colors: null, property_display_modes: null, inbox: null, } // Skip migration if already done diff --git a/src/utils/vaultConfigStore.ts b/src/utils/vaultConfigStore.ts index 581f20b6..5def078b 100644 --- a/src/utils/vaultConfigStore.ts +++ b/src/utils/vaultConfigStore.ts @@ -6,6 +6,7 @@ type Listener = () => void const DEFAULT_CONFIG: VaultConfig = { zoom: null, view_mode: null, editor_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, + inbox: null, } let config: VaultConfig = DEFAULT_CONFIG