feat: customize inbox note list columns

This commit is contained in:
lucaronin
2026-04-07 20:31:08 +02:00
parent 6c67b39ae5
commit 9cc3bcf758
20 changed files with 440 additions and 82 deletions

View File

@@ -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

View File

@@ -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.81.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

View File

@@ -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

View File

@@ -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' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} views={vault.views} visibleNotesRef={visibleNotesRef} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

View File

@@ -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(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} displayPropsOverride={['Owner']} onClickNote={noop} />
)
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('4')).not.toBeInTheDocument()
})
it('does not render chips for binary files', () => {
const entry = makeEntry({
fileKind: 'binary',

View File

@@ -147,7 +147,12 @@ function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttribu
return FileText
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, onClickNote, onPrefetch, onContextMenu }: {
function resolveDisplayProps(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>, 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<string, VaultEntry>
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}
</div>
)}
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
{!isBinary && displayProps.length > 0 && (
<PropertyChips entry={entry} displayProps={displayProps} />
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>

View File

@@ -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<VaultEntry>): 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(<NoteList {...defaultFilterProps} entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
@@ -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(
<NoteList
{...defaultFilterProps}
entries={entries}
selection={{ kind: 'filter', filter: 'inbox' }}
selectedNote={null}
onSelectNote={noopSelect}
onReplaceActiveTab={noopReplace}
onCreateNote={vi.fn()}
inboxNoteListProperties={null}
onUpdateInboxNoteListProperties={vi.fn()}
/>,
)
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(
<NoteList
{...defaultFilterProps}
entries={entries}
selection={{ kind: 'filter', filter: 'inbox' }}
selectedNote={null}
onSelectNote={noopSelect}
onReplaceActiveTab={noopReplace}
onCreateNote={vi.fn()}
inboxNoteListProperties={null}
onUpdateInboxNoteListProperties={onUpdateInboxNoteListProperties}
/>,
)
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(
<NoteList
{...defaultFilterProps}
entries={entries}
selection={{ kind: 'filter', filter: 'inbox' }}
selectedNote={null}
onSelectNote={noopSelect}
onReplaceActiveTab={noopReplace}
onCreateNote={vi.fn()}
inboxNoteListProperties={['Owner']}
onUpdateInboxNoteListProperties={vi.fn()}
/>,
)
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
})
describe('NoteList click behavior', () => {

View File

@@ -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<string>()
for (const entry of entries) {
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
}
return [...keys].sort((a, b) => a.localeCompare(b))
}
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
}
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
const ordered: string[] = []
const seen = new Set<string>()
for (const entry of entries) {
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
if (seen.has(key)) continue
seen.add(key)
ordered.push(key)
}
}
return ordered
}
function useBulkActions(
multiSelect: ReturnType<typeof useMultiSelect>,
onBulkArchive: NoteListProps['onBulkArchive'],
@@ -154,11 +182,13 @@ interface NoteListProps {
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
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<Set<string>>(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<string, ModifiedFile['status']>()
@@ -257,8 +339,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [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 (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onUpdateTypeProperty={onUpdateTypeSort} />
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} propertyPicker={propertyPicker} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={handleListKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (

View File

@@ -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<string>()
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 (
<div
@@ -41,84 +42,104 @@ function SortablePropertyItem({ id, checked, onToggle }: { id: string; checked:
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
data-testid={`list-prop-item-${id}`}
>
<button
<Checkbox
id={inputId}
checked={checked}
onCheckedChange={() => onToggle(id)}
aria-label={id}
/>
<label
htmlFor={inputId}
className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]"
onClick={(event) => {
event.preventDefault()
onToggle(id)
}}
>
<span className="truncate">{id}</span>
</label>
<Button
type="button"
className="flex shrink-0 cursor-grab items-center text-muted-foreground active:cursor-grabbing"
variant="ghost"
size="icon-xs"
className="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
tabIndex={-1}
aria-label={`Reorder ${id}`}
{...attributes}
{...listeners}
>
<DotsSixVertical size={14} />
</button>
<label className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(id)}
className="accent-primary"
style={{ width: 14, height: 14 }}
/>
<span className="truncate">{id}</span>
</label>
</Button>
</div>
)
}
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<OpenListPropertiesEventDetail>).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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
title="Configure list properties"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
title={triggerTitle}
aria-label={triggerTitle}
data-testid="list-properties-btn"
>
<SlidersHorizontal size={16} />
</button>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">

View File

@@ -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
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
<MagnifyingGlass size={16} />
</button>
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
)}
{propertyPicker && <ListPropertiesPopover {...propertyPicker} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
<Plus size={16} />
</button>

View File

@@ -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<OpenListPropertiesEventDetail>(OPEN_NOTE_LIST_PROPERTIES_EVENT, {
detail: { scope },
}))
}

View File

@@ -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<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked?: CheckedState
onCheckedChange?: (checked: CheckedState) => void
}
function Checkbox({
className,
checked = false,
disabled = false,
onCheckedChange,
...props
}: CheckboxProps) {
const isChecked = checked === true
return (
<button
type="button"
role="checkbox"
aria-checked={isChecked}
data-slot="checkbox"
data-state={isChecked ? 'checked' : 'unchecked'}
disabled={disabled}
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
onClick={() => {
if (disabled) return
onCheckedChange?.(!isChecked)
}}
{...props}
>
{isChecked && (
<span
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<Check className="size-3.5" />
</span>
)}
</button>
)
}
export { Checkbox }

View File

@@ -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 },

View File

@@ -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,
})

View File

@@ -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', () => {

View File

@@ -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,
])
}

View File

@@ -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))

View File

@@ -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<string, string> | null
status_colors: Record<string, string> | null
property_display_modes: Record<string, string> | null
inbox?: InboxConfig | null
}
export interface PulseFile {

View File

@@ -28,7 +28,7 @@ function readJson<T>(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

View File

@@ -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