feat: customize inbox note list columns
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
18
src/App.tsx
18
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' ? (
|
||||
<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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
src/components/note-list/noteListPropertiesEvents.ts
Normal file
13
src/components/note-list/noteListPropertiesEvents.ts
Normal 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 },
|
||||
}))
|
||||
}
|
||||
52
src/components/ui/checkbox.tsx
Normal file
52
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user