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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleFavorite}
|
||||
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
|
||||
@@ -47,6 +47,7 @@ interface EditorProps {
|
||||
vaultPath?: string
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -206,7 +207,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onToggleFavorite, onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
@@ -252,6 +253,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onTrashNote={onTrashNote}
|
||||
onRestoreNote={onRestoreNote}
|
||||
onDeleteNote={onDeleteNote}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface EditorContentProps {
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onEditorChange?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -144,6 +145,7 @@ function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
onToggleInspector={props.onToggleInspector}
|
||||
onToggleFavorite={bindPath(props.onToggleFavorite, path)}
|
||||
onTrash={bindPath(props.onTrashNote, path)}
|
||||
onRestore={bindPath(props.onRestoreNote, path)}
|
||||
onArchive={bindPath(props.onArchiveNote, path)}
|
||||
@@ -250,6 +252,8 @@ export function EditorContent({
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
notePath={activeTab.entry.path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -922,4 +922,40 @@ describe('Sidebar', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
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(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides FAVORITES section when no favorites', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides trashed favorites from the section', () => {
|
||||
const trashedFav = { ...favEntry, trashed: true }
|
||||
render(<Sidebar entries={[...mockEntries, trashedFav]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with favorites filter when clicking a favorite', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<span className="truncate">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FAVORITES</span>
|
||||
</div>
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
{favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{favorites.map((entry) => {
|
||||
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
|
||||
return (
|
||||
<SortableFavoriteItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isActive}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
@@ -289,6 +385,9 @@ export const Sidebar = memo(function Sidebar({
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-destructive text-destructive-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
|
||||
Reference in New Issue
Block a user