feat: draggable section groups in sidebar

This commit is contained in:
lucaronin
2026-02-21 18:36:28 +01:00
13 changed files with 330 additions and 36 deletions

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,9 @@
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mantine/core": "^8.3.14",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",

56
pnpm-lock.yaml generated
View File

@@ -20,6 +20,15 @@ importers:
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@mantine/core':
specifier: ^8.3.14
version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -322,6 +331,28 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emoji-mart/data@1.2.1':
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
@@ -3811,6 +3842,31 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@emoji-mart/data@1.2.1': {}
'@esbuild/aix-ppc64@0.27.3':

View File

@@ -42,6 +42,8 @@ pub struct VaultEntry {
pub icon: Option<String>,
/// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange".
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
}
/// Intermediate struct to capture YAML frontmatter fields.
@@ -75,6 +77,8 @@ struct Frontmatter {
icon: Option<String>,
#[serde(default)]
color: Option<String>,
#[serde(default)]
order: Option<i64>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -221,7 +225,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a", "aliases", "status", "cadence", "archived", "trashed", "trashed at",
"created at", "created time", "icon", "color",
"created at", "created time", "icon", "color", "order",
];
/// Check if a string contains a wikilink pattern `[[...]]`.
@@ -376,6 +380,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
modified_at, created_at, file_size,
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
})
}

View File

