From 0889d747febff373c11b45dd10f743cae2700611 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 2 Apr 2026 14:44:23 +0200 Subject: [PATCH] feat: add favorites section with star button, frontmatter persistence, and drag-to-reorder Adds FAVORITES sidebar section backed by _favorite and _favorite_index frontmatter properties. Star button in breadcrumb bar toggles favorite state. Drag-to-reorder updates _favorite_index on all affected notes. Section auto-hides when empty. ADR 0038 documents the decision to use frontmatter for portability. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/adr/0038-frontmatter-backed-favorites.md | 32 ++++++ src-tauri/src/vault/entry.rs | 5 + src-tauri/src/vault/frontmatter.rs | 12 ++ src-tauri/src/vault/mod.rs | 2 + src/App.tsx | 3 +- src/components/BreadcrumbBar.tsx | 14 ++- src/components/Editor.tsx | 4 +- src/components/EditorContent.tsx | 4 + src/components/Sidebar.test.tsx | 36 ++++++ src/components/Sidebar.tsx | 103 +++++++++++++++++- src/hooks/frontmatterOps.ts | 3 + src/hooks/useEntryActions.ts | 38 ++++++- src/types.ts | 6 +- src/utils/noteListHelpers.ts | 1 + 14 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0038-frontmatter-backed-favorites.md diff --git a/docs/adr/0038-frontmatter-backed-favorites.md b/docs/adr/0038-frontmatter-backed-favorites.md new file mode 100644 index 00000000..5a79a3c2 --- /dev/null +++ b/docs/adr/0038-frontmatter-backed-favorites.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0038" +title: "Frontmatter-backed favorites with _favorite and _favorite_index" +status: active +date: 2026-04-02 +--- + +## Context + +Users want to pin frequently-accessed notes to a dedicated FAVORITES section in the sidebar for quick navigation. The app needs a persistence mechanism for which notes are favorited and their display order. + +## Decision + +**Favorites are stored as two system properties in each note's YAML frontmatter: `_favorite: true` and `_favorite_index: `.** + +- `_favorite`: boolean. Present and `true` = favorited. Absent = not favorited. Toggling off deletes the key entirely (no `_favorite: false`). +- `_favorite_index`: integer. Controls display order in the FAVORITES sidebar section (lower = higher). Assigned automatically on favorite, updated on drag-to-reorder. +- Both use the `_` prefix convention (ADR 0008) — they are system-owned and hidden from the Properties panel. + +## Options considered + +- **Frontmatter per-note (chosen)**: Each note carries its own favorite state. Portable across devices (synced via git). No separate metadata file. Cons: two extra frontmatter writes on reorder. +- **Separate `.laputa/favorites.json` file**: Central list of favorite paths. Simpler reorder (one file write). Cons: not portable if `.laputa/` is gitignored; path references break on rename. +- **SQLite/app-level metadata**: Fast queries. Cons: not synced via git; diverges from frontmatter-first data model established in ADR 0008. + +## Consequences + +- Favorites survive vault sync via git — any client that reads frontmatter sees them. +- Reorder writes `_favorite_index` to N files (one per affected note). Acceptable for typical favorites lists (< 20 items). +- If `_favorite: true` exists but `_favorite_index` is absent, the note is appended to the end of the list. +- Re-evaluate if favorites list exceeds ~50 items and reorder writes become a performance concern. diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index 6c833cbb..43aa7c53 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -59,6 +59,11 @@ pub struct VaultEntry { pub view: Option, /// Whether this Type is visible in the sidebar. Defaults to true when absent. pub visible: Option, + /// Whether this note is a user favorite (shown in FAVORITES sidebar section). + pub favorite: bool, + /// Display order within the FAVORITES section (lower = higher). + #[serde(rename = "favoriteIndex")] + pub favorite_index: Option, /// Word count of the note body (excludes frontmatter and H1 title). #[serde(rename = "wordCount")] pub word_count: u32, diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 01b90027..93a3ad8e 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -45,6 +45,14 @@ pub(crate) struct Frontmatter { pub view: Option, #[serde(default)] pub visible: Option, + #[serde( + rename = "_favorite", + default, + deserialize_with = "deserialize_bool_or_string" + )] + pub favorite: Option, + #[serde(rename = "_favorite_index", default)] + pub favorite_index: Option, } /// Custom deserializer for boolean fields that may arrive as strings. @@ -154,6 +162,8 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { "notion_id", "Status", "status", + "_favorite", + "_favorite_index", ]; let filtered: serde_json::Map = data .iter() @@ -184,6 +194,8 @@ const SKIP_KEYS: &[&str] = &[ "view", "visible", "status", + "_favorite", + "_favorite_index", ]; /// Extract all wikilink-containing fields from raw YAML frontmatter. diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 5672e69f..f19cb9f6 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -102,6 +102,8 @@ pub fn parse_md_file(path: &Path) -> Result { sort: frontmatter.sort.and_then(|v| v.into_scalar()), view: frontmatter.view.and_then(|v| v.into_scalar()), visible: frontmatter.visible, + favorite: frontmatter.favorite.unwrap_or(false), + favorite_index: frontmatter.favorite_index, word_count, outgoing_links, properties, diff --git a/src/App.tsx b/src/App.tsx index 21d0bfe0..16c35d07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -488,7 +488,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -532,6 +532,7 @@ function App() { vaultPath={resolvedPath} noteList={aiNoteList} noteListFilter={aiNoteListFilter} + onToggleFavorite={entryActions.handleToggleFavorite} onTrashNote={entryActions.handleTrashNote} onRestoreNote={entryActions.handleRestoreNote} onDeleteNote={deleteActions.handleDeleteNote} diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index d66c8235..3fc1e5fb 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -13,6 +13,7 @@ import { ArrowCounterClockwise, Archive, ArrowUUpLeft, + Star, } from '@phosphor-icons/react' interface BreadcrumbBarProps { @@ -28,6 +29,7 @@ interface BreadcrumbBarProps { onToggleAIChat?: () => void inspectorCollapsed?: boolean onToggleInspector?: () => void + onToggleFavorite?: () => void onTrash?: () => void onRestore?: () => void onArchive?: () => void @@ -56,10 +58,20 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff, rawMode, onToggleRaw, showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector, - onTrash, onRestore, onArchive, onUnarchive, + onToggleFavorite, onTrash, onRestore, onArchive, onUnarchive, }: Omit) { return (
+
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 3ce9834d..28560c96 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -922,4 +922,40 @@ describe('Sidebar', () => { render( {}} />) expect(screen.queryByText('Build App')).not.toBeInTheDocument() }) + + describe('FAVORITES section', () => { + const favEntry: VaultEntry = { + path: '/vault/project/fav.md', filename: 'fav.md', title: 'My Favorite Note', + isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, + cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, + createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, + icon: null, color: null, order: null, sidebarLabel: null, template: null, + sort: null, view: null, visible: null, outgoingLinks: [], properties: {}, + favorite: true, favoriteIndex: 0, + } + + it('shows FAVORITES section when there are favorites', () => { + render( {}} />) + expect(screen.getByText('FAVORITES')).toBeInTheDocument() + expect(screen.getByText('My Favorite Note')).toBeInTheDocument() + }) + + it('hides FAVORITES section when no favorites', () => { + render( {}} />) + expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument() + }) + + it('hides trashed favorites from the section', () => { + const trashedFav = { ...favEntry, trashed: true } + render( {}} />) + expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument() + }) + + it('calls onSelect with favorites filter when clicking a favorite', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByText('My Favorite Note')) + expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' }) + }) + }) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d7b17a6e..555b26cf 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,8 +12,10 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { - FileText, Trash, Archive, CaretLeft, Tray, + FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, } from '@phosphor-icons/react' +import { isEmoji } from '../utils/emoji' +import { arrayMove } from '@dnd-kit/sortable' import { SlidersHorizontal } from 'lucide-react' import { type SectionGroup, isSelectionActive, @@ -34,6 +36,8 @@ interface SidebarProps { onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void onRenameSection?: (typeName: string, label: string) => void onToggleTypeVisibility?: (typeName: string) => void + onSelectFavorite?: (entry: VaultEntry) => void + onReorderFavorites?: (orderedPaths: string[]) => void folders?: FolderNode[] onCreateFolder?: (name: string) => void inboxCount?: number @@ -137,6 +141,98 @@ function SortableSection({ group, sectionProps }: { ) } +function SortableFavoriteItem({ entry, isActive, onSelect }: { + entry: VaultEntry; isActive: boolean; onSelect: () => void +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path }) + const icon = entry.icon && isEmoji(entry.icon) ? entry.icon : null + return ( +
+
+ {icon && {icon}} + {entry.title} +
+
+ ) +} + +function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder }: { + entries: VaultEntry[] + selection: SidebarSelection + onSelect: (sel: SidebarSelection) => void + onSelectNote?: (entry: VaultEntry) => void + onReorder?: (orderedPaths: string[]) => void +}) { + const [collapsed, setCollapsed] = useState(false) + const favorites = useMemo( + () => entries + .filter((e) => e.favorite && !e.archived && !e.trashed) + .sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)), + [entries], + ) + const favIds = useMemo(() => favorites.map((f) => f.path), [favorites]) + + 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 = favIds.indexOf(active.id as string) + const newIndex = favIds.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + const reordered = arrayMove(favIds, oldIndex, newIndex) + onReorder?.(reordered) + }, [favIds, onReorder]) + + if (favorites.length === 0) return null + + return ( +
+ + {!collapsed && ( + + +
+ {favorites.map((entry) => { + const isActive = isSelectionActive(selection, { kind: 'entity', entry }) + return ( + { + onSelect({ kind: 'filter', filter: 'favorites' }) + onSelectNote?.(entry) + }} + /> + ) + })} +
+
+
+ )} +
+ ) +} + function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) { const { onMouseDown } = useDragRegion() return ( @@ -204,7 +300,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, - onToggleTypeVisibility, + onToggleTypeVisibility, onSelectFavorite, onReorderFavorites, folders = [], onCreateFolder, inboxCount = 0, onCollapse, }: SidebarProps) { const [customizeTarget, setCustomizeTarget] = useState(null) @@ -289,6 +385,9 @@ export const Sidebar = memo(function Sidebar({ onSelect({ kind: 'filter', filter: 'trash' })} /> + {/* Favorites */} + + {/* Sections header + visibility popover */}
diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index deef37f7..f50a7120 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -12,6 +12,7 @@ const ENTRY_DELETE_MAP: Record> = { aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] }, archived: { archived: false }, trashed: { trashed: false }, order: { order: null }, template: { template: null }, sort: { sort: null }, visible: { visible: null }, + _favorite: { favorite: false }, _favorite_index: { favoriteIndex: null }, } /** Check if a string contains a wikilink pattern `[[...]]`. */ @@ -58,6 +59,8 @@ export function frontmatterToEntryPatch( sort: { sort: str }, view: { view: str }, visible: { visible: value === false ? false : null }, + _favorite: { favorite: Boolean(value) }, + _favorite_index: { favoriteIndex: typeof value === 'number' ? value : null }, } // Also update the relationships map for wikilink-containing values const wikilinks = value != null ? extractWikilinks(value) : [] diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index a0185a89..4ecdd46b 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -125,6 +125,42 @@ export function useEntryActions({ onFrontmatterPersisted?.() }, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted]) + const handleToggleFavorite = useCallback(async (path: string) => { + const entry = entries.find((e) => e.path === path) + if (!entry) return + if (entry.favorite) { + updateEntry(path, { favorite: false, favoriteIndex: null }) + try { + await handleDeleteProperty(path, '_favorite', { silent: true }) + await handleDeleteProperty(path, '_favorite_index', { silent: true }) + onFrontmatterPersisted?.() + } catch { + updateEntry(path, { favorite: true, favoriteIndex: entry.favoriteIndex }) + setToastMessage('Failed to unfavorite — rolled back') + } + } else { + const maxIndex = entries.filter((e) => e.favorite).reduce((max, e) => Math.max(max, e.favoriteIndex ?? 0), 0) + const newIndex = maxIndex + 1 + updateEntry(path, { favorite: true, favoriteIndex: newIndex }) + try { + await handleUpdateFrontmatter(path, '_favorite', true, { silent: true }) + await handleUpdateFrontmatter(path, '_favorite_index', newIndex, { silent: true }) + onFrontmatterPersisted?.() + } catch { + updateEntry(path, { favorite: false, favoriteIndex: null }) + setToastMessage('Failed to favorite — rolled back') + } + } + }, [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted]) + + const handleReorderFavorites = useCallback(async (orderedPaths: string[]) => { + for (let i = 0; i < orderedPaths.length; i++) { + updateEntry(orderedPaths[i], { favoriteIndex: i }) + await handleUpdateFrontmatter(orderedPaths[i], '_favorite_index', i, { silent: true }) + } + onFrontmatterPersisted?.() + }, [updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted]) + const handleToggleTypeVisibility = useCallback(async (typeName: string) => { const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry) if (typeEntry.visible === false) { @@ -137,5 +173,5 @@ export function useEntryActions({ onFrontmatterPersisted?.() }, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted]) - return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility } + return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility, handleToggleFavorite, handleReorderFavorites } } diff --git a/src/types.ts b/src/types.ts index 55f6a523..e358b50f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,10 @@ export interface VaultEntry { view: string | null /** Whether this Type is visible in the sidebar. Defaults to true when absent. */ visible: boolean | null + /** Whether this note is a user favorite (shown in FAVORITES sidebar section). */ + favorite: boolean + /** Display order within the FAVORITES section (lower = higher). */ + favoriteIndex: number | null /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ @@ -171,7 +175,7 @@ export interface PulseCommit { deleted: number } -export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' | 'inbox' +export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' | 'inbox' | 'favorites' export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all' diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index a0dcf89d..42a94886 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -336,6 +336,7 @@ function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] if (filter === 'all') return entries.filter(isActive) if (filter === 'archived') return entries.filter((e) => e.archived && !e.trashed) if (filter === 'trash') return entries.filter((e) => e.trashed) + if (filter === 'favorites') return entries.filter((e) => e.favorite && !e.archived && !e.trashed) if (filter === 'pulse') return [] return [] }