feat: add type-specific property chips in note list

Parse _list_properties_display from type file frontmatter and render
property/relationship values as chips below note snippets. Add a
SlidersHorizontal config popover in the note list header (sectionGroup
views only) with checkboxes and drag-to-reorder via dnd-kit. Changes
are saved back to the type file's frontmatter immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-04 12:52:54 +02:00
parent 0be1060d81
commit 8d90b8489b
12 changed files with 389 additions and 2 deletions

View File

@@ -75,6 +75,10 @@ pub struct VaultEntry {
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
/// Properties to display as chips in the note list for this Type's notes.
/// Configured via `_list_properties_display` in the type file's frontmatter.
#[serde(rename = "listPropertiesDisplay", default)]
pub list_properties_display: Vec<String>,
/// File kind: "markdown", "text", or "binary".
/// Determines how the frontend renders and opens the file.
#[serde(rename = "fileKind", default = "default_file_kind")]

View File

@@ -55,6 +55,8 @@ pub(crate) struct Frontmatter {
pub favorite: Option<bool>,
#[serde(rename = "_favorite_index", default)]
pub favorite_index: Option<i64>,
#[serde(rename = "_list_properties_display", default)]
pub list_properties_display: Option<Vec<String>>,
}
/// Custom deserializer for boolean fields that may arrive as strings.
@@ -169,6 +171,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"status",
"_favorite",
"_favorite_index",
"_list_properties_display",
];
let filtered: serde_json::Map<String, serde_json::Value> = data
.iter()
@@ -205,6 +208,7 @@ const SKIP_KEYS: &[&str] = &[
"status",
"_favorite",
"_favorite_index",
"_list_properties_display",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.

View File

@@ -117,6 +117,9 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
visible: frontmatter.visible,
favorite: frontmatter.favorite.unwrap_or(false),
favorite_index: frontmatter.favorite_index,
list_properties_display: frontmatter
.list_properties_display
.unwrap_or_default(),
word_count,
outgoing_links,
properties,

View File

@@ -1457,6 +1457,44 @@ fn test_scan_vault_folders_flat_vault() {
assert!(folders.is_empty(), "flat vault has no visible folders");
}
// --- list_properties_display tests ---
#[test]
fn test_parse_list_properties_display() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n";
let entry = parse_test_entry(&dir, "movies.md", content);
assert_eq!(entry.list_properties_display, vec!["rating", "genre"]);
}
#[test]
fn test_parse_list_properties_display_absent_defaults_empty() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Books\n";
let entry = parse_test_entry(&dir, "books.md", content);
assert!(entry.list_properties_display.is_empty());
}
#[test]
fn test_list_properties_display_not_in_properties_or_relationships() {
let dir = TempDir::new().unwrap();
let content =
"---\ntype: Type\n_list_properties_display:\n - rating\n---\n# Movies\n";
let entry = parse_test_entry(&dir, "movies.md", content);
assert!(
!entry
.properties
.contains_key("_list_properties_display"),
"_list_properties_display must not leak into properties map"
);
assert!(
!entry
.relationships
.contains_key("_list_properties_display"),
"_list_properties_display must not leak into relationships map"
);
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { NoteItem } from './NoteItem'
import type { VaultEntry } from '../types'
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
status: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
snippet: 'A snippet', wordCount: 50,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
visible: null, favorite: false, favoriteIndex: null,
outgoingLinks: [], properties: {},
listPropertiesDisplay: [],
...overrides,
}
}
function makeTypeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return makeEntry({
path: '/vault/movie.md', filename: 'movie.md', title: 'Movie',
isA: 'Type', listPropertiesDisplay: [],
...overrides,
})
}
const noop = vi.fn()
describe('NoteItem property chips', () => {
it('renders property chips when type has listPropertiesDisplay', () => {
const entry = makeEntry({
properties: { rating: 4, genre: 'Drama' },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByTestId('property-chips')).toBeInTheDocument()
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.getByText('Drama')).toBeInTheDocument()
})
it('does not render chips when listPropertiesDisplay is empty', () => {
const entry = makeEntry({ properties: { rating: 4 } })
const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
})
it('skips chips for missing properties', () => {
const entry = makeEntry({ properties: { rating: 4 } })
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.queryByText('genre')).not.toBeInTheDocument()
})
it('renders relationship values as display labels', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
})
it('shows hostname for URL properties', () => {
const entry = makeEntry({
properties: { url: 'https://www.example.com/page/123' },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('www.example.com')).toBeInTheDocument()
})
it('does not render chips for binary files', () => {
const entry = makeEntry({
fileKind: 'binary',
properties: { rating: 4 },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
})
})

View File

@@ -10,6 +10,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isEmoji } from '../utils/emoji'
import { wikilinkDisplay } from '../utils/wikilink'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -85,6 +86,59 @@ function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean
return null
}
function formatChipValue(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const s = String(value)
// URL: show only hostname
try {
if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname
} catch { /* not a URL */ }
return s.length > 40 ? s.slice(0, 37) + '…' : s
}
function resolveChipValues(entry: VaultEntry, propName: string): string[] {
// Check relationships first (wikilink values)
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
if (relKey) {
return entry.relationships[relKey].map((ref) => wikilinkDisplay(ref)).filter(Boolean)
}
// Check scalar properties
const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase())
if (!propKey) return []
const val = entry.properties[propKey]
if (Array.isArray(val)) return val.map((v) => formatChipValue(v)).filter((v): v is string => v !== null)
const formatted = formatChipValue(val)
return formatted ? [formatted] : []
}
function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProps: string[] }) {
const chips = useMemo(() => {
const result: { key: string; values: string[] }[] = []
for (const prop of displayProps) {
const values = resolveChipValues(entry, prop)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [entry, displayProps])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((v, i) => (
<span
key={`${key}-${i}`}
className="inline-block max-w-full truncate rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{v}
</span>
))
)}
</div>
)
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
@@ -153,6 +207,9 @@ 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 && (entry.trashed && entry.trashedAt
? <TrashDateLine entry={entry} />
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>

View File

@@ -100,7 +100,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} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} 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} onEmptyTrash={onEmptyTrash} onUpdateTypeProperty={onUpdateTypeSort} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (

View File

@@ -0,0 +1,142 @@
import { useState, useMemo, useCallback } from 'react'
import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import type { VaultEntry } from '../../types'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext, sortableKeyboardCoordinates, 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
}
/** 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 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 }
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
data-testid={`list-prop-item-${id}`}
>
<button
type="button"
className="flex shrink-0 cursor-grab items-center text-muted-foreground active:cursor-grabbing"
{...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>
</div>
)
}
export function ListPropertiesPopover({ typeDocument, entries, onSave }: 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))
return [...selected, ...unselected]
}, [currentDisplay, availableProperties])
const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleToggle = useCallback((key: string) => {
const newSelected = selectedSet.has(key)
? currentDisplay.filter((k) => k !== key)
: [...currentDisplay, key]
onSave(typeDocument.path, '_list_properties_display', newSelected.length > 0 ? newSelected : null)
}, [selectedSet, currentDisplay, typeDocument.path, onSave])
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 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])
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"
data-testid="list-properties-btn"
>
<SlidersHorizontal size={16} />
</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">
Show in note list
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
{orderedItems.map((key) => (
<SortablePropertyItem
key={key}
id={key}
checked={selectedSet.has(key)}
onToggle={handleToggle}
/>
))}
</SortableContext>
</DndContext>
</PopoverContent>
</Popover>
)
}

View File

@@ -4,8 +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'
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
@@ -17,12 +18,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
sidebarCollapsed?: boolean
searchVisible: boolean
search: string
isSectionGroup?: boolean
entries?: VaultEntry[]
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
onCreateNote: () => void
onOpenType: (entry: VaultEntry) => void
onToggleSearch: () => void
onSearchChange: (value: string) => void
onEmptyTrash?: () => void
onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
return (
@@ -41,6 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
<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} />
)}
{isTrashView && trashCount > 0 && (
<button
className="flex items-center text-destructive transition-colors hover:text-destructive/80"

View File

@@ -15,6 +15,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
order: { order: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
_list_properties_display: { listPropertiesDisplay: [] },
}
/** Check if a string contains a wikilink pattern `[[...]]`. */
@@ -64,6 +65,7 @@ export function frontmatterToEntryPatch(
visible: { visible: value === false ? false : null },
_favorite: { favorite: Boolean(value) },
_favorite_index: { favoriteIndex: typeof value === 'number' ? value : null },
_list_properties_display: { listPropertiesDisplay: Array.isArray(value) ? value.map(String) : [] },
}
// Also update the relationships map for wikilink-containing values
const wikilinks = value != null ? extractWikilinks(value) : []

View File

@@ -401,6 +401,16 @@ describe('frontmatterToEntryPatch', () => {
},
)
it('maps _list_properties_display update to listPropertiesDisplay array', () => {
const result = frontmatterToEntryPatch('update', '_list_properties_display', ['rating', 'genre'])
expect(result.patch).toEqual({ listPropertiesDisplay: ['rating', 'genre'] })
})
it('maps _list_properties_display delete to empty array', () => {
const result = frontmatterToEntryPatch('delete', '_list_properties_display')
expect(result.patch).toEqual({ listPropertiesDisplay: [] })
})
it('returns empty patch for unknown key on delete, with relationship removal', () => {
const result = frontmatterToEntryPatch('delete', 'unknown_key')
expect(result.patch).toEqual({})

View File

@@ -39,6 +39,8 @@ export interface VaultEntry {
favorite: boolean
/** Display order within the FAVORITES section (lower = higher). */
favoriteIndex: number | null
/** Properties to display as chips in the note list for this Type's notes. */
listPropertiesDisplay: string[]
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */