revert: remove pinned properties feature

Reverts commits d263e43, ae2260c, 473239b, aaa4691, cd7a570, 3cd097d.
Removes PinnedPropertiesBar, usePinnedProperties hook, Rust backend
for _pinned_properties, and all related tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-27 16:20:31 +01:00
parent 71f6f4cc5d
commit bfbc9d49d1
23 changed files with 37 additions and 964 deletions

View File

@@ -39,9 +39,8 @@ Any frontmatter field whose name starts with `_` is a **system property**:
Examples:
```yaml
_pinned_properties: # which properties appear in the editor inline bar (per-type)
- "Status:circle-dot" # format: "key:icon" (icon is optional)
- "Belongs to:arrow-up-right"
- "Due date:calendar"
- key: status
icon: circle-dot
_icon: shapes # icon assigned to a type
_color: blue # color assigned to a type
_order: 10 # sort order in the sidebar
@@ -179,7 +178,6 @@ Each entity type can have a corresponding **type document** in the `type/` folde
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
| `visible` | bool | Whether type appears in sidebar (default: true) |
| `_pinned_properties` | list | Pinned properties shown in editor bar + note list. Each item: `"key:icon"` |
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.

View File

@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 9;
const CACHE_VERSION: u32 = 8;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {

View File

@@ -1,13 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A single pinned-property config item stored in the type's frontmatter.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct PinnedPropertyConfig {
pub key: String,
pub icon: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
pub path: String,
@@ -66,7 +59,4 @@ pub struct VaultEntry {
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
/// Pinned properties configuration for Type entries.
#[serde(rename = "pinnedProperties", default)]
pub pinned_properties: Vec<PinnedPropertyConfig>,
}

View File

@@ -1,4 +1,3 @@
use crate::vault::entry::PinnedPropertyConfig;
use crate::vault::parsing::contains_wikilink;
use serde::Deserialize;
use std::collections::HashMap;
@@ -194,9 +193,6 @@ pub(crate) fn extract_relationships(
let mut relationships = HashMap::new();
for (key, value) in data {
if key.starts_with('_') {
continue;
}
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
continue;
}
@@ -232,9 +228,6 @@ pub(crate) fn extract_properties(
let mut properties = HashMap::new();
for (key, value) in data {
if key.starts_with('_') {
continue;
}
let lower = key.to_ascii_lowercase();
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) {
continue;
@@ -272,38 +265,6 @@ pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
}
/// Parse a single pinned-property entry from "key:icon" format.
fn parse_pinned_entry(s: &str) -> PinnedPropertyConfig {
match s.split_once(':') {
Some((key, icon)) if !icon.is_empty() => PinnedPropertyConfig {
key: key.trim().to_string(),
icon: Some(icon.trim().to_string()),
},
_ => PinnedPropertyConfig {
key: s.trim().to_string(),
icon: None,
},
}
}
/// Extract `_pinned_properties` from raw YAML frontmatter.
pub(crate) fn extract_pinned_properties(
data: &HashMap<String, serde_json::Value>,
) -> Vec<PinnedPropertyConfig> {
let value = match data.get("_pinned_properties") {
Some(v) => v,
None => return Vec::new(),
};
match value {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.map(parse_pinned_entry)
.collect(),
_ => Vec::new(),
}
}
/// Convert gray_matter::Pod to serde_json::Value
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
@@ -323,26 +284,17 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
}
}
/// Return type for `extract_fm_and_rels`: (frontmatter, relationships, properties, pinned).
type FrontmatterBundle = (
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
pub(crate) fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
) -> (
Frontmatter,
HashMap<String, Vec<String>>,
HashMap<String, serde_json::Value>,
Vec<PinnedPropertyConfig>,
);
/// Extract frontmatter, relationships, custom properties, and pinned-property config.
pub(crate) fn extract_fm_and_rels(data: Option<gray_matter::Pod>) -> FrontmatterBundle {
) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => {
return (
Frontmatter::default(),
HashMap::new(),
HashMap::new(),
Vec::new(),
)
}
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
};
let json_map: HashMap<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
@@ -350,70 +302,5 @@ pub(crate) fn extract_fm_and_rels(data: Option<gray_matter::Pod>) -> Frontmatter
parse_frontmatter(&json_map),
extract_relationships(&json_map),
extract_properties(&json_map),
extract_pinned_properties(&json_map),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pinned_entry_with_icon() {
let result = parse_pinned_entry("Status:circle-dot");
assert_eq!(result.key, "Status");
assert_eq!(result.icon, Some("circle-dot".to_string()));
}
#[test]
fn test_parse_pinned_entry_without_icon() {
let result = parse_pinned_entry("Priority");
assert_eq!(result.key, "Priority");
assert_eq!(result.icon, None);
}
#[test]
fn test_extract_pinned_properties_array() {
let mut data = HashMap::new();
data.insert(
"_pinned_properties".to_string(),
serde_json::json!(["Status:circle-dot", "Belongs to:arrow-up-right", "date"]),
);
let result = extract_pinned_properties(&data);
assert_eq!(result.len(), 3);
assert_eq!(result[0].key, "Status");
assert_eq!(result[0].icon, Some("circle-dot".to_string()));
assert_eq!(result[1].key, "Belongs to");
assert_eq!(result[1].icon, Some("arrow-up-right".to_string()));
assert_eq!(result[2].key, "date");
assert_eq!(result[2].icon, None);
}
#[test]
fn test_extract_pinned_properties_missing() {
let data = HashMap::new();
assert!(extract_pinned_properties(&data).is_empty());
}
#[test]
fn test_underscore_keys_excluded_from_properties() {
let mut data = HashMap::new();
data.insert("_pinned_properties".to_string(), serde_json::json!(["a:b"]));
data.insert("_hidden".to_string(), serde_json::json!("secret"));
data.insert("visible_key".to_string(), serde_json::json!("value"));
let props = extract_properties(&data);
assert!(!props.contains_key("_pinned_properties"));
assert!(!props.contains_key("_hidden"));
assert!(props.contains_key("visible_key"));
}
#[test]
fn test_underscore_keys_excluded_from_relationships() {
let mut data = HashMap::new();
data.insert("_refs".to_string(), serde_json::json!("[[note]]"));
data.insert("Topics".to_string(), serde_json::json!("[[topic]]"));
let rels = extract_relationships(&data);
assert!(!rels.contains_key("_refs"));
assert!(rels.contains_key("Topics"));
}
}

View File

@@ -43,8 +43,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let (frontmatter, mut relationships, properties, pinned_properties) =
extract_fm_and_rels(parsed.data);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
let snippet = extract_snippet(&content);
@@ -104,7 +103,6 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
word_count,
outgoing_links,
properties,
pinned_properties,
})
}

View File

@@ -1,4 +1,3 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -9,7 +8,6 @@ import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import { countWords } from '../utils/wikilinks'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
import { PushPin } from '@phosphor-icons/react'
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
@@ -32,51 +30,15 @@ function formatFileSize(bytes: number): string {
return `${mb.toFixed(1)} MB`
}
function PropertyPinMenu({ x, y, isPinned, onPin, onUnpin, onClose }: {
x: number; y: number; isPinned: boolean
onPin: () => void; onUnpin: () => void; onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [onClose])
return (
<div
ref={ref}
className="fixed z-50 flex flex-col rounded-lg border border-border bg-popover shadow-lg"
style={{ left: x, top: y, minWidth: 160, padding: 4 }}
data-testid="property-context-menu"
>
<button
className="flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-accent"
style={{ border: 'none', background: 'transparent', cursor: 'pointer' }}
onClick={() => { if (isPinned) { onUnpin() } else { onPin() } onClose() }}
>
<PushPin size={14} />
{isPinned ? 'Unpin from editor' : 'Pin to editor'}
</button>
</div>
)
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, isPinned, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange, onPin, onUnpin }: {
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]; vaultTags: string[]
isPinned: boolean
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
onPin?: (key: string) => void; onUnpin?: (key: string) => void
}) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && editingKey !== propKey) {
e.preventDefault()
@@ -84,21 +46,9 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
}
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!onPin && !onUnpin) return
e.preventDefault()
setCtxMenu({ x: e.clientX, y: e.clientY })
}, [onPin, onUnpin])
return (
<div
className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
style={isPinned ? { backgroundColor: 'color-mix(in srgb, var(--primary) 5%, transparent)' } : undefined}
tabIndex={0} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu}
data-testid="editable-property"
>
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
{isPinned && <PushPin size={10} className="shrink-0 text-primary" style={{ opacity: 0.6 }} />}
<span className="truncate">{propKey}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
@@ -108,13 +58,6 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
<div className="min-w-0">
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
{ctxMenu && (
<PropertyPinMenu
x={ctxMenu.x} y={ctxMenu.y} isPinned={isPinned}
onPin={() => onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
)
}
@@ -155,7 +98,6 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
isPinned, onPin, onUnpin,
}: {
entry: VaultEntry
content: string | null
@@ -165,9 +107,6 @@ export function DynamicPropertiesPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
isPinned?: (key: string) => boolean
onPin?: (key: string) => void
onUnpin?: (key: string) => void
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -187,12 +126,10 @@ export function DynamicPropertiesPanel({
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
isPinned={isPinned?.(key) ?? false}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
onPin={onPin} onUnpin={onUnpin}
/>
))}
</div>

View File

@@ -264,7 +264,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
onUpdateFrontmatter={onUpdateFrontmatter}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -1,8 +1,7 @@
import type React from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import type { FrontmatterValue } from './Inspector'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TitleField } from './TitleField'
@@ -14,8 +13,6 @@ import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
import { parseFrontmatter } from '../utils/frontmatter'
import { PinnedPropertiesBar } from './PinnedPropertiesBar'
interface Tab {
entry: VaultEntry
@@ -63,8 +60,6 @@ interface EditorContentProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Update a frontmatter property (for inline pinned-property editing). */
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
}
function EditorLoadingSkeleton() {
@@ -161,7 +156,6 @@ export function EditorContent({
onDeleteNote, rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
onUpdateFrontmatter,
...breadcrumbProps
}: EditorContentProps) {
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
@@ -181,12 +175,6 @@ export function EditorContent({
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
}, [activeTab, onRemoveNoteIcon])
const frontmatter = useMemo(
() => parseFrontmatter(activeTab?.content ?? null),
[activeTab?.content],
)
const currentEntry = freshEntry ?? activeTab?.entry ?? null
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{activeTab && (
@@ -227,15 +215,6 @@ export function EditorContent({
editable={!isTrashed}
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
/>
{currentEntry && (
<PinnedPropertiesBar
entry={currentEntry}
entries={entries}
frontmatter={frontmatter}
onUpdateFrontmatter={onUpdateFrontmatter}
onNavigate={onNavigateWikilink}
/>
)}
<div className="title-section__separator" />
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />

View File

@@ -8,7 +8,6 @@ import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
import { usePinnedProperties } from '../hooks/usePinnedProperties'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -139,11 +138,6 @@ export function Inspector({
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
}, [entry, onAddProperty])
const { isPinned, pinProperty, unpinProperty } = usePinnedProperties({
entry, entries, frontmatter,
onUpdateTypeFrontmatter: onUpdateFrontmatter,
})
return (
<aside className={cn("flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10")}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
@@ -158,7 +152,6 @@ export function Inspector({
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onNavigate={onNavigate}
isPinned={isPinned} onPin={pinProperty} onUnpin={unpinProperty}
/>
<DynamicRelationshipsPanel
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}

View File

@@ -1,5 +1,5 @@
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry, NoteStatus, PinnedPropertyConfig } from '../types'
import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
@@ -9,7 +9,6 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isEmoji } from '../utils/emoji'
import { NoteListPinnedValues } from './NoteListPinnedValues'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -28,15 +27,6 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
function defaultPinnedConfigs(entry: VaultEntry): PinnedPropertyConfig[] {
if (!entry.isA) return []
const pins: PinnedPropertyConfig[] = []
if (entry.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
if (entry.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
if (entry.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
return pins
}
const THIRTY_DAYS_SECS = 86400 * 30
function TrashDateLine({ entry }: { entry: VaultEntry }) {
@@ -101,37 +91,6 @@ function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor:
return base
}
function NoteItemTitle({ entry, noteStatus, isSelected }: { entry: VaultEntry; noteStatus: NoteStatus; isSelected: boolean }) {
return (
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
{entry.title}
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>
</div>
)
}
function NoteItemFooter({ entry, pinnedConfigs }: { entry: VaultEntry; pinnedConfigs: PinnedPropertyConfig[] }) {
return (
<>
{entry.snippet && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{pinnedConfigs.length > 0 && <NoteListPinnedValues entry={entry} pinnedConfigs={pinnedConfigs} />}
{entry.trashed && entry.trashedAt
? <TrashDateLine entry={entry} />
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
}
</>
)
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
entry: VaultEntry
isSelected: boolean
@@ -146,10 +105,6 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => {
if (te?.pinnedProperties && te.pinnedProperties.length > 0) return te.pinnedProperties
return defaultPinnedConfigs(entry)
}, [te, entry])
return (
<div
@@ -167,8 +122,23 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<NoteItemTitle entry={entry} noteStatus={noteStatus} isSelected={isSelected} />
<NoteItemFooter entry={entry} pinnedConfigs={pinnedConfigs} />
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
{entry.title}
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>
</div>
{entry.snippet && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{entry.trashed && entry.trashedAt
? <TrashDateLine entry={entry} />
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
}
</div>
)
}

View File

@@ -264,11 +264,11 @@ describe('NoteList', () => {
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
})
it('shows pinned status values in note items', () => {
it('does not render type badge or status on note items', () => {
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Pinned properties feature shows status values as chips in note items
const activeChips = screen.queryAllByText('Active')
expect(activeChips.length).toBeGreaterThanOrEqual(0) // status shown if entry has one
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
expect(screen.queryByText('Active')).not.toBeInTheDocument()
})
it('header shows search and plus icons instead of count badge', () => {

View File

@@ -1,76 +0,0 @@
import { memo } from 'react'
import type { VaultEntry, PinnedPropertyConfig } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { getStatusStyle } from '../utils/statusStyles'
import { wikilinkDisplay } from '../utils/wikilink'
import { resolvePinIcon } from '../hooks/usePinnedProperties'
import type { FrontmatterValue } from './Inspector'
function resolveNoteValue(entry: VaultEntry, key: string): { value: FrontmatterValue; isRelationship: boolean } {
const lowerKey = key.toLowerCase().replace(/\s+/g, '_')
if (lowerKey === 'status') return { value: entry.status, isRelationship: false }
const relValue = entry.relationships[key]
if (relValue && relValue.length > 0) {
return { value: relValue.length === 1 ? relValue[0] : relValue, isRelationship: true }
}
const propValue = entry.properties[key]
if (propValue != null) return { value: propValue, isRelationship: false }
return { value: null, isRelationship: false }
}
function formatCompactValue(value: FrontmatterValue, isRelationship: boolean): string | null {
if (value == null) return null
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return isRelationship ? wikilinkDisplay(value) : value
if (Array.isArray(value)) {
const items = value.map((v) => (isRelationship ? wikilinkDisplay(String(v)) : String(v)))
if (items.length <= 2) return items.join(', ')
return `${items[0]}, +${items.length - 1}`
}
return null
}
function chipStyle(key: string, value: FrontmatterValue, isRelationship: boolean): { bg: string; color: string } {
if (key.toLowerCase() === 'status' && typeof value === 'string') return getStatusStyle(value)
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
}
function ChipIcon({ name }: { name: string }) {
const Icon = resolveIcon(name)
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
return <Icon width={10} height={10} style={{ flexShrink: 0 }} />
}
function NoteListChip({ config, entry }: { config: PinnedPropertyConfig; entry: VaultEntry }) {
const { value, isRelationship } = resolveNoteValue(entry, config.key)
const display = formatCompactValue(value, isRelationship)
const iconName = resolvePinIcon(config.key, config.icon)
const colors = chipStyle(config.key, value, isRelationship)
if (!display) return null
return (
<span
className="inline-flex items-center gap-0.5 truncate"
style={{ fontSize: 11, fontWeight: 500, borderRadius: 4, padding: '2px 6px', backgroundColor: colors.bg, color: colors.color, maxWidth: 140 }}
data-testid="pinned-chip"
>
<ChipIcon name={iconName} />
<span className="truncate">{display}</span>
</span>
)
}
export const NoteListPinnedValues = memo(function NoteListPinnedValues({ entry, pinnedConfigs }: { entry: VaultEntry; pinnedConfigs: PinnedPropertyConfig[] }) {
const configs = pinnedConfigs.filter((c) => c.key.toLowerCase() !== 'type')
if (configs.length === 0) return null
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5" style={{ maxHeight: 44, overflow: 'hidden' }} data-testid="pinned-values">
{configs.map((cfg) => (
<NoteListChip key={cfg.key} config={cfg} entry={entry} />
))}
</div>
)
})

View File

@@ -1,167 +0,0 @@
import { memo, useRef, useState, useCallback, useEffect } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { usePinnedProperties, type ResolvedPinnedProperty } from '../hooks/usePinnedProperties'
import { PinnedPropertyChip } from './PinnedPropertyChip'
import { DotsThree } from '@phosphor-icons/react'
export const PinnedPropertiesBar = memo(function PinnedPropertiesBar({ entry, entries, frontmatter, onUpdateFrontmatter, onNavigate }: {
entry: VaultEntry
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onNavigate?: (target: string) => void
}) {
const { resolved, pinnedConfigs, reorderPins } = usePinnedProperties({ entry, entries, frontmatter, onUpdateTypeFrontmatter: onUpdateFrontmatter })
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = pinnedConfigs.findIndex((c) => c.key === active.id)
const newIndex = pinnedConfigs.findIndex((c) => c.key === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = [...pinnedConfigs]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reorderPins(reordered)
}, [pinnedConfigs, reorderPins])
const containerRef = useRef<HTMLDivElement>(null)
const [visibleCount, setVisibleCount] = useState(resolved.length)
const [showOverflow, setShowOverflow] = useState(false)
const measureOverflow = useCallback(() => {
const el = containerRef.current
if (!el) return
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-pinchip]'))
if (items.length === 0) { setVisibleCount(0); return }
const containerRight = el.getBoundingClientRect().right - 48
let count = 0
for (const child of items) {
if (child.getBoundingClientRect().right <= containerRight) count++
else break
}
setVisibleCount(Math.max(count, 1))
}, [])
useEffect(() => {
// Defer measurement to avoid synchronous setState in effect
requestAnimationFrame(measureOverflow)
const observer = new ResizeObserver(measureOverflow)
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [measureOverflow, resolved.length])
if (resolved.length === 0) return null
const handleSave = (key: string, value: string) => {
onUpdateFrontmatter?.(entry.path, key, value)
}
const hiddenCount = resolved.length - visibleCount
const sortableIds = resolved.map((p) => p.key)
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
<div
ref={containerRef}
className="flex items-start overflow-hidden"
style={{ gap: 16, padding: '8px 0', position: 'relative' }}
data-testid="pinned-properties-bar"
>
{resolved.map((p, i) => (
<SortableChip key={p.key} prop={p} visible={i < visibleCount} onSave={handleSave} onNavigate={onNavigate} />
))}
{hiddenCount > 0 && (
<OverflowPopover
count={hiddenCount}
items={resolved.slice(visibleCount)}
open={showOverflow}
onToggle={() => setShowOverflow((v) => !v)}
onSave={handleSave}
onNavigate={onNavigate}
/>
)}
</div>
</SortableContext>
</DndContext>
)
})
function SortableChip({ prop, visible, onSave, onNavigate }: {
prop: ResolvedPinnedProperty; visible: boolean
onSave: (key: string, value: string) => void; onNavigate?: (target: string) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: prop.key })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
...(visible ? {} : { visibility: 'hidden' as const, position: 'absolute' as const }),
}
return (
<div ref={setNodeRef} data-pinchip style={style} {...attributes} {...listeners}>
<PinnedPropertyChip
propKey={prop.key} label={prop.label} value={prop.value} icon={prop.icon}
isRelationship={prop.isRelationship}
onSave={onSave} onNavigate={onNavigate}
/>
</div>
)
}
function OverflowPopover({ count, items, open, onToggle, onSave, onNavigate }: {
count: number
items: ResolvedPinnedProperty[]
open: boolean
onToggle: () => void
onSave: (key: string, value: string) => void
onNavigate?: (target: string) => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onToggle()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, onToggle])
return (
<div ref={ref} className="relative shrink-0 self-end">
<button
className="flex items-center gap-1 rounded border-none bg-transparent cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
style={{ padding: '3px 6px', fontSize: 12 }}
onClick={onToggle}
title={`${count} more properties`}
>
<DotsThree weight="bold" width={16} height={16} />
<span>+{count}</span>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 flex flex-wrap gap-4 rounded-lg border border-border bg-popover p-3 shadow-md" style={{ minWidth: 200, maxWidth: 400 }}>
{items.map((p) => (
<PinnedPropertyChip
key={p.key} propKey={p.key} label={p.label} value={p.value} icon={p.icon}
isRelationship={p.isRelationship}
onSave={onSave} onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,119 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import type { FrontmatterValue } from './Inspector'
import { resolveIcon } from '../utils/iconRegistry'
import { getStatusStyle, DEFAULT_STATUS_STYLE } from '../utils/statusStyles'
import { wikilinkDisplay } from '../utils/wikilink'
function formatChipDisplay(value: FrontmatterValue, isRelationship: boolean): string | null {
if (value == null) return null
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') {
if (isRelationship) return wikilinkDisplay(value)
return value || null
}
if (Array.isArray(value)) {
if (value.length === 0) return null
const displays = value.map((v) => (typeof v === 'string' && isRelationship ? wikilinkDisplay(v) : String(v)))
if (displays.length <= 2) return displays.join(', ')
return `${displays[0]}, +${displays.length - 1}`
}
return null
}
interface ChipStyle { bg: string; color: string }
function resolveChipStyle(value: FrontmatterValue, isRelationship: boolean): ChipStyle {
if (!isRelationship && typeof value === 'string' && value) {
const s = getStatusStyle(value)
if (s !== DEFAULT_STATUS_STYLE) return { bg: s.bg, color: s.color }
}
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
}
function PropertyLabelIcon({ name }: { name: string }) {
const IconCmp = resolveIcon(name)
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
return <IconCmp width={12} height={12} />
}
function PropertyLabel({ icon, label }: { icon: string; label: string }) {
return (
<span className="flex items-center gap-1 text-muted-foreground" style={{ fontSize: 12 }}>
<PropertyLabelIcon name={icon} />
<span>{label}</span>
</span>
)
}
/** Full chip for the editor pinned properties bar (icon + label above, value chip below). */
export function PinnedPropertyChip({ propKey, label, value, icon, isRelationship, onSave, onNavigate }: {
propKey: string
label: string
value: FrontmatterValue
icon: string
isRelationship: boolean
onSave?: (key: string, value: string) => void
onNavigate?: (target: string) => void
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const display = formatChipDisplay(value, isRelationship)
const chipColors = resolveChipStyle(value, isRelationship)
const startEdit = useCallback(() => {
if (isRelationship) return
setDraft(display ?? '')
setEditing(true)
}, [display, isRelationship])
const commitEdit = useCallback(() => {
setEditing(false)
if (onSave && draft !== (display ?? '')) onSave(propKey, draft)
}, [onSave, propKey, draft, display])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); commitEdit() }
if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
}, [commitEdit])
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const handleChipClick = useCallback(() => {
if (isRelationship && typeof value === 'string') {
onNavigate?.(wikilinkDisplay(value))
} else {
startEdit()
}
}, [isRelationship, value, onNavigate, startEdit])
return (
<div className="flex flex-col gap-1 shrink-0" data-testid="pinned-property">
<PropertyLabel icon={icon} label={label} />
{editing ? (
<input
ref={inputRef}
className="rounded border border-border bg-background px-2 text-foreground outline-none focus:ring-1 focus:ring-primary"
style={{ fontSize: 12, fontWeight: 500, padding: '3px 8px', minWidth: 60, maxWidth: 160 }}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
/>
) : (
<button
className="inline-flex items-center gap-1 rounded border-none cursor-pointer transition-opacity hover:opacity-80"
style={{ padding: '3px 8px', fontSize: 12, fontWeight: 500, backgroundColor: chipColors.bg, color: chipColors.color }}
onClick={handleChipClick}
title={typeof value === 'string' ? value : undefined}
>
{display ?? <span className="text-muted-foreground italic">&mdash;</span>}
</button>
)}
</div>
)
}

View File

@@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest'
import { frontmatterToEntryPatch } from './frontmatterOps'
describe('frontmatterToEntryPatch — _pinned_properties', () => {
it('patches pinnedProperties on update', () => {
const result = frontmatterToEntryPatch('update', '_pinned_properties', ['Status:circle-dot', 'Date'])
expect(result.patch.pinnedProperties).toEqual([
{ key: 'Status', icon: 'circle-dot' },
{ key: 'Date', icon: null },
])
expect(result.relationshipPatch).toBeNull()
})
it('clears pinnedProperties on delete', () => {
const result = frontmatterToEntryPatch('delete', '_pinned_properties')
expect(result.patch.pinnedProperties).toEqual([])
expect(result.relationshipPatch).toBeNull()
})
it('ignores non-array _pinned_properties values', () => {
const result = frontmatterToEntryPatch('update', '_pinned_properties', 'not-an-array')
// Falls through to default handling (no patch for unknown keys)
expect(result.patch.pinnedProperties).toBeUndefined()
})
})

View File

@@ -4,7 +4,6 @@ import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
import { updateMockContent, trackMockChange } from '../mock-tauri'
import { parsePinnedConfig } from './usePinnedProperties'
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
@@ -43,17 +42,9 @@ export function frontmatterToEntryPatch(
): EntryPatchResult {
const k = key.toLowerCase().replace(/\s+/g, '_')
if (op === 'delete') {
if (k === '_pinned_properties') return { patch: { pinnedProperties: [] }, relationshipPatch: null }
const relPatch: RelationshipPatch = { [key]: null }
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
}
// Handle _pinned_properties for Type entries
if (k === '_pinned_properties' && Array.isArray(value)) {
const pinned = parsePinnedConfig(value.map(String))
return { patch: { pinnedProperties: pinned }, relationshipPatch: null }
}
const str = value != null ? String(value) : null
const arr = Array.isArray(value) ? value.map(String) : []
const updates: Record<string, Partial<VaultEntry>> = {

View File

@@ -19,7 +19,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, pinnedProperties: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {},
}
}

View File

@@ -1,78 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
resolvePinIcon,
serialisePinnedConfig,
parsePinnedConfig,
} from './usePinnedProperties'
import type { PinnedPropertyConfig } from '../types'
describe('resolvePinIcon', () => {
it('returns explicit icon when provided', () => {
expect(resolvePinIcon('status', 'star')).toBe('star')
})
it('returns default icon for known keys', () => {
expect(resolvePinIcon('status', null)).toBe('circle-dot')
expect(resolvePinIcon('Status', null)).toBe('circle-dot')
expect(resolvePinIcon('date', null)).toBe('calendar')
expect(resolvePinIcon('Belongs to', null)).toBe('arrow-up-right')
expect(resolvePinIcon('Related to', null)).toBe('arrows-left-right')
expect(resolvePinIcon('Due date', null)).toBe('calendar')
})
it('returns fallback icon for unknown keys', () => {
expect(resolvePinIcon('Priority', null)).toBe('arrow-up-right')
})
})
describe('serialisePinnedConfig', () => {
it('serialises configs with icons', () => {
const configs: PinnedPropertyConfig[] = [
{ key: 'Status', icon: 'circle-dot' },
{ key: 'date', icon: 'calendar' },
]
expect(serialisePinnedConfig(configs)).toEqual(['Status:circle-dot', 'date:calendar'])
})
it('serialises configs without icons', () => {
const configs: PinnedPropertyConfig[] = [
{ key: 'Status', icon: null },
]
expect(serialisePinnedConfig(configs)).toEqual(['Status'])
})
it('handles empty array', () => {
expect(serialisePinnedConfig([])).toEqual([])
})
})
describe('parsePinnedConfig', () => {
it('parses key:icon format', () => {
expect(parsePinnedConfig(['Status:circle-dot'])).toEqual([
{ key: 'Status', icon: 'circle-dot' },
])
})
it('parses key-only format', () => {
expect(parsePinnedConfig(['Status'])).toEqual([
{ key: 'Status', icon: null },
])
})
it('handles mixed formats', () => {
const result = parsePinnedConfig(['Status:circle-dot', 'Priority', 'date:calendar'])
expect(result).toEqual([
{ key: 'Status', icon: 'circle-dot' },
{ key: 'Priority', icon: null },
{ key: 'date', icon: 'calendar' },
])
})
it('round-trips with serialise', () => {
const configs: PinnedPropertyConfig[] = [
{ key: 'Status', icon: 'circle-dot' },
{ key: 'Belongs to', icon: 'arrow-up-right' },
]
expect(parsePinnedConfig(serialisePinnedConfig(configs))).toEqual(configs)
})
})

View File

@@ -1,148 +0,0 @@
import { useMemo, useCallback } from 'react'
import type { VaultEntry, PinnedPropertyConfig } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
const DEFAULT_ICONS: Record<string, string> = {
status: 'circle-dot',
date: 'calendar',
'belongs to': 'arrow-up-right',
'related to': 'arrows-left-right',
'due date': 'calendar',
}
export function resolvePinIcon(key: string, explicit: string | null): string {
if (explicit) return explicit
return DEFAULT_ICONS[key.toLowerCase()] ?? 'arrow-up-right'
}
export function serialisePinnedConfig(configs: PinnedPropertyConfig[]): string[] {
return configs.map((c) => (c.icon ? `${c.key}:${c.icon}` : c.key))
}
export function parsePinnedConfig(items: string[]): PinnedPropertyConfig[] {
return items.map((s) => {
const sep = s.indexOf(':')
if (sep === -1) return { key: s.trim(), icon: null }
return { key: s.slice(0, sep).trim(), icon: s.slice(sep + 1).trim() || null }
})
}
function computeDefaults(entries: VaultEntry[], typeName: string): PinnedPropertyConfig[] {
const sample = entries.find((e) => e.isA === typeName)
if (!sample) return []
const pins: PinnedPropertyConfig[] = []
if (sample.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
if (sample.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
if (sample.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
return pins
}
export interface ResolvedPinnedProperty {
key: string
icon: string
label: string
value: FrontmatterValue
isRelationship: boolean
}
function resolveValue(
entry: VaultEntry,
frontmatter: ParsedFrontmatter,
key: string,
): { value: FrontmatterValue; isRelationship: boolean } {
const lowerKey = key.toLowerCase().replace(/\s+/g, '_')
if (lowerKey === 'status') return { value: entry.status, isRelationship: false }
const relValue = entry.relationships?.[key]
if (relValue && relValue.length > 0) {
return { value: relValue.length === 1 ? relValue[0] : relValue, isRelationship: true }
}
const propValue = entry.properties?.[key]
if (propValue != null) return { value: propValue, isRelationship: false }
const fmValue = frontmatter[key]
if (fmValue != null) return { value: fmValue, isRelationship: false }
return { value: null, isRelationship: false }
}
function formatLabel(key: string): string {
return key.replace(/_/g, ' ')
}
/** Resolve a single pinned config to its display representation. */
function resolveOne(
entry: VaultEntry, frontmatter: ParsedFrontmatter, cfg: PinnedPropertyConfig,
): ResolvedPinnedProperty {
const { value, isRelationship } = resolveValue(entry, frontmatter, cfg.key)
return { key: cfg.key, icon: resolvePinIcon(cfg.key, cfg.icon), label: formatLabel(cfg.key), value, isRelationship }
}
/** Find the Type entry for a note's isA field. */
function findTypeEntry(entries: VaultEntry[], isA: string | null): VaultEntry | null {
if (!isA) return null
return entries.find((e) => e.isA === 'Type' && e.title === isA) ?? null
}
/** Determine which properties are pinned for a given type. */
function resolvePinnedConfigs(
isA: string | null, typeEntry: VaultEntry | null, entries: VaultEntry[],
): PinnedPropertyConfig[] {
if (!isA) return []
if (typeEntry && typeEntry.pinnedProperties.length > 0) return typeEntry.pinnedProperties
return computeDefaults(entries, isA)
}
export interface UsePinnedPropertiesResult {
pinnedConfigs: PinnedPropertyConfig[]
resolved: ResolvedPinnedProperty[]
pinProperty: (key: string, icon?: string) => void
unpinProperty: (key: string) => void
updatePinIcon: (key: string, icon: string) => void
reorderPins: (configs: PinnedPropertyConfig[]) => void
isPinned: (key: string) => boolean
}
/** Hook to read and mutate pinned properties for a note based on its type. */
export function usePinnedProperties({
entry, entries, frontmatter, onUpdateTypeFrontmatter,
}: {
entry: VaultEntry | null
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
onUpdateTypeFrontmatter?: (typePath: string, key: string, value: FrontmatterValue) => Promise<void>
}): UsePinnedPropertiesResult {
const typeEntry = useMemo(() => findTypeEntry(entries, entry?.isA ?? null), [entry?.isA, entries])
const pinnedConfigs = useMemo(
() => resolvePinnedConfigs(entry?.isA ?? null, typeEntry, entries),
[entry?.isA, typeEntry, entries],
)
const resolved = useMemo(
() => (entry ? pinnedConfigs.map((cfg) => resolveOne(entry, frontmatter, cfg)) : []),
[entry, pinnedConfigs, frontmatter],
)
const savePins = useCallback(
(newConfigs: PinnedPropertyConfig[]) => {
if (!typeEntry || !onUpdateTypeFrontmatter) return
onUpdateTypeFrontmatter(typeEntry.path, '_pinned_properties', serialisePinnedConfig(newConfigs))
},
[typeEntry, onUpdateTypeFrontmatter],
)
const pinProperty = useCallback(
(key: string, icon?: string) => {
if (pinnedConfigs.some((c) => c.key === key)) return
savePins([...pinnedConfigs, { key, icon: icon ?? null }])
},
[pinnedConfigs, savePins],
)
const unpinProperty = useCallback(
(key: string) => savePins(pinnedConfigs.filter((c) => c.key !== key)),
[pinnedConfigs, savePins],
)
const updatePinIcon = useCallback(
(key: string, icon: string) => savePins(pinnedConfigs.map((c) => (c.key === key ? { ...c, icon } : c))),
[pinnedConfigs, savePins],
)
const reorderPins = useCallback((configs: PinnedPropertyConfig[]) => savePins(configs), [savePins])
const isPinned = useCallback((key: string) => pinnedConfigs.some((c) => c.key === key), [pinnedConfigs])
return { pinnedConfigs, resolved, pinProperty, unpinProperty, updatePinIcon, reorderPins, isPinned }
}

View File

@@ -80,7 +80,6 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string,
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
if (key.startsWith('_')) return false
return !SKIP_KEYS.has(key.toLowerCase()) && !containsWikilinks(value)
}

View File

@@ -37,7 +37,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['q1-2026', 'software-development', 'matteo-cellini', 'maria-bianchi', 'marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15', Owner: 'Luca Rossi' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/grow-newsletter.md',
@@ -73,7 +72,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['on-writing-well', 'engineering-leadership-101', 'ai-agents-primer', 'growth', 'writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly', Owner: 'Luca Rossi' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/manage-sponsorships.md',
@@ -103,7 +101,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['matteo-cellini'],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/write-weekly-essays.md',
@@ -133,7 +130,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['grow-newsletter'],
properties: { Owner: 'Luca Rossi', Cadence: 'Weekly' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/run-sponsorships.md',
@@ -163,7 +159,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['manage-sponsorships'],
properties: { Owner: 'Matteo Cellini', Cadence: 'Weekly' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/stock-screener.md',
@@ -194,7 +189,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['trading', 'algorithmic-trading', 'ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01', Owner: 'Luca Rossi' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/facebook-ads-strategy.md',
@@ -225,7 +219,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['26q1-laputa-app', 'growth', 'ads'],
properties: { Priority: 'Medium', Rating: 4 },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/budget-allocation.md',
@@ -255,7 +248,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['26q1-laputa-app'],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/matteo-cellini.md',
@@ -284,7 +276,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/maria-bianchi.md',
@@ -313,7 +304,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/marco-verdi.md',
@@ -342,7 +332,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/elena-russo.md',
@@ -371,7 +360,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/2026-02-14-laputa-app-kickoff.md',
@@ -401,7 +389,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['26q1-laputa-app', 'matteo-cellini'],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/software-development.md',
@@ -431,7 +418,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/trading.md',
@@ -461,7 +447,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/on-writing-well.md',
@@ -491,7 +476,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['grow-newsletter'],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/engineering-leadership-101.md',
@@ -522,7 +506,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['grow-newsletter', 'software-development'],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/ai-agents-primer.md',
@@ -552,7 +535,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['grow-newsletter'],
properties: {},
pinnedProperties: [],
},
// --- Type documents ---
{
@@ -580,11 +562,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [
{ key: 'Status', icon: 'circle-dot' },
{ key: 'Belongs to', icon: 'arrow-up-right' },
{ key: 'Due date', icon: 'calendar' },
],
},
{
path: '/Users/luca/Laputa/responsibility.md',
@@ -611,7 +588,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/procedure.md',
@@ -638,7 +614,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/experiment.md',
@@ -665,7 +640,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: false,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/person.md',
@@ -692,7 +666,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/event.md',
@@ -719,7 +692,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/topic.md',
@@ -746,7 +718,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/essay.md',
@@ -773,7 +744,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/note.md',
@@ -800,7 +770,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
// --- Custom type documents ---
{
@@ -828,7 +797,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/recipe.md',
@@ -855,7 +823,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/book.md',
@@ -882,7 +849,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
// --- Instances of custom types ---
{
@@ -912,7 +878,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/pasta-carbonara.md',
@@ -941,7 +906,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/designing-data-intensive-applications.md',
@@ -970,7 +934,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
pinnedProperties: [],
},
// --- Trashed entries ---
{
@@ -1001,7 +964,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/deprecated-api-notes.md',
@@ -1030,7 +992,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/failed-seo-experiment.md',
@@ -1060,7 +1021,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Owner: 'Luca Rossi' },
pinnedProperties: [],
},
// --- Archived entries ---
{
@@ -1082,7 +1042,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Owner: 'Luca Rossi' },
pinnedProperties: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
fileSize: 680,
@@ -1112,7 +1071,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Owner: 'Luca Rossi' },
pinnedProperties: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
fileSize: 520,
@@ -1149,7 +1107,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/refactoring-ideas.md',
@@ -1176,7 +1133,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/refactoring-key-ideas.md',
@@ -1203,7 +1159,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
{
path: '/Users/luca/Laputa/refactoring-patterns.md',
@@ -1230,7 +1185,6 @@ export const MOCK_ENTRIES: VaultEntry[] = [
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
pinnedProperties: [],
},
]
@@ -1283,7 +1237,6 @@ function generateBulkEntries(count: number): VaultEntry[] {
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
properties: {},
pinnedProperties: [],
})
}
return entries

View File

@@ -137,7 +137,7 @@ const trimOrNull = (v: string | null | undefined): string | null => v?.trim() ||
export const mockHandlers: Record<string, (args: any) => any> = {
list_vault: () => MOCK_ENTRIES,
reload_vault: () => MOCK_ENTRIES,
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {}, pinnedProperties: [] },
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
sync_note_title: () => false,
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
get_all_content: () => MOCK_CONTENT,

View File

@@ -1,9 +1,3 @@
/** A single pinned-property config item from a Type's `_pinned_properties` list. */
export interface PinnedPropertyConfig {
key: string
icon: string | null
}
export interface VaultEntry {
path: string
filename: string
@@ -45,8 +39,6 @@ export interface VaultEntry {
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
properties: Record<string, string | number | boolean | null>
/** Pinned properties config for Type entries. Parsed from `_pinned_properties` frontmatter. */
pinnedProperties: PinnedPropertyConfig[]
}
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'