@@ -127,6 +127,15 @@ function App() {
setToastMessage('Note restored from trash')
}, [notes, vault, setToastMessage])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = vault.entries.find((e) => e.isA === 'Type' && e.title === typeName)
if (!typeEntry) continue
notes.handleUpdateFrontmatter(typeEntry.path, 'order', order)
vault.updateEntry(typeEntry.path, { order })
}
}, [vault, notes])
useAppKeyboard({
onQuickOpen: () => setShowQuickOpen(true),
onCreateNote: openCreateDialog,
@@ -177,7 +186,7 @@ function App() {
<div className="app-shell">
<div className="app">
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={openCreateTypeDialog} onCustomizeType={handleCustomizeType} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={openCreateTypeDialog} onCustomizeType={handleCustomizeType} onReorderSections={handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>

View File

@@ -62,6 +62,7 @@ const mockEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const mockContent = `---

View File

@@ -24,6 +24,7 @@ const mockEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const mockContent = `---
@@ -65,6 +66,7 @@ const referrerEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const allContent: Record<string, string> = {

View File

@@ -30,6 +30,7 @@ const mockEntries: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
@@ -55,6 +56,7 @@ const mockEntries: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
@@ -77,6 +79,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
@@ -99,6 +102,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
@@ -121,6 +125,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
]
@@ -278,6 +283,7 @@ describe('getSortComparator', () => {
relationships: {},
icon: null,
color: null,
order: null,
...overrides,
})
@@ -353,6 +359,7 @@ describe('NoteList sort controls', () => {
relationships: {},
icon: null,
color: null,
order: null,
...overrides,
})

View File

@@ -37,6 +37,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/responsibility/grow-newsletter.md',
@@ -59,6 +60,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/experiment/stock-screener.md',
@@ -81,6 +83,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/procedure/weekly-essays.md',
@@ -103,6 +106,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/topic/software-development.md',
@@ -125,6 +129,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/topic/trading.md',
@@ -147,6 +152,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/person/alice.md',
@@ -169,6 +175,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/event/kickoff.md',
@@ -191,6 +198,7 @@ const mockEntries: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: null,
},
]
@@ -215,34 +223,45 @@ describe('Sidebar', () => {
expect(screen.getByText('Types')).toBeInTheDocument()
})
it('shows entity names under their section groups', () => {
it('shows entity names under their section groups after expanding', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Sections start collapsed by default — expand them first
fireEvent.click(screen.getByLabelText('Expand Projects'))
fireEvent.click(screen.getByLabelText('Expand Responsibilities'))
fireEvent.click(screen.getByLabelText('Expand Experiments'))
fireEvent.click(screen.getByLabelText('Expand Procedures'))
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Grow Newsletter')).toBeInTheDocument()
expect(screen.getByText('Stock Screener')).toBeInTheDocument()
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
})
it('shows People and Events as section groups', () => {
it('shows People and Events items after expanding', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand People'))
fireEvent.click(screen.getByLabelText('Expand Events'))
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
})
it('collapses and expands sections', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Collapse Projects'))
// Start collapsed — items hidden
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
// Expand
fireEvent.click(screen.getByLabelText('Expand Projects'))
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
// Collapse
fireEvent.click(screen.getByLabelText('Collapse Projects'))
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('calls onSelect when clicking an entity', () => {
const onSelect = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Expand Projects'))
fireEvent.click(screen.getByText('Build Laputa App'))
expect(onSelect).toHaveBeenCalledWith({
kind: 'entity',
@@ -270,9 +289,10 @@ describe('Sidebar', () => {
})
})
it('renders Topics section with topic entries', () => {
it('renders Topics section with topic entries after expanding', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Topics')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand Topics'))
expect(screen.getByText('Software Development')).toBeInTheDocument()
expect(screen.getByText('Trading')).toBeInTheDocument()
})
@@ -280,6 +300,7 @@ describe('Sidebar', () => {
it('calls onSelect with topic kind when clicking a topic', () => {
const onSelect = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Expand Topics'))
fireEvent.click(screen.getByText('Software Development'))
expect(onSelect).toHaveBeenCalledWith({
kind: 'topic',
@@ -340,6 +361,7 @@ describe('Sidebar', () => {
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/type/book.md',
@@ -362,6 +384,7 @@ describe('Sidebar', () => {
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/recipe/pasta.md',
@@ -384,6 +407,7 @@ describe('Sidebar', () => {
relationships: {},
icon: null,
color: null,
order: null,
},
]
@@ -393,8 +417,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
it('shows instances of custom types under their section', () => {
it('shows instances of custom types under their section after expanding', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand Recipes'))
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument()
})
@@ -434,6 +459,7 @@ describe('Sidebar', () => {
relationships: {},
icon: null,
color: null,
order: null,
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
// "Projects" should appear once (the built-in section), not twice
@@ -463,15 +489,17 @@ describe('Sidebar', () => {
it('hides a section when its toggle is clicked off', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People section should be visible initially
expect(screen.getByText('Alice')).toBeInTheDocument()
// People section header should be visible initially
expect(screen.getByText('People')).toBeInTheDocument()
// Open customize popover and toggle People off
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section and its items should be gone
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
// People section header should be gone (use getAllByText to handle popover)
const peopleElements = screen.queryAllByText('People')
// Only the toggle label in the popover should remain, not the section header
expect(peopleElements.length).toBeLessThanOrEqual(1)
})
it('re-shows a section when its toggle is clicked on again', () => {
@@ -480,11 +508,12 @@ describe('Sidebar', () => {
// Hide People
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
// Show People again
fireEvent.click(screen.getByLabelText('Toggle People'))
expect(screen.getByText('Alice')).toBeInTheDocument()
// People section should be visible again — popover toggle + section header = 2 "People" texts
const peopleElements = screen.getAllByText('People')
expect(peopleElements.length).toBe(2)
})
it('persists hidden sections in localStorage', () => {
@@ -502,13 +531,13 @@ describe('Sidebar', () => {
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event']))
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People and Events sections should be hidden
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
// People and Events section headers should be hidden
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
// Other sections should still be visible
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Software Development')).toBeInTheDocument()
// Other section headers should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
@@ -534,4 +563,52 @@ describe('Sidebar', () => {
expect(screen.queryByText('Show in sidebar')).not.toBeInTheDocument()
})
})
describe('section ordering by type order property', () => {
const entriesWithOrder: VaultEntry[] = [
...mockEntries,
// Type entries with order values — reversed from default
{
path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
relationships: {}, icon: null, color: null, order: 5,
},
{
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
relationships: {}, icon: null, color: null, order: 0,
},
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
relationships: {}, icon: null, color: null, order: 1,
},
]
it('sorts sections by order from Type entries', () => {
render(<Sidebar entries={entriesWithOrder} selection={defaultSelection} onSelect={() => {}} />)
// Get all section header labels
const headers = screen.getAllByText(/^(Topics|People|Projects|Experiments|Responsibilities|Procedures|Events|Types)$/)
const labels = headers.map((el) => el.textContent)
// Topics (order: 0) and People (order: 1) should come before Projects (order: 5)
const topicsIdx = labels.indexOf('Topics')
const peopleIdx = labels.indexOf('People')
const projectsIdx = labels.indexOf('Projects')
expect(topicsIdx).toBeLessThan(projectsIdx)
expect(peopleIdx).toBeLessThan(projectsIdx)
expect(topicsIdx).toBeLessThan(peopleIdx)
})
it('renders drag handle on section headers', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const dragHandles = screen.getAllByLabelText(/^Drag to reorder/)
// Should have one drag handle per visible section group
expect(dragHandles.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,10 +1,26 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus, SlidersHorizontal } from 'lucide-react'
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus, SlidersHorizontal, GripVertical } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText,
Star,
@@ -30,6 +46,7 @@ interface SidebarProps {
onCreateType?: (type: string) => void
onCreateNewType?: () => void
onCustomizeType?: (typeName: string, icon: string, color: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
modifiedCount?: number
onCommitPush?: () => void
}
@@ -59,7 +76,7 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, modifiedCount = 0, onCommitPush }: SidebarProps) {
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush }: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
@@ -83,7 +100,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
}, [showCustomize])
const toggleSection = (type: string) => {
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
}
// Build a map of type name → type entry for quick lookup of icon/color
@@ -130,11 +147,47 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
})
}, [typeEntryMap])
const allSectionGroups = useMemo(
() => [...builtInWithOverrides, ...customSectionGroups],
[builtInWithOverrides, customSectionGroups],
// Merge built-in and custom sections, then sort by order from type entries
const allSectionGroups = useMemo(() => {
const merged = [...builtInWithOverrides, ...customSectionGroups]
return merged.sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
if (orderA !== orderB) return orderA - orderB
return a.label.localeCompare(b.label)
})
}, [builtInWithOverrides, customSectionGroups, typeEntryMap])
// DnD sensors with activation distance to avoid accidental drags on click
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const visibleSections = useMemo(
() => allSectionGroups.filter((g) => isSectionVisible(g.type)),
[allSectionGroups, isSectionVisible],
)
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = sectionIds.indexOf(active.id as string)
const newIndex = sectionIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
// Compute new order: assign sequential order values to the reordered list
const reordered = [...sectionIds]
reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, active.id as string)
const updates = reordered.map((typeName, i) => ({ typeName, order: i }))
onReorderSections?.(updates)
}, [sectionIds, onReorderSections])
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
@@ -192,7 +245,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
}
}, [customizeTarget, typeEntryMap, onCustomizeType])
const renderSection = ({ label, type, Icon, customColor }: SectionGroup) => {
const renderSectionContent = ({ label, type, Icon, customColor }: SectionGroup, dragHandleProps?: Record<string, unknown>) => {
const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed)
const isCollapsed = collapsed[type] ?? true
const isTopic = type === 'Topic'
@@ -210,7 +263,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
}
return (
<div key={type} style={{ padding: '4px 6px' }}>
<>
{/* Section header row */}
<div
className={cn(
@@ -219,13 +272,22 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
? "bg-secondary"
: "hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4, gap: 8 }}
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
onContextMenu={(e) => handleContextMenu(e, type)}
>
<div className="flex items-center" style={{ gap: 8 }}>
<div className="flex items-center" style={{ gap: 4 }}>
{/* Drag handle */}
<div
className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab"
style={{ width: 16, height: 16 }}
{...dragHandleProps}
aria-label={`Drag to reorder ${label}`}
>
<GripVertical size={12} />
</div>
<Icon size={16} style={{ color: sectionColor }} />
<span className="text-[13px] font-medium text-foreground">{label}</span>
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{(onCreateType || (isTypeSection && onCreateNewType)) && (
@@ -281,6 +343,30 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
))}
</div>
)}
</>
)
}
function SortableSection({ group }: { group: SectionGroup }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: group.type })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
padding: '4px 6px',
}
return (
<div ref={setNodeRef} style={style} {...attributes}>
{renderSectionContent(group, listeners)}
</div>
)
}
@@ -443,8 +529,14 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
)}
</div>
{/* Section Groups (built-in + custom), filtered by visibility */}
{allSectionGroups.filter((g) => isSectionVisible(g.type)).map(renderSection)}
{/* Section Groups (built-in + custom), filtered by visibility, sortable by drag */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((g) => (
<SortableSection key={g.type} group={g} />
))}
</SortableContext>
</DndContext>
</nav>
{/* Commit button — always visible */}

View File

@@ -232,7 +232,7 @@ export function useNoteActions(
status: noStatusTypes.has(type) ? null : 'Active',
owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
snippet: '', relationships: {}, icon: null, color: null, order: null,
}
const frontmatter = [
@@ -260,7 +260,7 @@ export function useNoteActions(
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
snippet: '', relationships: {}, icon: null, color: null, order: null,
}
const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n`

View File

@@ -449,6 +449,7 @@ AI agents are autonomous systems that can plan, execute, and adapt to achieve go
// --- Type documents ---
'/Users/luca/Laputa/type/project.md': `---
Is A: Type
order: 0
---
# Project
@@ -462,6 +463,7 @@ A **time-bound initiative** that advances a [[type/responsibility|Responsibility
`,
'/Users/luca/Laputa/type/responsibility.md': `---
Is A: Type
order: 1
---
# Responsibility
@@ -474,6 +476,7 @@ An **ongoing area of ownership** — something you're accountable for indefinite
`,
'/Users/luca/Laputa/type/procedure.md': `---
Is A: Type
order: 2
---
# Procedure
@@ -488,6 +491,7 @@ A **recurring process** tied to a [[type/responsibility|Responsibility]]. Proced
`,
'/Users/luca/Laputa/type/experiment.md': `---
Is A: Type
order: 3
---
# Experiment
@@ -500,6 +504,7 @@ A **hypothesis-driven investigation** with a clear test and measurable outcome.
`,
'/Users/luca/Laputa/type/person.md': `---
Is A: Type
order: 4
---
# Person
@@ -511,6 +516,7 @@ A **person** you interact with — team members, collaborators, contacts. People
`,
'/Users/luca/Laputa/type/event.md': `---
Is A: Type
order: 5
---
# Event
@@ -522,6 +528,7 @@ A **point-in-time occurrence** — meetings, launches, milestones. Events are li
`,
'/Users/luca/Laputa/type/topic.md': `---
Is A: Type
order: 6
---
# Topic
@@ -533,6 +540,7 @@ A **subject area** for categorization. Topics group related notes, projects, and
`,
'/Users/luca/Laputa/type/essay.md': `---
Is A: Type
order: 7
---
# Essay
@@ -544,6 +552,7 @@ A **published piece of writing** — newsletter essays, blog posts, articles. Es
`,
'/Users/luca/Laputa/type/note.md': `---
Is A: Type
order: 8
---
# Note
@@ -724,6 +733,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
@@ -755,6 +765,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md',
@@ -780,6 +791,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/procedure/write-weekly-essays.md',
@@ -805,6 +817,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/procedure/run-sponsorships.md',
@@ -830,6 +843,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/experiment/stock-screener.md',
@@ -856,6 +870,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
@@ -882,6 +897,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/note/budget-allocation.md',
@@ -907,6 +923,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
@@ -931,6 +948,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
@@ -956,6 +974,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
@@ -981,6 +1000,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/topic/trading.md',
@@ -1006,6 +1026,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/essay/on-writing-well.md',
@@ -1031,6 +1052,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/essay/engineering-leadership-101.md',
@@ -1057,6 +1079,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
@@ -1082,6 +1105,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
// --- Type documents ---
{
@@ -1105,6 +1129,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 0,
},
{
path: '/Users/luca/Laputa/type/responsibility.md',
@@ -1127,6 +1152,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 1,
},
{
path: '/Users/luca/Laputa/type/procedure.md',
@@ -1149,6 +1175,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 2,
},
{
path: '/Users/luca/Laputa/type/experiment.md',
@@ -1171,6 +1198,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 3,
},
{
path: '/Users/luca/Laputa/type/person.md',
@@ -1193,6 +1221,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 4,
},
{
path: '/Users/luca/Laputa/type/event.md',
@@ -1215,6 +1244,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 5,
},
{
path: '/Users/luca/Laputa/type/topic.md',
@@ -1237,6 +1267,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 6,
},
{
path: '/Users/luca/Laputa/type/essay.md',
@@ -1259,6 +1290,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 7,
},
{
path: '/Users/luca/Laputa/type/note.md',
@@ -1281,6 +1313,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: null,
color: null,
order: 8,
},
// --- Custom type documents (user-created types) ---
{
@@ -1304,6 +1337,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: 'cooking-pot',
color: 'orange',
order: 9,
},
{
path: '/Users/luca/Laputa/type/book.md',
@@ -1326,6 +1360,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
relationships: {},
icon: 'book-open',
color: 'green',
order: 10,
},
// --- Instances of custom types ---
{
@@ -1351,6 +1386,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/book/designing-data-intensive-applications.md',
@@ -1375,6 +1411,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
icon: null,
color: null,
order: null,
},
// --- Trashed entries ---
{
@@ -1468,6 +1505,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
trashedAt: null,
icon: null,
color: null,
order: null,
modifiedAt: Date.now() / 1000 - 86400 * 120,
createdAt: Date.now() / 1000 - 86400 * 200,
fileSize: 680,
@@ -1493,6 +1531,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
trashedAt: null,
icon: null,
color: null,
order: null,
modifiedAt: Date.now() / 1000 - 86400 * 90,
createdAt: Date.now() / 1000 - 86400 * 150,
fileSize: 520,

View File

@@ -22,6 +22,8 @@ export interface VaultEntry {
icon: string | null
/** Accent color key for Type entries: "red" | "purple" | "blue" | "green" | "yellow" | "orange" */
color: string | null
/** Display order for Type entries in sidebar (lower = higher). null = use default order. */
order: number | null
}
export interface GitCommit {