refactor: lean code health improvements — avg 8.92 → 9.70

This commit is contained in:
lucaronin
2026-02-22 11:31:21 +01:00
25 changed files with 2278 additions and 2616 deletions

View File

@@ -12,6 +12,7 @@ import { StatusBar } from './components/StatusBar'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useNoteActions } from './hooks/useNoteActions'
import { useAppKeyboard } from './hooks/useAppKeyboard'
import { useEntryActions } from './hooks/useEntryActions'
import { isTauri } from './mock-tauri'
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
import type { SidebarSelection, GitCommit } from './types'
@@ -26,8 +27,6 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
// In web/browser mode: only Demo v2 (no real vault access)
// In native Tauri mode: Demo v2 + real Laputa vault
const VAULTS = isTauri()
? [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
@@ -43,12 +42,20 @@ const BUILT_IN_TYPE_NAMES = new Set([
'Quarter', 'Journal', 'Evergreen',
])
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
}
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const layout = useLayoutPanels()
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createNoteDefaultType, setCreateNoteDefaultType] = useState<string | undefined>()
@@ -62,7 +69,14 @@ function App() {
const vault = useVaultLoader(vaultPath)
const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage)
// Derive custom types from vault (Type entries not in built-in list)
const entryActions = useEntryActions({
entries: vault.entries,
updateEntry: vault.updateEntry,
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty,
setToastMessage,
})
const customTypes = useMemo(
() => vault.entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPE_NAMES.has(e.title))
@@ -71,7 +85,6 @@ function App() {
[vault.entries],
)
// Reset UI state when vault changes
const handleSwitchVault = useCallback((path: string) => {
setVaultPath(path)
setSelection(DEFAULT_SELECTION)
@@ -79,12 +92,8 @@ function App() {
notes.closeAllTabs()
}, [notes])
// Load git history when active tab changes
useEffect(() => {
if (!notes.activeTabPath) {
setGitHistory([])
return
}
if (!notes.activeTabPath) { setGitHistory([]); return }
vault.loadGitHistory(notes.activeTabPath).then(setGitHistory)
}, [notes.activeTabPath, vault.loadGitHistory])
@@ -93,67 +102,17 @@ function App() {
setShowCreateDialog(true)
}, [])
const openCreateTypeDialog = useCallback(() => {
setShowCreateTypeDialog(true)
}, [])
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
}, [notes, setToastMessage])
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
const typeEntry = vault.entries.find((e) => e.isA === 'Type' && e.title === typeName)
if (!typeEntry) return
// Update icon and color in frontmatter (two separate calls)
notes.handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
notes.handleUpdateFrontmatter(typeEntry.path, 'color', color)
// Also update the entry in-memory for instant UI feedback
vault.updateEntry(typeEntry.path, { icon, color })
}, [vault, notes])
const handleTrashNote = useCallback(async (path: string) => {
const now = new Date().toISOString().slice(0, 10)
await notes.handleUpdateFrontmatter(path, 'trashed', true)
await notes.handleUpdateFrontmatter(path, 'trashed_at', now)
vault.updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Note moved to trash')
}, [notes, vault, setToastMessage])
const handleRestoreNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'trashed', false)
await notes.handleDeleteProperty(path, 'trashed_at')
vault.updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
}, [notes, vault, setToastMessage])
const handleArchiveNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'archived', true)
vault.updateEntry(path, { archived: true })
setToastMessage('Note archived')
}, [notes, vault, setToastMessage])
const handleUnarchiveNote = useCallback(async (path: string) => {
await notes.handleUpdateFrontmatter(path, 'archived', false)
vault.updateEntry(path, { archived: false })
setToastMessage('Note unarchived')
}, [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])
}, [notes])
useAppKeyboard({
onQuickOpen: () => setShowQuickOpen(true),
onCreateNote: openCreateDialog,
onSave: () => setToastMessage('Saved'),
onTrashNote: handleTrashNote,
onArchiveNote: handleArchiveNote,
onTrashNote: entryActions.handleTrashNote,
onArchiveNote: entryActions.handleArchiveNote,
activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef,
})
@@ -169,18 +128,6 @@ function App() {
onSelectNote: notes.handleSelectNote,
})
const handleSidebarResize = useCallback((delta: number) => {
setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta)))
}, [])
const handleNoteListResize = useCallback((delta: number) => {
setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta)))
}, [])
const handleInspectorResize = useCallback((delta: number) => {
setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta)))
}, [])
const handleCommitPush = useCallback(async (message: string) => {
setShowCommitDialog(false)
try {
@@ -198,14 +145,14 @@ function App() {
return (
<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} onReorderSections={handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={() => setShowCreateTypeDialog(true)} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
<ResizeHandle onResize={layout.handleSidebarResize} />
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} onSelectNote={notes.handleSelectNote} onCreateNote={openCreateDialog} />
</div>
<ResizeHandle onResize={handleNoteListResize} />
<ResizeHandle onResize={layout.handleNoteListResize} />
<div className="app__editor">
<Editor
tabs={notes.tabs}
@@ -219,10 +166,10 @@ function App() {
onLoadDiffAtCommit={vault.loadDiffAtCommit}
isModified={vault.isFileModified}
onCreateNote={openCreateDialog}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={() => setInspectorCollapsed((c) => !c)}
inspectorWidth={inspectorWidth}
onInspectorResize={handleInspectorResize}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)}
inspectorWidth={layout.inspectorWidth}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
allContent={vault.allContent}
@@ -233,39 +180,19 @@ function App() {
showAIChat={showAIChat}
onToggleAIChat={() => setShowAIChat(c => !c)}
vaultPath={vaultPath}
onTrashNote={handleTrashNote}
onRestoreNote={handleRestoreNote}
onArchiveNote={handleArchiveNote}
onUnarchiveNote={handleUnarchiveNote}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={VAULTS} onSwitchVault={handleSwitchVault} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette
open={showQuickOpen}
entries={vault.entries}
onSelect={notes.handleSelectNote}
onClose={() => setShowQuickOpen(false)}
/>
<CreateNoteDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreate={notes.handleCreateNote}
defaultType={createNoteDefaultType}
customTypes={customTypes}
/>
<CreateTypeDialog
open={showCreateTypeDialog}
onClose={() => setShowCreateTypeDialog(false)}
onCreate={handleCreateType}
/>
<CommitDialog
open={showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
onCommit={handleCommitPush}
onClose={() => setShowCommitDialog(false)}
/>
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CreateNoteDialog open={showCreateDialog} onClose={() => setShowCreateDialog(false)} onCreate={notes.handleCreateNote} defaultType={createNoteDefaultType} customTypes={customTypes} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />
<CommitDialog open={showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={handleCommitPush} onClose={() => setShowCommitDialog(false)} />
</div>
)
}

View File

@@ -24,6 +24,7 @@ const baseEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const archivedEntry: VaultEntry = {

View File

@@ -4,6 +4,25 @@ interface DiffViewProps {
diff: string
}
const DIFF_HEADER_PREFIXES = ['diff', 'index', '---', '+++', 'new file']
function classifyDiffLine(line: string): string {
if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
if (line.startsWith('@@')) return 'bg-[rgba(33,150,243,0.08)] text-primary italic'
if (DIFF_HEADER_PREFIXES.some((p) => line.startsWith(p))) return 'bg-muted text-muted-foreground font-semibold'
return 'text-secondary-foreground'
}
function DiffLine({ line, lineNumber }: { line: string; lineNumber: number }) {
return (
<div className={cn("flex min-h-[22px] px-4", classifyDiffLine(line))}>
<span className="w-10 shrink-0 text-right pr-3 text-muted-foreground select-none">{lineNumber}</span>
<span className="flex-1 whitespace-pre-wrap break-all px-2">{line || '\u00A0'}</span>
</div>
)
}
export function DiffView({ diff }: DiffViewProps) {
if (!diff) {
return (
@@ -13,33 +32,11 @@ export function DiffView({ diff }: DiffViewProps) {
)
}
const lines = diff.split('\n')
return (
<div className="font-mono text-[13px] leading-relaxed py-3">
{lines.map((line, i) => {
let lineClass = 'text-secondary-foreground'
if (line.startsWith('+') && !line.startsWith('+++')) {
lineClass = 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
} else if (line.startsWith('-') && !line.startsWith('---')) {
lineClass = 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
} else if (line.startsWith('@@')) {
lineClass = 'bg-[rgba(33,150,243,0.08)] text-primary italic'
} else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('---') || line.startsWith('+++') || line.startsWith('new file')) {
lineClass = 'bg-muted text-muted-foreground font-semibold'
}
return (
<div key={i} className={cn("flex min-h-[22px] px-4", lineClass)}>
<span className="w-10 shrink-0 text-right pr-3 text-muted-foreground select-none">
{i + 1}
</span>
<span className="flex-1 whitespace-pre-wrap break-all px-2">
{line || '\u00A0'}
</span>
</div>
)
})}
{diff.split('\n').map((line, i) => (
<DiffLine key={i} line={line} lineNumber={i + 1} />
))}
</div>
)
}

View File

@@ -49,6 +49,166 @@ function formatDate(timestamp: number | null): string {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
if (raw.toLowerCase() === 'false') return false
if (!isNaN(Number(raw)) && raw.trim() !== '') return Number(raw)
return raw
}
function parseNewValue(rawValue: string): FrontmatterValue {
if (!rawValue.includes(',')) return rawValue.trim() || ''
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items.length === 1 ? items[0] : items
}
function isStatusKey(key: string): boolean {
return key === 'Status' || key.includes('Status')
}
function isDateKey(key: string): boolean {
return key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')
}
function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: {
propKey: string; value: FrontmatterValue; isEditing: boolean
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
}) {
const statusStr = String(value)
const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE
if (isEditing) {
return (
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" defaultValue={statusStr}
onKeyDown={(e) => {
if (e.key === 'Enter') onSave(propKey, (e.target as HTMLInputElement).value)
if (e.key === 'Escape') onStartEdit(null)
}}
onBlur={(e) => onSave(propKey, e.target.value)}
autoFocus
/>
)
}
return (
<span
className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }}
onClick={() => onStartEdit(propKey)} title={statusStr}
>
{statusStr}
</span>
)
}
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<button
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={onToggle}
>
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
)
}
function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: string) => void; onCancel: () => void }) {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue)
else if (e.key === 'Escape') onCancel()
}
return (
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Value" value={newValue}
onChange={(e) => setNewValue(e.target.value)} onKeyDown={handleKeyDown}
/>
<div className="flex justify-end gap-2">
<Button size="xs" onClick={() => onAdd(newKey, newValue)} disabled={!newKey.trim()}>Add</Button>
<Button size="xs" variant="outline" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
function TypeRow({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
{onNavigate ? (
<button
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
style={{ background: getTypeLightColor(isA), color: getTypeColor(isA), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
onClick={() => onNavigate(`type/${isA.toLowerCase()}`)} title={isA}
>{isA}</button>
) : (
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
)}
</div>
)
}
function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveList, onUpdate, onDelete }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
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
}) {
return (
<div className="group/prop flex items-center justify-between">
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
{propKey}
{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>
)}
</span>
<PropertyValueCell propKey={propKey} value={value} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
)
}
function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: {
propKey: string; value: FrontmatterValue; isEditing: boolean
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
}) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
if (value === null || value === undefined) return <EditableValue {...editProps} />
if (isStatusKey(propKey)) return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
if (isDateKey(propKey)) return <EditableValue {...editProps} />
if (typeof value === 'boolean') return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
return <EditableValue {...editProps} />
}
function StaticRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">{label}</span>
<span className="text-right text-[12px] text-secondary-foreground">{value}</span>
</div>
)
}
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
<button
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
onClick={onClick} disabled={disabled}
>+ Add property</button>
)
}
export function DynamicPropertiesPanel({
entry,
content,
@@ -68,219 +228,46 @@ export function DynamicPropertiesPanel({
}) {
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const wordCount = countWords(content)
const propertyEntries = useMemo(() => {
return Object.entries(frontmatter)
.filter(([key, value]) => {
if (SKIP_KEYS.has(key)) return false
if (RELATIONSHIP_KEYS.has(key)) return false
if (containsWikilinks(value)) return false
return true
})
.filter(([key, value]) => !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value))
}, [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (onUpdateProperty) {
if (newValue.toLowerCase() === 'true') onUpdateProperty(key, true)
else if (newValue.toLowerCase() === 'false') onUpdateProperty(key, false)
else if (!isNaN(Number(newValue)) && newValue.trim() !== '') onUpdateProperty(key, Number(newValue))
else onUpdateProperty(key, newValue)
}
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
}, [onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (onUpdateProperty) {
if (newItems.length === 0) onDeleteProperty?.(key)
else if (newItems.length === 1) onUpdateProperty(key, newItems[0])
else onUpdateProperty(key, newItems)
}
if (!onUpdateProperty) return
if (newItems.length === 0) onDeleteProperty?.(key)
else if (newItems.length === 1) onUpdateProperty(key, newItems[0])
else onUpdateProperty(key, newItems)
}, [onUpdateProperty, onDeleteProperty])
const handleAddProperty = useCallback(() => {
if (newKey.trim() && onAddProperty) {
if (newValue.includes(',')) {
const items = newValue.split(',').map(s => s.trim()).filter(s => s)
onAddProperty(newKey.trim(), items.length === 1 ? items[0] : items)
} else {
onAddProperty(newKey.trim(), newValue.trim() || '')
}
setNewKey('')
setNewValue('')
setShowAddDialog(false)
}
}, [newKey, newValue, onAddProperty])
const handleAddKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleAddProperty()
else if (e.key === 'Escape') {
setShowAddDialog(false)
setNewKey('')
setNewValue('')
}
}
const renderEditableValue = (key: string, value: FrontmatterValue) => {
if (value === null || value === undefined) {
return (
<EditableValue
value="" isEditing={editingKey === key}
onStartEdit={() => setEditingKey(key)}
onSave={(v) => handleSaveValue(key, v)}
onCancel={() => setEditingKey(null)}
/>
)
}
if (key === 'Status' || key.includes('Status')) {
const statusStr = String(value)
const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE
if (editingKey === key) {
return (
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" defaultValue={statusStr}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveValue(key, (e.target as HTMLInputElement).value)
if (e.key === 'Escape') setEditingKey(null)
}}
onBlur={(e) => handleSaveValue(key, e.target.value)}
autoFocus
/>
)
}
return (
<span
className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }}
onClick={() => setEditingKey(key)} title={statusStr}
>
{statusStr}
</span>
)
}
if (Array.isArray(value)) {
return <TagPillList items={value.map(String)} onSave={(items) => handleSaveList(key, items)} label={key} />
}
if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) {
return (
<EditableValue
value={String(value)} isEditing={editingKey === key}
onStartEdit={() => setEditingKey(key)}
onSave={(v) => handleSaveValue(key, v)}
onCancel={() => setEditingKey(null)}
/>
)
}
if (typeof value === 'boolean') {
return (
<button
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={() => onUpdateProperty?.(key, !value)}
>
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
)
}
return (
<EditableValue
value={String(value)} isEditing={editingKey === key}
onStartEdit={() => setEditingKey(key)}
onSave={(v) => handleSaveValue(key, v)}
onCancel={() => setEditingKey(null)}
/>
)
}
const handleAdd = useCallback((rawKey: string, rawValue: string) => {
if (!rawKey.trim() || !onAddProperty) return
onAddProperty(rawKey.trim(), parseNewValue(rawValue))
setShowAddDialog(false)
}, [onAddProperty])
return (
<div>
<div className="flex flex-col gap-2">
{entry.isA && (
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
{onNavigate ? (
<button
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
style={{
background: getTypeLightColor(entry.isA),
color: getTypeColor(entry.isA),
borderRadius: 6,
padding: '2px 8px',
fontSize: 12,
fontWeight: 500,
}}
onClick={() => onNavigate(`type/${entry.isA!.toLowerCase()}`)}
title={entry.isA}
>
{entry.isA}
</button>
) : (
<span className="text-right text-[12px] text-secondary-foreground">{entry.isA}</span>
)}
</div>
)}
<TypeRow isA={entry.isA} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<div key={key} className="group/prop flex items-center justify-between">
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
{key}
{onDeleteProperty && (
<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={() => onDeleteProperty(key)} title="Delete property"
>
&times;
</button>
)}
</span>
{renderEditableValue(key, value)}
</div>
<PropertyRow key={key} propKey={key} value={value} editingKey={editingKey} onStartEdit={setEditingKey} onSave={handleSaveValue} onSaveList={handleSaveList} onUpdate={onUpdateProperty} onDelete={onDeleteProperty} />
))}
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">Modified</span>
<span className="text-right text-[12px] text-secondary-foreground">{formatDate(entry.modifiedAt)}</span>
</div>
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">Words</span>
<span className="text-right text-[12px] text-secondary-foreground">{wordCount}</span>
</div>
<StaticRow label="Modified" value={formatDate(entry.modifiedAt)} />
<StaticRow label="Words" value={String(wordCount)} />
</div>
{showAddDialog ? (
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleAddKeyDown} autoFocus
/>
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Value" value={newValue}
onChange={(e) => setNewValue(e.target.value)} onKeyDown={handleAddKeyDown}
/>
<div className="flex justify-end gap-2">
<Button size="xs" onClick={handleAddProperty} disabled={!newKey.trim()}>Add</Button>
<Button size="xs" variant="outline" onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</Button>
</div>
</div>
) : (
<button
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
onClick={() => setShowAddDialog(true)} disabled={!onAddProperty}
>
+ Add property
</button>
)}
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} />
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
}
</div>
)
}

View File

@@ -70,22 +70,22 @@ const TYPE_COLOR_MAP: Record<string, string> = {
purple: 'var(--accent-purple)',
}
function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
return entries.find(e => e.title === target || e.filename.replace(/\.md$/, '') === target)
}
function lookupColorForEntry(entries: VaultEntry[], entry: VaultEntry): string | undefined {
if (entry.isA === 'Type' && entry.color) return TYPE_COLOR_MAP[entry.color]
if (!entry.isA) return undefined
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === entry.isA)
return typeEntry?.color ? TYPE_COLOR_MAP[typeEntry.color] : undefined
}
function resolveWikilinkColor(target: string): string | undefined {
const entries = _wikilinkEntriesRef.current
if (!entries.length) return undefined
// Find the target entry by title or filename slug
const entry = entries.find(
e => e.title === target || e.filename.replace(/\.md$/, '') === target
)
if (!entry) return undefined
// If entry is itself a Type, use its own color
if (entry.isA === 'Type' && entry.color) return TYPE_COLOR_MAP[entry.color]
// Otherwise look up the type entry
if (entry.isA) {
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === entry.isA)
if (typeEntry?.color) return TYPE_COLOR_MAP[typeEntry.color]
}
return undefined
const entry = findEntryByTarget(entries, target)
return entry ? lookupColorForEntry(entries, entry) : undefined
}
const WikiLink = createReactInlineContentSpec(

View File

@@ -1,29 +1,12 @@
import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import { useMemo, useCallback } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import {
SlidersHorizontal, X, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple, Trash,
} from '@phosphor-icons/react'
import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
Experiment: Flask,
Responsibility: Target,
Procedure: ArrowsClockwise,
Person: Users,
Event: CalendarBlank,
Topic: Tag,
Type: StackSimple,
}
function getTypeIcon(isA: string | undefined): ComponentType<SVGAttributes<SVGSVGElement>> {
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
export type FrontmatterValue = string | number | boolean | string[] | null
interface InspectorProps {
collapsed: boolean
@@ -40,292 +23,38 @@ interface InspectorProps {
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
}
export type FrontmatterValue = string | number | boolean | string[] | null
/** Check if a string is a wikilink */
function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
/** Extract display name from a wikilink */
function wikilinkDisplay(ref: string): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
if (pipeIdx !== -1) return inner.slice(pipeIdx + 1)
const last = inner.split('/').pop() ?? inner
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Extract the target path for navigation from a wikilink ref */
function wikilinkTarget(ref: string): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
}
function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined {
const target = wikilinkTarget(ref)
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === target) return true
const fileStem = e.filename.replace(/\.md$/, '')
if (fileStem === target.split('/').pop()) return true
return false
})
}
function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string; refs: string[]; entries: VaultEntry[]; onNavigate: (target: string) => void }) {
if (refs.length === 0) return null
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? undefined
const isArchived = resolved?.archived ?? false
const isTrashed = resolved?.trashed ?? false
const isDimmed = isArchived || isTrashed
const color = refType ? getTypeColor(refType) : 'var(--accent-blue)'
const bgColor = refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)'
const TypeIcon = getTypeIcon(refType)
return (
<button
key={`${ref}-${idx}`}
className="flex items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80"
style={{
background: isDimmed ? 'var(--muted)' : bgColor,
color: isDimmed ? 'var(--muted-foreground)' : color,
borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500,
opacity: isDimmed ? 0.7 : 1,
}}
onClick={() => onNavigate(wikilinkTarget(ref))}
title={isTrashed ? 'Trashed' : isArchived ? 'Archived' : undefined}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{wikilinkDisplay(ref)}
{isTrashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{isArchived && !isTrashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />
</button>
)
})}
</div>
</div>
)
}
function DynamicRelationshipsPanel({
frontmatter, entries, onNavigate, onAddProperty,
}: {
frontmatter: ParsedFrontmatter
entries: VaultEntry[]
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}) {
const [showForm, setShowForm] = useState(false)
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const keyInputRef = useRef<HTMLInputElement>(null)
const relationshipEntries = useMemo(() => {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
return { key, refs }
})
.filter(({ refs }) => refs.length > 0)
}, [frontmatter])
// All note titles for datalist autocomplete
const noteTitles = useMemo(() => entries.map(e => e.title), [entries])
const handleAdd = useCallback(() => {
const key = relKey.trim()
const target = relTarget.trim()
if (!key || !target || !onAddProperty) return
onAddProperty(key, `[[${target}]]`)
setRelKey('')
setRelTarget('')
setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleAdd()
else if (e.key === 'Escape') { setShowForm(false); setRelKey(''); setRelTarget('') }
}
return (
<div>
{relationshipEntries.length === 0 ? (
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
) : (
relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup key={key} label={key} refs={refs} entries={entries} onNavigate={onNavigate} />
))
)}
{showForm ? (
<div className="mt-2 flex flex-col gap-1.5" onKeyDown={handleKeyDown}>
<datalist id="rel-note-titles">
{noteTitles.map(t => <option key={t} value={t} />)}
</datalist>
<input
ref={keyInputRef}
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name (e.g. Has, Related to)"
value={relKey}
onChange={e => setRelKey(e.target.value)}
/>
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
list="rel-note-titles"
value={relTarget}
onChange={e => setRelTarget(e.target.value)}
/>
<div className="flex gap-1.5">
<button
className="flex-1 border border-border bg-transparent text-xs text-foreground"
style={{ borderRadius: 4, padding: '4px 0' }}
onClick={handleAdd}
disabled={!relKey.trim() || !relTarget.trim()}
>
Add
</button>
<button
className="border border-border bg-transparent text-xs text-muted-foreground"
style={{ borderRadius: 4, padding: '4px 8px' }}
onClick={() => { setShowForm(false); setRelKey(''); setRelTarget('') }}
>
Cancel
</button>
</div>
</div>
) : (
<button
className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: onAddProperty ? 1 : 0.5, cursor: onAddProperty ? 'pointer' : 'not-allowed' }}
disabled={!onAddProperty}
onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}
>
+ Link existing
</button>
)}
</div>
)
}
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
return useMemo(() => {
if (!entry) return []
const title = entry.title
const targets = [entry.title, ...entry.aliases]
const stem = entry.filename.replace(/\.md$/, '')
const targets = [title, ...entry.aliases]
const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
return entries.filter((e) => {
if (e.path === entry.path) return false
const content = allContent[e.path]
if (!content) return false
for (const t of targets) {
if (content.includes(`[[${t}]]`)) return true
}
if (content.includes(`[[${stem}]]`)) return true
if (content.includes(`[[${pathStem}]]`)) return true
if (content.includes(`[[${pathStem}|`)) return true
return false
const c = allContent[e.path]
if (!c) return false
for (const t of targets) { if (c.includes(`[[${t}]]`)) return true }
return c.includes(`[[${stem}]]`) || c.includes(`[[${pathStem}]]`) || c.includes(`[[${pathStem}|`)
})
}, [entry, entries, allContent])
}
function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) {
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Backlinks {backlinks.length > 0 && <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>}
</h4>
{backlinks.length === 0 ? (
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
<div className="flex items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }} data-tauri-drag-region>
{collapsed ? (
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<SlidersHorizontal size={16} />
</button>
) : (
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => {
const color = e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)'
const TypeIcon = getTypeIcon(e.isA ?? undefined)
const isDimmed = e.archived || e.trashed
return (
<button
key={e.path}
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] cursor-pointer hover:opacity-80"
style={{ color: isDimmed ? 'var(--muted-foreground)' : color, opacity: isDimmed ? 0.7 : 1 }}
onClick={() => onNavigate(e.title)}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
>
<span className="flex items-center gap-1 flex-1 truncate">
{e.trashed && <Trash size={12} className="shrink-0" />}
{e.title}
{e.trashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
{e.archived && !e.trashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
</span>
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />}
</button>
)
})}
</div>
)}
</div>
)
}
function formatRelativeDate(timestamp: number): string {
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
if (diff < 86400) return 'today'
const days = Math.floor(diff / 86400)
if (days === 1) return 'yesterday'
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
if (months === 1) return '1mo ago'
return `${months}mo ago`
}
function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0 ? (
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
) : (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button
className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline"
style={{ fontSize: 11 }}
onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`}
>
{c.shortHash}
</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && (
<div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>
)}
</div>
))}
</div>
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<X size={16} />
</button>
</>
)}
</div>
)
@@ -334,12 +63,8 @@ function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[];
function EmptyInspector() {
return (
<>
<div>
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
</div>
<div>
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
</div>
<div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
<div><p className="m-0 text-[13px] text-muted-foreground">No relationships</p></div>
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">Backlinks</h4>
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
@@ -372,25 +97,8 @@ export function Inspector({
}, [entry, onAddProperty])
return (
<aside className={cn(
"flex flex-1 flex-col overflow-y-auto border-l border-border bg-background text-foreground transition-[width] duration-200",
collapsed && "!w-10 !min-w-10"
)}>
<div className="flex items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }} data-tauri-drag-region>
{collapsed ? (
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<SlidersHorizontal size={16} />
</button>
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<X size={16} />
</button>
</>
)}
</div>
<aside className={cn("flex flex-1 flex-col overflow-y-auto border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10")}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
{!collapsed && (
<div className="flex flex-col gap-4 p-3">
{entry ? (

View File

@@ -0,0 +1,253 @@
import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple, Trash,
} from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import type { FrontmatterValue } from './Inspector'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench, Experiment: Flask, Responsibility: Target,
Procedure: ArrowsClockwise, Person: Users, Event: CalendarBlank,
Topic: Tag, Type: StackSimple,
}
function getTypeIcon(isA: string | undefined): ComponentType<SVGAttributes<SVGSVGElement>> {
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
function wikilinkDisplay(ref: string): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
if (pipeIdx !== -1) return inner.slice(pipeIdx + 1)
const last = inner.split('/').pop() ?? inner
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function wikilinkTarget(ref: string): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
}
function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined {
const target = wikilinkTarget(ref)
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === target) return true
return e.filename.replace(/\.md$/, '') === target.split('/').pop()
})
}
function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) {
if (isTrashed) return <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>
if (isArchived) return <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>
return null
}
function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, title, TypeIcon }: {
label: string
typeColor: string
bgColor?: string
isArchived: boolean
isTrashed: boolean
onClick: () => void
title?: string
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
}) {
const isDimmed = isArchived || isTrashed
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
return (
<button
className="flex items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80"
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>
<TypeIcon width={14} height={14} className="shrink-0" style={{ color }} />
</button>
)
}
function entryStatusTitle(entry: VaultEntry | undefined): string | undefined {
if (entry?.trashed) return 'Trashed'
if (entry?.archived) return 'Archived'
return undefined
}
function resolveRefProps(ref: string, entries: VaultEntry[]) {
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? undefined
return {
label: wikilinkDisplay(ref),
typeColor: refType ? getTypeColor(refType) : 'var(--accent-blue)',
bgColor: refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)',
isArchived: resolved?.archived ?? false,
isTrashed: resolved?.trashed ?? false,
target: wikilinkTarget(ref),
title: entryStatusTitle(resolved),
TypeIcon: getTypeIcon(refType),
}
}
function RelationshipGroup({ label, refs, entries, onNavigate }: {
label: string; refs: string[]; entries: VaultEntry[]; onNavigate: (target: string) => void
}) {
if (refs.length === 0) return null
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries)
return <LinkButton key={`${ref}-${idx}`} {...props} onClick={() => onNavigate(props.target)} />
})}
</div>
</div>
)
}
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
return { key, refs }
})
.filter(({ refs }) => refs.length > 0)
}
function AddRelationshipForm({ entries, onAddProperty }: {
entries: VaultEntry[]
onAddProperty: (key: string, value: FrontmatterValue) => void
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const [showForm, setShowForm] = useState(false)
const keyInputRef = useRef<HTMLInputElement>(null)
const noteTitles = useMemo(() => entries.map(e => e.title), [entries])
const handleAdd = useCallback(() => {
const key = relKey.trim()
const target = relTarget.trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const resetForm = () => { setShowForm(false); setRelKey(''); setRelTarget('') }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleAdd()
else if (e.key === 'Escape') resetForm()
}
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Link existing</button>
)
}
return (
<div className="mt-2 flex flex-col gap-1.5" onKeyDown={handleKeyDown}>
<datalist id="rel-note-titles">{noteTitles.map(t => <option key={t} value={t} />)}</datalist>
<input ref={keyInputRef} autoFocus className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground" style={{ borderRadius: 4, outline: 'none' }} placeholder="Relationship name" value={relKey} onChange={e => setRelKey(e.target.value)} />
<input className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground" style={{ borderRadius: 4, outline: 'none' }} placeholder="Note title" list="rel-note-titles" value={relTarget} onChange={e => setRelTarget(e.target.value)} />
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={handleAdd} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
</div>
</div>
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, onNavigate, onAddProperty }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; onNavigate: (target: string) => void; onAddProperty?: (key: string, value: FrontmatterValue) => void
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
return (
<div>
{relationshipEntries.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
: relationshipEntries.map(({ key, refs }) => <RelationshipGroup key={key} label={key} refs={refs} entries={entries} onNavigate={onNavigate} />)
}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>
)
}
export function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Backlinks {backlinks.length > 0 && <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>}
</h4>
{backlinks.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
: (
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => (
<LinkButton key={e.path} label={e.title} typeColor={e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)'} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA ?? undefined)} />
))}
</div>
)
}
</div>
)
}
function formatRelativeDate(timestamp: number): string {
const now = Math.floor(Date.now() / 1000)
const days = Math.floor((now - timestamp) / 86400)
if (days < 1) return 'today'
if (days === 1) return 'yesterday'
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return months === 1 ? '1mo ago' : `${months}mo ago`
}
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
: (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
</div>
))}
</div>
)
}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import type { ComponentType, SVGAttributes } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from './TypeCustomizePopover'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
Experiment: Flask,
Responsibility: Target,
Procedure: ArrowsClockwise,
Person: Users,
Event: CalendarBlank,
Topic: Tag,
Type: StackSimple,
}
export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (customIcon) return resolveIcon(customIcon)
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
function TrashDateLine({ entry }: { entry: VaultEntry }) {
const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0
const isExpired = trashedAge >= 86400 * 30
const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined
const suffix = isExpired ? ' — will be permanently deleted' : ''
return (
<div className="mt-0.5 text-[10px] text-muted-foreground" style={style}>
Trashed {relativeDate(entry.trashedAt)}{suffix}
</div>
)
}
export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
entry: VaultEntry
isSelected: boolean
typeEntryMap: Record<string, VaultEntry>
onSelectNote: (entry: VaultEntry) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = getTypeIcon(entry.isA, te?.icon)
return (
<div
className={cn(
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
isSelected && "border-l-[3px]",
!isSelected && "hover:bg-muted"
)}
style={{
padding: isSelected ? '14px 16px 14px 13px' : '14px 16px',
...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
}}
onClick={() => onSelectNote(entry)}
>
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{entry.title}
{entry.archived && (
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
ARCHIVED
</span>
)}
{entry.trashed && (
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
TRASHED
</span>
)}
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" 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

@@ -276,6 +276,8 @@ describe('getSortComparator', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 100,
@@ -352,6 +354,8 @@ describe('NoteList sort controls', () => {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 100,
@@ -486,6 +490,7 @@ const trashedEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const expiredTrashedEntry: VaultEntry = {
@@ -509,6 +514,7 @@ const expiredTrashedEntry: VaultEntry = {
relationships: {},
icon: null,
color: null,
order: null,
}
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]

View File

@@ -1,32 +1,23 @@
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
// Virtuoso removed — flat list rendering used instead
import { useState, useMemo, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
ArrowsDownUp, Check, Warning,
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
} from '@phosphor-icons/react'
import type { ComponentType, SVGAttributes } from 'react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from './TypeCustomizePopover'
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import {
type SortOption, type RelationshipGroup,
getSortComparator,
buildRelationshipGroups, filterEntries,
sortByModified, relativeDate, getDisplayDate,
loadSortPreferences, saveSortPreferences,
} from '../utils/noteListHelpers'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
Experiment: Flask,
Responsibility: Target,
Procedure: ArrowsClockwise,
Person: Users,
Event: CalendarBlank,
Topic: Tag,
Type: StackSimple,
}
function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (customIcon) return resolveIcon(customIcon)
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
// Re-export for consumers
export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator }
export type { SortOption }
interface NoteListProps {
entries: VaultEntry[]
@@ -38,660 +29,234 @@ interface NoteListProps {
onCreateNote: () => void
}
interface RelationshipGroup {
label: string
entries: VaultEntry[]
}
function relativeDate(ts: number | null): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 0) {
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function getDisplayDate(entry: VaultEntry): number | null {
return entry.modifiedAt ?? entry.createdAt
}
function refsMatch(refs: string[], entry: VaultEntry): boolean {
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const fileStem = entry.filename.replace(/\.md$/, '')
return refs.some((ref) => {
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
const inner = raw.split('|')[0]
return inner === stem || inner.split('/').pop() === fileStem
})
}
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs
.map((ref) => {
// Strip [[ ]] and remove alias (|display text) if present
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
const inner = raw.split('|')[0]
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === inner) return true
const fileStem = e.filename.replace(/\.md$/, '')
if (fileStem === inner.split('/').pop()) return true
return false
})
})
.filter((e): e is VaultEntry => e !== undefined)
}
export function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'title', label: 'Title' },
{ value: 'status', label: 'Status' },
]
const STATUS_ORDER: Record<string, number> = {
Active: 0,
Paused: 1,
Done: 2,
Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
switch (option) {
case 'modified':
return sortByModified
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
case 'title':
return (a, b) => a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
function loadSortPreferences(): Record<string, SortOption> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function saveSortPreferences(prefs: Record<string, SortOption>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const targets = [entity.title, ...entity.aliases]
return allEntries.filter((e) => {
if (e.path === entity.path) return false
const content = allContent[e.path]
if (!content) return false
for (const t of targets) {
if (content.includes(`[[${t}]]`)) return true
}
if (content.includes(`[[${stem}]]`)) return true
if (content.includes(`[[${pathStem}]]`)) return true
if (content.includes(`[[${pathStem}|`)) return true
return false
})
}
function addGroup(
groups: RelationshipGroup[],
label: string,
entries: VaultEntry[],
seen: Set<string>,
) {
const unseen = entries.filter((e) => !seen.has(e.path))
if (unseen.length > 0) {
groups.push({ label, entries: unseen })
unseen.forEach((e) => seen.add(e.path))
}
}
export function buildRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
allContent: Record<string, string>,
): RelationshipGroup[] {
const groups: RelationshipGroup[] = []
const seen = new Set<string>([entity.path])
const rels = entity.relationships ?? {}
// 0. "Instances" — for type documents, show all entries of this type
if (entity.isA === 'Type') {
const instances = allEntries
.filter((e) => e.isA === entity.title && !seen.has(e.path))
.sort(sortByModified)
addGroup(groups, 'Instances', instances, seen)
}
// 1. "Has" — from the entity's own relationships map
const hasRefs = rels['Has'] ?? []
if (hasRefs.length > 0) {
addGroup(groups, 'Has', resolveRefs(hasRefs, allEntries).sort(sortByModified), seen)
}
// 2. Children — entries whose belongsTo points to this entity (reverse lookup, excluding events)
const children = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
.sort(sortByModified)
addGroup(groups, 'Children', children, seen)
// 3. Events — entities of type Event that reference this entity via belongsTo/relatedTo
const events = allEntries
.filter(
(e) =>
!seen.has(e.path) &&
e.isA === 'Event' &&
(refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity))
)
.sort(sortByModified)
addGroup(groups, 'Events', events, seen)
// 4. "Topics" — from the entity's own relationships map
const topicRefs = rels['Topics'] ?? []
if (topicRefs.length > 0) {
addGroup(groups, 'Topics', resolveRefs(topicRefs, allEntries).sort(sortByModified), seen)
}
// 5. All other generic relationship fields (alphabetically)
const handledKeys = new Set(['Has', 'Topics'])
const otherKeys = Object.keys(rels)
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
for (const key of otherKeys) {
const refs = rels[key]
if (refs && refs.length > 0) {
addGroup(groups, key, resolveRefs(refs, allEntries).sort(sortByModified), seen)
}
}
// 6. Referenced By — entries that reference this entity via relatedTo (reverse lookup)
const referencedBy = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
.sort(sortByModified)
addGroup(groups, 'Referenced By', referencedBy, seen)
// 7. Backlinks — always last
const backlinks = findBacklinks(entity, allEntries, allContent)
.filter((e) => !seen.has(e.path))
.sort(sortByModified)
addGroup(groups, 'Backlinks', backlinks, seen)
return groups
}
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] {
switch (selection.kind) {
case 'filter':
switch (selection.filter) {
case 'all':
return entries.filter((e) => !e.archived && !e.trashed)
case 'favorites':
return []
case 'archived':
return entries.filter((e) => e.archived && !e.trashed)
case 'trash':
return entries.filter((e) => e.trashed)
}
break
case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed)
case 'entity':
return []
case 'topic': {
const topic = selection.entry
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed)
}
}
}
function SortDropdown({
groupLabel,
current,
onChange,
}: {
groupLabel: string
current: SortOption
onChange: (groupLabel: string, option: SortOption) => void
function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
entry: VaultEntry
typeEntryMap: Record<string, VaultEntry>
onSelectNote: (entry: VaultEntry) => void
showDate?: boolean
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = getTypeIcon(entry.isA, te?.icon)
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn(
"flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent",
open && "bg-accent text-foreground"
)}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${current}`}
data-testid={`sort-button-${groupLabel}`}
>
<ArrowsDownUp size={12} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 130, padding: 4 }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent",
opt.value === current && "bg-accent font-medium"
)}
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
onClick={(e) => {
e.stopPropagation()
onChange(groupLabel, opt.value)
setOpen(false)
}}
data-testid={`sort-option-${opt.value}`}
>
{opt.value === current
? <Check size={12} />
: <span style={{ width: 12, height: 12, display: 'inline-block' }} />
}
{opt.label}
</button>
))}
</div>
)}
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
</div>
)
}
function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
isCollapsed: boolean
sortPrefs: Record<string, SortOption>
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption) => void
renderItem: (entry: VaultEntry) => React.ReactNode
}) {
const groupSort = sortPrefs[group.label] ?? 'modified'
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
<button className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0" onClick={onToggle}>
<span className="font-mono-label text-muted-foreground">{group.label}</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupSort} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
</span>
</div>
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
</div>
)
}
function TrashWarningBanner({ expiredCount }: { expiredCount: number }) {
if (expiredCount === 0) return null
return (
<div className="flex items-start gap-2 border-b border-[var(--border)]" style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}>
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>Notes in trash for 30+ days will be permanently deleted</div>
<div className="text-muted-foreground" style={{ fontSize: 11 }}>{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period</div>
</div>
</div>
)
}
function EmptyMessage({ text }: { text: string }) {
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>
}
function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
if (selection.kind === 'entity') return selection.entry.title
if (typeDocument) return typeDocument.title
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
return 'Notes'
}
function useTypeEntryMap(entries: VaultEntry[]) {
return useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
}
// --- View sub-components ---
function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortOption>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
}) {
return (
<div className="h-full overflow-y-auto">
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} showDate />
{groups.length === 0
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
: groups.map((group) => (
<RelationshipGroupSection key={group.label} group={group} isCollapsed={collapsedGroups.has(group.label)} sortPrefs={sortPrefs} onToggle={() => onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} />
))
}
</div>
)
}
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onSelectNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
}) {
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
return (
<div className="h-full overflow-y-auto">
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
{searched.length === 0
? <EmptyMessage text={emptyText} />
: searched.map((entry) => renderItem(entry))
}
</div>
)
}
// --- Pure helpers ---
function filterByQuery<T extends { title: string }>(items: T[], query: string): T[] {
return query ? items.filter((e) => e.title.toLowerCase().includes(query)) : items
}
function filterGroupsByQuery(groups: RelationshipGroup[], query: string): RelationshipGroup[] {
if (!query) return groups
return groups.map((g) => ({ ...g, entries: filterByQuery(g.entries, query) })).filter((g) => g.entries.length > 0)
}
function countExpiredTrash(entries: VaultEntry[]): number {
const now = Date.now() / 1000
return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
}
// --- Data hooks ---
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; modifiedFiles?: ModifiedFile[]
}
function useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const searched = useMemo(() => {
if (isEntityView) return []
const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort))
return filterByQuery(sorted, query)
}, [entries, selection, modifiedFiles, isEntityView, listSort, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, allContent, query])
const expiredTrashCount = useMemo(
() => isTrashView ? countExpiredTrash(searched) : 0,
[isTrashView, searched],
)
return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount }
}
// --- Main component ---
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [sortPrefs, setSortPrefs] = useState<Record<string, SortOption>>(loadSortPreferences)
const isEntityView = selection.kind === 'entity'
const isSectionGroup = selection.kind === 'sectionGroup'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
setSortPrefs((prev) => {
const next = { ...prev, [groupLabel]: option }
saveSortPreferences(next)
return next
})
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: option }; saveSortPreferences(next); return next })
}, [])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(label)) next.delete(label)
else next.add(label)
return next
})
setCollapsedGroups((prev) => { const next = new Set(prev); next.has(label) ? next.delete(label) : next.add(label); return next })
}, [])
// Build type entry map for custom icon/color lookup
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
// Find the type document for this section group (e.g., type/project.md for "Project")
const typeDocument = useMemo(() => {
if (!isSectionGroup) return null
const typeName = (selection as { kind: 'sectionGroup'; type: string }).type
return entries.find((e) => e.isA === 'Type' && e.title === typeName) ?? null
}, [isSectionGroup, selection, entries])
const entityGroups = useMemo(
() => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [],
[isEntityView, selection, entries, allContent]
)
const filtered = useMemo(
() => isEntityView ? [] : filterEntries(entries, selection, modifiedFiles),
[entries, selection, modifiedFiles, isEntityView]
)
const listSort = sortPrefs['__list__'] ?? 'modified'
const sorted = useMemo(
() => isEntityView ? [] : [...filtered].sort(getSortComparator(listSort)),
[filtered, isEntityView, listSort]
)
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listSort = sortPrefs['__list__'] ?? 'modified'
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles })
const searched = useMemo(
() => query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted,
[sorted, query]
)
const searchedGroups = useMemo(
() => query
? entityGroups
.map((g) => ({
...g,
entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)),
}))
.filter((g) => g.entries.length > 0)
: entityGroups,
[entityGroups, query]
)
const expiredTrashCount = useMemo(() => {
if (!isTrashView) return 0
const now = Date.now() / 1000
return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
}, [isTrashView, searched])
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
const isSelected = selectedNote?.path === entry.path && !isPinned
const te = typeEntryMap[entry.isA ?? '']
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = getTypeIcon(entry.isA, te?.icon)
return (
<div
key={entry.path}
className={cn(
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
isPinned && "border-l-[3px] border-l-[var(--accent-green)] bg-muted",
isSelected && "border-l-[3px]",
!isPinned && !isSelected && "hover:bg-muted"
)}
style={{
padding: isPinned || isSelected ? '14px 16px 14px 13px' : '14px 16px',
...(isSelected && {
borderLeftColor: typeColor,
backgroundColor: typeLightColor,
}),
}}
onClick={() => onSelectNote(entry)}
>
<TypeIcon
width={14}
height={14}
className="absolute right-3 top-2.5"
style={{ color: typeColor }}
data-testid="type-icon"
/>
<div className="pr-5">
<div className={cn(
"truncate text-[13px] text-foreground",
isSelected ? "font-semibold" : "font-medium"
)}>
{entry.title}
{entry.archived && (
<span
className="ml-1.5 inline-block align-middle text-muted-foreground"
style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
>
ARCHIVED
</span>
)}
{entry.trashed && (
<span
className="ml-1.5 inline-block align-middle"
style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
>
TRASHED
</span>
)}
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground" style={entry.trashed && entry.trashedAt && (Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}>
{entry.trashed && entry.trashedAt
? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}`
: relativeDate(getDisplayDate(entry))}
</div>
</div>
)
}, [selectedNote?.path, onSelectNote, typeEntryMap])
const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => {
const ete = typeEntryMap[entity.isA ?? '']
const entityTypeColor = getTypeColor(entity.isA ?? '', ete?.color)
const entityLightColor = getTypeLightColor(entity.isA ?? '', ete?.color)
const EntityIcon = getTypeIcon(entity.isA, ete?.icon)
return (
<div className="h-full overflow-y-auto">
{/* Prominent card */}
<div
className="relative cursor-pointer border-b border-[var(--border)]"
style={{ backgroundColor: entityLightColor, padding: '14px 16px' }}
onClick={() => onSelectNote(entity)}
>
<EntityIcon
width={16}
height={16}
className="absolute right-3 top-3.5"
style={{ color: entityTypeColor }}
data-testid="type-icon"
/>
<div className="pr-6 text-[14px] font-bold" style={{ color: entityTypeColor }}>
{entity.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: entityTypeColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entity.snippet}
</div>
<div className="mt-1 text-[11px] opacity-60" style={{ color: entityTypeColor }}>
{relativeDate(getDisplayDate(entity))}
</div>
</div>
{/* Relationship groups */}
{groups.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{query ? 'No matching items' : 'No related items'}
</div>
) : (
groups.map((group) => {
const isGroupCollapsed = collapsedGroups.has(group.label)
const groupSort = sortPrefs[group.label] ?? 'modified'
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
return (
<div key={group.label}>
<div
className="flex w-full items-center justify-between bg-muted"
style={{ height: 32, padding: '0 16px' }}
>
<button
className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0"
onClick={() => toggleGroup(group.label)}
>
<span className="font-mono-label text-muted-foreground">
{group.label}
</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown
groupLabel={group.label}
current={groupSort}
onChange={handleSortChange}
/>
<button
className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground"
onClick={() => toggleGroup(group.label)}
>
{isGroupCollapsed
? <CaretRight size={12} />
: <CaretDown size={12} />
}
</button>
</span>
</div>
{!isGroupCollapsed && sortedEntries.map((groupEntry) => renderItem(groupEntry))}
</div>
)
})
)}
</div>
)
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem, typeEntryMap, sortPrefs, handleSortChange])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
), [selectedNote?.path, onSelectNote, typeEntryMap])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
{/* Header */}
<div className="flex h-[45px] shrink-0 items-center justify-between border-b border-border px-4" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">
{isEntityView
? selection.entry.title
: typeDocument
? typeDocument.title
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived'
? 'Archive'
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash'
? 'Trash'
: 'Notes'}
</h3>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && (
<SortDropdown
groupLabel="__list__"
current={listSort}
onChange={handleSortChange}
/>
)}
<button
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }}
title="Search notes"
>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
<MagnifyingGlass size={16} />
</button>
<button
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
onClick={onCreateNote}
title="Create new note"
>
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onCreateNote} title="Create new note">
<Plus size={16} />
</button>
</div>
</div>
{/* Search (toggle on icon click) */}
{searchVisible && (
<div className="border-b border-border px-3 py-2">
<Input
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-[13px]"
autoFocus
/>
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
</div>
)}
{/* Items */}
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{isEntityView ? (() => {
const entity = selection.entry
return renderPinnedView(entity, searchedGroups)
})() : (
<div className="h-full overflow-y-auto">
{/* Type document pinned card (for sectionGroup view) */}
{typeDocument && (() => {
const tde = typeEntryMap[typeDocument.title] ?? typeDocument
const tdColor = getTypeColor(typeDocument.isA ?? 'Type', tde?.color)
const tdLightColor = getTypeLightColor(typeDocument.isA ?? 'Type', tde?.color)
const TDIcon = getTypeIcon(typeDocument.isA, tde?.icon)
return (
<div
className="relative cursor-pointer border-b border-[var(--border)]"
style={{ backgroundColor: tdLightColor, padding: '14px 16px' }}
onClick={() => onSelectNote(typeDocument)}
>
<TDIcon
width={16}
height={16}
className="absolute right-3 top-3.5"
style={{ color: tdColor }}
data-testid="type-icon"
/>
<div className="pr-6 text-[14px] font-bold" style={{ color: tdColor }}>
{typeDocument.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: tdColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{typeDocument.snippet}
</div>
</div>
)
})()}
{/* 30-day warning banner for trash view */}
{isTrashView && expiredTrashCount > 0 && (
<div
className="flex items-start gap-2 border-b border-[var(--border)]"
style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}
>
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>
Notes in trash for 30+ days will be permanently deleted
</div>
<div className="text-muted-foreground" style={{ fontSize: 11 }}>
{expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
</div>
</div>
</div>
)}
{searched.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{isTrashView ? 'Trash is empty' : 'No notes found'}
</div>
) : (
searched.map((entry) => renderItem(entry))
)}
</div>
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
) : (
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
)}
</div>
</div>

View File

@@ -571,19 +571,19 @@ describe('Sidebar', () => {
{
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: '',
archived: false, trashed: false, trashedAt: null, 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: '',
archived: false, trashed: false, trashedAt: null, 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: '',
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
relationships: {}, icon: null, color: null, order: 1,
},
]

View File

@@ -1,42 +1,24 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
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,
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText,
Star,
Wrench,
Flask,
Target,
ArrowsClockwise,
Users,
CalendarBlank,
Tag,
TagSimple,
Trash,
StackSimple,
Archive,
type IconProps,
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
type SectionGroup, isSelectionActive,
NavItem, SectionContent, type SectionContentProps, VisibilityPopover,
} from './SidebarParts'
interface SidebarProps {
entries: VaultEntry[]
@@ -51,18 +33,6 @@ interface SidebarProps {
onCommitPush?: () => void
}
const TOP_NAV = [
{ label: 'All Notes', filter: 'all' as const, Icon: FileText },
{ label: 'Favorites', filter: 'favorites' as const, Icon: Star },
]
interface SectionGroup {
label: string
type: string
Icon: ComponentType<IconProps>
customColor?: string | null
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
@@ -76,528 +46,262 @@ 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, onReorderSections, modifiedCount = 0, onCommitPush }: SidebarProps) {
// --- Hooks ---
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
useEffect(() => {
if (!isOpen) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [isOpen, onClose])
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
const map: Record<string, VaultEntry> = {}
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
return map
}
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const te = typeEntryMap[sg.type]
if (!te?.icon && !te?.color) return sg
return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null }
})
}
function buildCustomSections(entries: VaultEntry[]): SectionGroup[] {
return entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
.sort((a, b) => a.title.localeCompare(b.title))
.map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color }))
}
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return [...groups].sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
})
}
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const built = applyOverrides(typeEntryMap)
const custom = buildCustomSections(entries)
return sortSections([...built, ...custom], typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
}
function useEntryCounts(entries: VaultEntry[]) {
return useMemo(() => {
let active = 0, archived = 0, trashed = 0
for (const e of entries) {
if (e.trashed) trashed++
else if (e.archived) archived++
else active++
}
return { activeCount: active, archivedCount: archived, trashedCount: trashed }
}, [entries])
}
function computeReorder(sectionIds: string[], activeId: string, overId: string): string[] | null {
const oldIndex = sectionIds.indexOf(activeId)
const newIndex = sectionIds.indexOf(overId)
if (oldIndex === -1 || newIndex === -1) return null
const reordered = [...sectionIds]
reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, activeId)
return reordered
}
function buildCustomizeArgs(typeEntry: VaultEntry, prop: 'icon' | 'color', value: string): [string, string] {
return [
prop === 'icon' ? value : (typeEntry.icon ?? 'file-text'),
prop === 'color' ? value : (typeEntry.color ?? 'blue'),
]
}
// --- Sub-components ---
function SortableSection({ group, sectionProps }: {
group: SectionGroup
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'dragHandleProps' | 'onToggle'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
const isCollapsed = sectionProps.collapsed[group.type] ?? true
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
<SectionContent
group={group} items={items} isCollapsed={isCollapsed}
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
onToggle={() => sectionProps.onToggle(group.type)}
dragHandleProps={listeners}
/>
</div>
)
}
function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) {
if (!onClick) return null
return (
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
<button className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }} onClick={onClick}>
<GitCommitHorizontal size={14} />
<span className="text-[13px] font-medium">Commit & Push</span>
{modifiedCount > 0 && (
<span className="text-white font-semibold" style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}>{modifiedCount}</span>
)}
</button>
</div>
)
}
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
pos: { x: number; y: number } | null; type: string | null
innerRef: React.Ref<HTMLDivElement>
onOpenCustomize: (type: string) => void
}) {
if (!pos || !type) return null
return (
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
<button className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" onClick={() => onOpenCustomize(type)}>
Customize icon & color
</button>
</div>
)
}
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
target: string | null; typeEntryMap: Record<string, VaultEntry>
innerRef: React.Ref<HTMLDivElement>
onCustomize: (prop: 'icon' | 'color', value: string) => void
onClose: () => void
}) {
if (!target) return null
return (
<div ref={innerRef} className="fixed z-50" style={{ left: 20, top: 100 }}>
<TypeCustomizePopover
currentIcon={typeEntryMap[target]?.icon ?? null}
currentColor={typeEntryMap[target]?.color ?? null}
onChangeIcon={(icon) => onCustomize('icon', icon)}
onChangeColor={(color) => onCustomize('color', color)}
onClose={onClose}
/>
</div>
)
}
// --- Main Sidebar ---
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)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [showCustomize, setShowCustomize] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const [showCustomize, setShowCustomize] = useState(false)
const customizeRef = useRef<HTMLDivElement>(null)
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
// Close section-visibility popover on outside click
useEffect(() => {
if (!showCustomize) return
const handleClick = (e: MouseEvent) => {
if (customizeRef.current && !customizeRef.current.contains(e.target as Node)) {
setShowCustomize(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showCustomize])
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
const closeCustomize = useCallback(() => setShowCustomize(false), [])
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
const toggleSection = (type: string) => {
useOutsideClick(customizeRef, showCustomize, closeCustomize)
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
const toggleSection = useCallback((type: string) => {
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
}
}, [])
// Build a map of type name → type entry for quick lookup of icon/color
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
const isActive = (sel: SidebarSelection): boolean => {
if (selection.kind !== sel.kind) return false
if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter
if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type
if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path
if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path
return false
}
// Derive custom type sections from Type entries not in the built-in list
const customSectionGroups: SectionGroup[] = useMemo(() => {
return entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
.sort((a, b) => a.title.localeCompare(b.title))
.map((e) => ({
label: e.title + 's',
type: e.title,
Icon: resolveIcon(e.icon),
customColor: e.color,
}))
}, [entries])
// For built-in types, check if they have custom icon/color overrides from their type entry
const builtInWithOverrides: SectionGroup[] = useMemo(() => {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const typeEntry = typeEntryMap[sg.type]
if (!typeEntry?.icon && !typeEntry?.color) return sg
return {
...sg,
Icon: typeEntry?.icon ? resolveIcon(typeEntry.icon) : sg.Icon,
customColor: typeEntry?.color ?? null,
}
})
}, [typeEntryMap])
// 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)
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, i) => ({ typeName, order: i })))
}, [sectionIds, onReorderSections])
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
// Close context menu on outside click
useEffect(() => {
if (!contextMenuPos) return
const handler = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuPos(null)
setContextMenuType(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [contextMenuPos])
// Close customize popover on outside click
useEffect(() => {
if (!customizeTarget) return
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setCustomizeTarget(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [customizeTarget])
const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPos({ x: e.clientX, y: e.clientY })
setContextMenuType(type)
e.preventDefault(); e.stopPropagation()
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
}, [])
const openCustomizePopover = useCallback((type: string) => {
setContextMenuPos(null)
setContextMenuType(null)
setCustomizeTarget(type)
}, [])
const handleCustomizeIcon = useCallback((icon: string) => {
if (!customizeTarget) return
const typeEntry = typeEntryMap[customizeTarget]
if (typeEntry && onCustomizeType) {
onCustomizeType(customizeTarget, icon, typeEntry.color ?? 'blue')
}
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
if (!customizeTarget || !onCustomizeType) return
const te = typeEntryMap[customizeTarget]
if (!te) return
const [icon, color] = buildCustomizeArgs(te, prop, value)
onCustomizeType(customizeTarget, icon, color)
}, [customizeTarget, typeEntryMap, onCustomizeType])
const handleCustomizeColor = useCallback((color: string) => {
if (!customizeTarget) return
const typeEntry = typeEntryMap[customizeTarget]
if (typeEntry && onCustomizeType) {
onCustomizeType(customizeTarget, typeEntry.icon ?? 'file-text', color)
}
}, [customizeTarget, typeEntryMap, onCustomizeType])
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'
const isTypeSection = type === 'Type'
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
const handlePlusClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (isTypeSection) {
onCreateNewType?.()
} else {
onCreateType?.(type)
}
}
return (
<>
{/* Section header row */}
<div
className={cn(
"group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors",
isActive({ kind: 'sectionGroup', type })
? "bg-secondary"
: "hover:bg-accent"
)}
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: 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" style={{ marginLeft: 4 }}>{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{(onCreateType || (isTypeSection && onCreateNewType)) && (
<button
className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer"
style={{ width: 20, height: 20 }}
onClick={handlePlusClick}
aria-label={isTypeSection ? 'Create new Type' : `Create new ${type}`}
title={isTypeSection ? 'New Type' : `New ${type}`}
>
<Plus size={14} />
</button>
)}
<button
className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
toggleSection(type)
}}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
</div>
</div>
{/* Children items */}
{!isCollapsed && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map((entry) => (
<div
key={entry.path}
className={cn(
"cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors",
isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
? "text-foreground"
: "text-muted-foreground hover:bg-accent"
)}
style={{
padding: '4px 16px 4px 28px',
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
backgroundColor: sectionLightColor,
color: sectionColor,
}),
}}
onClick={() => {
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
onSelectNote?.(entry)
}}
>
{entry.title}
</div>
))}
</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>
)
const sectionProps = {
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onContextMenu: handleContextMenu, onToggle: toggleSection,
}
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground" style={{ paddingTop: 38 } as React.CSSProperties}>
{/* Native macOS title bar on top */}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto">
{/* Top nav — All Notes + Favorites */}
{/* Top nav */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
{TOP_NAV.map(({ label, filter, Icon }) => {
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
return (
<div
key={filter}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter })
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter })}
>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
{count > 0 && (
<span
className="flex items-center justify-center bg-primary text-primary-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10 }}
>
{count}
</span>
)}
</div>
)
})}
{/* Archive filter */}
<div
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'archived' })
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'archived' })}
>
<Archive size={16} />
<span className="flex-1 text-[13px] font-medium">Archive</span>
{archivedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{archivedCount}
</span>
)}
</div>
{/* Disabled placeholders */}
<div
className="flex select-none items-center gap-2 rounded text-foreground"
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
>
<TagSimple size={16} />
<span className="flex-1 text-[13px] font-medium">Untagged</span>
</div>
{/* Trash filter */}
<div
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
isActive({ kind: 'filter', filter: 'trash' })
? "bg-destructive/10 text-destructive"
: "text-foreground hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
>
<Trash size={16} />
<span className="flex-1 text-[13px] font-medium">Trash</span>
{trashedCount > 0 && (
<span
className="flex items-center justify-center text-muted-foreground"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{trashedCount}
</span>
)}
</div>
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={TagSimple} label="Untagged" disabled />
<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)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
</div>
{/* Sections header + customize popover */}
{/* 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' }}
>
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections</span>
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={() => setShowCustomize((v) => !v)}
aria-label="Customize sections"
title="Customize sections"
>
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
<SlidersHorizontal size={14} />
</button>
</div>
{showCustomize && (
<div
className="border border-border bg-popover text-popover-foreground"
style={{
position: 'absolute',
top: '100%',
left: 6,
right: 6,
zIndex: 50,
borderRadius: 8,
padding: '8px 0',
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
}}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>
Show in sidebar
</div>
{allSectionGroups.map(({ label, type, Icon }) => (
<button
key={type}
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => toggleVisibility(type)}
aria-label={`Toggle ${label}`}
>
<Icon size={14} style={{ color: getTypeColor(type) }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<div
className="flex items-center"
style={{
width: 32,
height: 18,
borderRadius: 9,
padding: 2,
backgroundColor: isSectionVisible(type) ? 'var(--primary)' : 'var(--muted)',
justifyContent: isSectionVisible(type) ? 'flex-end' : 'flex-start',
transition: 'background-color 150ms',
}}
>
<div
style={{
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: 'white',
transition: 'transform 150ms',
}}
/>
</div>
</button>
))}
</div>
)}
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
</div>
{/* Section Groups (built-in + custom), filtered by visibility, sortable by drag */}
{/* Sortable section groups */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((g) => (
<SortableSection key={g.type} group={g} />
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
</nav>
{/* Commit button — always visible */}
{onCommitPush && (
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
<button
className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }}
onClick={onCommitPush}
>
<GitCommitHorizontal size={14} />
<span className="text-[13px] font-medium">Commit & Push</span>
{modifiedCount > 0 && (
<span
className="text-white font-semibold"
style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}
>
{modifiedCount}
</span>
)}
</button>
</div>
)}
{/* Context menu (right-click on section header) */}
{contextMenuPos && contextMenuType && (
<div
ref={contextMenuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: contextMenuPos.x, top: contextMenuPos.y, minWidth: 180 }}
>
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
onClick={() => openCustomizePopover(contextMenuType)}
>
Customize icon & color
</button>
</div>
)}
{/* Customize popover */}
{customizeTarget && (
<div
ref={popoverRef}
className="fixed z-50"
style={{ left: 20, top: 100 }}
>
<TypeCustomizePopover
currentIcon={typeEntryMap[customizeTarget]?.icon ?? null}
currentColor={typeEntryMap[customizeTarget]?.color ?? null}
onChangeIcon={handleCustomizeIcon}
onChangeColor={handleCustomizeColor}
onClose={() => setCustomizeTarget(null)}
/>
</div>
)}
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
</aside>
)
})

View File

@@ -0,0 +1,227 @@
import { type ComponentType } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
export interface SectionGroup {
label: string
type: string
Icon: ComponentType<IconProps>
customColor?: string | null
}
export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean {
if (current.kind !== check.kind) return false
switch (check.kind) {
case 'filter': return (current as typeof check).filter === check.filter
case 'sectionGroup': return (current as typeof check).type === check.type
case 'entity':
case 'topic': return (current as typeof check).entry.path === check.entry.path
default: return false
}
}
// --- NavItem ---
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled }: {
icon: ComponentType<IconProps>
label: string
count?: number
isActive?: boolean
activeClassName?: string
badgeClassName?: string
badgeStyle?: React.CSSProperties
onClick?: () => void
disabled?: boolean
}) {
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title="Coming soon">
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
</div>
)
}
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding: '6px 16px', borderRadius: 4 }}
onClick={onClick}
>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
{count !== undefined && count > 0 && (
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
{count}
</span>
)}
</div>
)
}
// --- Section Content ---
export interface SectionContentProps {
group: SectionGroup
items: VaultEntry[]
isCollapsed: boolean
selection: SidebarSelection
onSelect: (sel: SidebarSelection) => void
onSelectNote?: (entry: VaultEntry) => void
onCreateType?: (type: string) => void
onCreateNewType?: () => void
onContextMenu: (e: React.MouseEvent, type: string) => void
onToggle: () => void
dragHandleProps?: Record<string, unknown>
}
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
return type === 'Topic' ? { kind: 'topic', entry } : { kind: 'entity', entry }
}
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
const isType = type === 'Type'
if (!onCreateType && !(isType && onCreateNewType)) return undefined
return isType ? () => onCreateNewType?.() : () => onCreateType?.(type)
}
export function SectionContent({
group, items, isCollapsed, selection, onSelect, onSelectNote,
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType)
return (
<>
<SectionHeader
label={label} type={type} Icon={Icon}
sectionColor={sectionColor}
isCollapsed={isCollapsed}
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
showCreate={!!onCreate}
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
onContextMenu={(e) => onContextMenu(e, type)}
onToggle={onToggle}
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
dragHandleProps={dragHandleProps}
/>
{!isCollapsed && items.length > 0 && (
<SectionChildList
items={items} type={type} selection={selection}
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
onSelect={onSelect} onSelectNote={onSelectNote}
/>
)}
</>
)
}
function SectionChildList({ items, type, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
items: VaultEntry[]; type: string; selection: SidebarSelection
sectionColor: string; sectionLightColor: string
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map((entry) => {
const sel = childSelection(type, entry)
const active = isSelectionActive(selection, sel)
return (
<SectionChildItem
key={entry.path} title={entry.title} isActive={active}
sectionColor={active ? sectionColor : undefined}
sectionLightColor={active ? sectionLightColor : undefined}
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
/>
)
})}
</div>
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
}) {
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
onClick={onSelect} onContextMenu={onContextMenu}
>
<div className="flex items-center" style={{ gap: 4 }}>
<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" style={{ marginLeft: 4 }}>{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{showCreate && (
<button className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer" style={{ width: 20, height: 20 }} onClick={onCreate} aria-label={type === 'Type' ? 'Create new Type' : `Create new ${type}`} title={type === 'Type' ? 'New Type' : `New ${type}`}>
<Plus size={14} />
</button>
)}
<button className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer" onClick={(e) => { e.stopPropagation(); onToggle() }} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
</div>
</div>
)
}
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
title: string; isActive: boolean
sectionColor?: string; sectionLightColor?: string
onClick: () => void
}) {
return (
<div
className={cn("cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors", isActive ? "text-foreground" : "text-muted-foreground hover:bg-accent")}
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
onClick={onClick}
>
{title}
</div>
)
}
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
sections: SectionGroup[]
isSectionVisible: (type: string) => boolean
onToggle: (type: string) => void
}) {
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
{sections.map(({ label, type, Icon }) => (
<button key={type} className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent" style={{ padding: '6px 12px', gap: 8 }} onClick={() => onToggle(type)} aria-label={`Toggle ${label}`}>
<Icon size={14} style={{ color: getTypeColor(type) }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isSectionVisible(type)} />
</button>
))}
</div>
)
}
function ToggleSwitch({ on }: { on: boolean }) {
return (
<div className="flex items-center" style={{ width: 32, height: 18, borderRadius: 9, padding: 2, backgroundColor: on ? 'var(--primary)' : 'var(--muted)', justifyContent: on ? 'flex-end' : 'flex-start', transition: 'background-color 150ms' }}>
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'white', transition: 'transform 150ms' }} />
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowsDownUp, Check } from '@phosphor-icons/react'
import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers'
export function SortDropdown({ groupLabel, current, onChange }: {
groupLabel: string
current: SortOption
onChange: (groupLabel: string, option: SortOption) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption) => {
onChange(groupLabel, opt)
setOpen(false)
}
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${current}`}
data-testid={`sort-button-${groupLabel}`}
>
<ArrowsDownUp size={12} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 130, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn("flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", opt.value === current && "bg-accent font-medium")}
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
data-testid={`sort-option-${opt.value}`}
>
{opt.value === current ? <Check size={12} /> : <span style={{ width: 12, height: 12, display: 'inline-block' }} />}
{opt.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -13,146 +13,74 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
}
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault }: StatusBarProps) {
const [showVaultMenu, setShowVaultMenu] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
return (
<div
role="button" onClick={onSelect}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
>
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
{vault.label}
</div>
)
}
function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void }) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((v) => v.path === vaultPath)
useEffect(() => {
if (!showVaultMenu) return
if (!open) return
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowVaultMenu(false)
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showVaultMenu])
}, [open])
return (
<footer
style={{
height: 30,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
}}
>
{/* Left section */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div ref={menuRef} style={{ position: 'relative' }}>
<span
role="button"
onClick={() => setShowVaultMenu((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
padding: '2px 4px',
borderRadius: 3,
background: showVaultMenu ? 'var(--hover)' : 'transparent',
}}
title="Switch vault"
>
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</span>
{showVaultMenu && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
background: 'var(--sidebar)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: 4,
minWidth: 160,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
{vaults.map((v) => (
<div
key={v.path}
role="button"
onClick={() => {
onSwitchVault(v.path)
setShowVaultMenu(false)
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: 'pointer',
background: v.path === vaultPath ? 'var(--hover)' : 'transparent',
color: v.path === vaultPath ? 'var(--foreground)' : 'var(--muted-foreground)',
fontSize: 12,
}}
onMouseEnter={(e) => {
if (v.path !== vaultPath) e.currentTarget.style.background = 'var(--hover)'
}}
onMouseLeave={(e) => {
if (v.path !== vaultPath) e.currentTarget.style.background = 'transparent'
}}
>
{v.path === vaultPath ? <Check size={12} /> : <span style={{ width: 12 }} />}
{v.label}
</div>
))}
</div>
)}
<div ref={menuRef} style={{ position: 'relative' }}>
<span role="button" onClick={() => setOpen((v) => !v)} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: open ? 'var(--hover)' : 'transparent' }} title="Switch vault">
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</span>
{open && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
</div>
<span style={{ color: 'var(--border)' }}>|</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Package size={13} />
v0.4.2
</span>
<span style={{ color: 'var(--border)' }}>|</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<GitBranch size={13} />
main
</span>
<span style={{ color: 'var(--border)' }}>|</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RefreshCw size={13} style={{ color: 'var(--accent-green)' }} />
Synced 2m ago
</span>
</div>
)}
</div>
)
}
{/* Right section */}
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault }: StatusBarProps) {
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />
Claude Sonnet 4
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FileText size={13} />
{noteCount.toLocaleString()} notes
</span>
<span
style={{ display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
>
<Bell size={14} />
</span>
<span
style={{ display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' }}
title="Coming soon"
>
<Settings size={14} />
</span>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} />
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><GitBranch size={13} />main</span>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><RefreshCw size={13} style={{ color: 'var(--accent-green)' }} />Synced 2m ago</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
<span style={DISABLED_STYLE} title="Coming soon"><Bell size={14} /></span>
<span style={DISABLED_STYLE} title="Coming soon"><Settings size={14} /></span>
</div>
</footer>
)

View File

@@ -8,8 +8,9 @@ function makeEntry(path: string, title: string): VaultEntry {
path, filename: `${title}.md`, title, isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false,
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null,
snippet: '', relationships: {}, icon: null, color: null, order: null,
}
}

View File

@@ -20,191 +20,177 @@ interface TabBarProps {
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
export const TabBar = memo(function TabBar({
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
}: TabBarProps) {
// --- Drag-and-drop helpers ---
function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null {
if (dragIdx === null || dropIdx === null || dragIdx === dropIdx) return null
const toIndex = dropIdx > dragIdx ? dropIdx - 1 : dropIdx
return toIndex !== dragIdx ? toIndex : null
}
function computeInsertIndex(e: React.DragEvent<HTMLDivElement>, index: number): number {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientX < rect.left + rect.width / 2 ? index : index + 1
}
function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dropIndex, setDropIndex] = useState<number | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
// Make the drag image slightly transparent
if (e.currentTarget) {
dragNodeRef.current = e.currentTarget
requestAnimationFrame(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.5'
}
})
}
}, [])
const handleDragEnd = useCallback(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = ''
}
const resetDrag = useCallback(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = ''
dragNodeRef.current = null
setDragIndex(null)
setDropIndex(null)
}, [])
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
dragNodeRef.current = e.currentTarget
requestAnimationFrame(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.5'
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragIndex === null || dragIndex === index) {
setDropIndex(null)
return
}
// Determine drop position based on cursor within the tab
const rect = e.currentTarget.getBoundingClientRect()
const midpoint = rect.left + rect.width / 2
const insertIndex = e.clientX < midpoint ? index : index + 1
setDropIndex(insertIndex)
if (dragIndex === null || dragIndex === index) { setDropIndex(null); return }
setDropIndex(computeInsertIndex(e, index))
}, [dragIndex])
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex && onReorderTabs) {
// Adjust target index: if dropping after the dragged item, account for removal
const toIndex = dropIndex > dragIndex ? dropIndex - 1 : dropIndex
if (toIndex !== dragIndex) {
onReorderTabs(dragIndex, toIndex)
}
}
handleDragEnd()
}, [dragIndex, dropIndex, onReorderTabs, handleDragEnd])
const toIndex = computeDropTarget(dragIndex, dropIndex)
if (toIndex !== null && onReorderTabs) onReorderTabs(dragIndex!, toIndex)
resetDrag()
}, [dragIndex, dropIndex, onReorderTabs, resetDrag])
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
// Only clear if we're leaving the tab bar entirely
const relatedTarget = e.relatedTarget as HTMLElement | null
if (!e.currentTarget.contains(relatedTarget)) {
setDropIndex(null)
}
const handleBarDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as HTMLElement | null
if (!e.currentTarget.contains(related)) setDropIndex(null)
}, [])
return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave }
}
// --- Sub-components ---
function DropIndicator({ side }: { side: 'left' | 'right' }) {
return (
<div style={{
position: 'absolute', [side]: -1, top: 8, bottom: 8,
width: 2, background: 'var(--primary)', borderRadius: 1, zIndex: 10,
}} />
)
}
function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: {
tab: Tab
isActive: boolean
isDragging: boolean
showDropBefore: boolean
showDropAfter: boolean
onSwitch: () => void
onClose: () => void
dragProps: React.HTMLAttributes<HTMLDivElement>
}) {
return (
<div
draggable
{...dragProps}
className={cn(
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
)}
style={{
background: isActive ? 'var(--background)' : 'transparent',
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
padding: '0 12px', fontSize: 12,
fontWeight: isActive ? 500 : 400,
cursor: isDragging ? 'grabbing' : 'grab',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
onClick={onSwitch}
>
{showDropBefore && <DropIndicator side="left" />}
<span className="truncate">{tab.entry.title}</span>
<button
className={cn(
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
style={{ lineHeight: 0 }}
draggable={false}
onClick={(e) => { e.stopPropagation(); onClose() }}
>
<X size={14} />
</button>
{showDropAfter && <DropIndicator side="right" />}
</div>
)
}
function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
return (
<div
className="flex shrink-0 items-center"
style={{
borderLeft: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
gap: 12, padding: '0 12px', WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors" onClick={onCreateNote} title="New note">
<Plus size={16} />
</button>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
<Columns size={16} />
</button>
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
<ArrowsOutSimple size={16} />
</button>
</div>
)
}
// --- Main TabBar ---
export const TabBar = memo(function TabBar({
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
return (
<div
className="flex shrink-0 items-stretch"
style={{ height: 45, background: 'var(--sidebar)', WebkitAppRegion: 'drag' } as React.CSSProperties}
data-tauri-drag-region
onDragLeave={handleDragLeave}
onDragLeave={handleBarDragLeave}
>
{tabs.map((tab, index) => {
const isActive = tab.entry.path === activeTabPath
const showDropBefore = dropIndex === index
const showDropAfter = dropIndex === index + 1 && index === tabs.length - 1
return (
<div
key={tab.entry.path}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
className={cn(
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-secondary-foreground"
)}
style={{
background: isActive ? 'var(--background)' : 'transparent',
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
padding: '0 12px',
fontSize: 12,
fontWeight: isActive ? 500 : 400,
cursor: dragIndex !== null ? 'grabbing' : 'grab',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
onClick={() => onSwitchTab(tab.entry.path)}
>
{showDropBefore && (
<div
style={{
position: 'absolute',
left: -1,
top: 8,
bottom: 8,
width: 2,
background: 'var(--primary)',
borderRadius: 1,
zIndex: 10,
}}
/>
)}
<span className="truncate">{tab.entry.title}</span>
<button
className={cn(
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
style={{ lineHeight: 0 }}
draggable={false}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.entry.path)
}}
>
<X size={14} />
</button>
{showDropAfter && (
<div
style={{
position: 'absolute',
right: -1,
top: 8,
bottom: 8,
width: 2,
background: 'var(--primary)',
borderRadius: 1,
zIndex: 10,
}}
/>
)}
</div>
)
})}
{tabs.map((tab, index) => (
<TabItem
key={tab.entry.path}
tab={tab}
isActive={tab.entry.path === activeTabPath}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
onSwitch={() => onSwitchTab(tab.entry.path)}
onClose={() => onCloseTab(tab.entry.path)}
dragProps={{
onDragStart: (e) => handleDragStart(e, index),
onDragEnd: handleDragEnd,
onDragOver: (e) => handleDragOver(e, index),
onDrop: handleDrop,
}}
/>
))}
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)' }} />
<div
className="flex shrink-0 items-center"
style={{
borderLeft: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
gap: 12,
padding: '0 12px',
WebkitAppRegion: 'no-drag',
} as React.CSSProperties}
>
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onCreateNote}
title="New note"
>
<Plus size={16} />
</button>
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<Columns size={16} />
</button>
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
title="Coming soon"
tabIndex={-1}
>
<ArrowsOutSimple size={16} />
</button>
</div>
<TabBarActions onCreateNote={onCreateNote} />
</div>
)
})

View File

@@ -0,0 +1,76 @@
import type { FrontmatterValue } from '../components/Inspector'
function formatYamlValue(value: FrontmatterValue): string {
if (Array.isArray(value)) return '\n' + value.map(v => ` - "${v}"`).join('\n')
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (value === null) return 'null'
return String(value)
}
function formatYamlKey(key: string): string {
return key.includes(' ') ? `"${key}"` : key
}
function buildKeyPattern(key: string): RegExp {
return new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm')
}
function parseFrontmatter(content: string): { fm: string; rest: string } | null {
if (!content.startsWith('---\n')) return null
const fmEnd = content.indexOf('\n---', 4)
if (fmEnd === -1) return null
return { fm: content.slice(4, fmEnd), rest: content.slice(fmEnd + 4) }
}
function formatKeyValue(yamlKey: string, yamlValue: string, isArray: boolean): string {
return isArray ? `${yamlKey}:${yamlValue}` : `${yamlKey}: ${yamlValue}`
}
function processKeyInLines(lines: string[], keyPattern: RegExp, replacement: string | null): string[] {
const newLines: string[] = []
let i = 0
while (i < lines.length) {
if (keyPattern.test(lines[i])) {
i++
while (i < lines.length && lines[i].startsWith(' - ')) i++
if (replacement !== null) newLines.push(replacement)
continue
}
newLines.push(lines[i])
i++
}
return newLines
}
export function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string {
const content = window.__mockContent?.[path] || ''
const yamlKey = formatYamlKey(key)
const yamlValue = formatYamlValue(value)
const isArray = Array.isArray(value)
const parsed = parseFrontmatter(content)
if (!parsed) {
return `---\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---\n${content}`
}
const { fm, rest } = parsed
const keyPattern = buildKeyPattern(key)
if (keyPattern.test(fm)) {
const newLines = processKeyInLines(fm.split('\n'), keyPattern, formatKeyValue(yamlKey, yamlValue, isArray))
return `---\n${newLines.join('\n')}\n---${rest}`
}
return `---\n${fm}\n${formatKeyValue(yamlKey, yamlValue, isArray)}\n---${rest}`
}
export function deleteMockFrontmatterProperty(path: string, key: string): string {
const content = window.__mockContent?.[path] || ''
const parsed = parseFrontmatter(content)
if (!parsed) return content
const { fm, rest } = parsed
const keyPattern = buildKeyPattern(key)
const newLines = processKeyInLines(fm.split('\n'), keyPattern, null)
return `---\n${newLines.join('\n')}\n---${rest}`
}

View File

@@ -0,0 +1,64 @@
import { useCallback } from 'react'
import type { VaultEntry } from '../types'
interface EntryActionsConfig {
entries: VaultEntry[]
updateEntry: (path: string, updates: Partial<VaultEntry>) => void
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
handleDeleteProperty: (path: string, key: string) => Promise<void>
setToastMessage: (msg: string | null) => void
}
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
return entries.find((e) => e.isA === 'Type' && e.title === typeName)
}
export function useEntryActions({
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage,
}: EntryActionsConfig) {
const handleTrashNote = useCallback(async (path: string) => {
const now = new Date().toISOString().slice(0, 10)
await handleUpdateFrontmatter(path, 'trashed', true)
await handleUpdateFrontmatter(path, 'trashed_at', now)
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Note moved to trash')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
const handleRestoreNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'trashed', false)
await handleDeleteProperty(path, 'trashed_at')
updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage])
const handleArchiveNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'archived', true)
updateEntry(path, { archived: true })
setToastMessage('Note archived')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
const handleUnarchiveNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'archived', false)
updateEntry(path, { archived: false })
setToastMessage('Note unarchived')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
handleUpdateFrontmatter(typeEntry.path, 'color', color)
updateEntry(typeEntry.path, { icon, color })
}, [entries, handleUpdateFrontmatter, updateEntry])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) continue
handleUpdateFrontmatter(typeEntry.path, 'order', order)
updateEntry(typeEntry.path, { order })
}
}, [entries, handleUpdateFrontmatter, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections }
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../components/NoteList'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
interface Tab {
@@ -71,55 +71,52 @@ function navigateNote(
}
}
type ShortcutKind = 'tab' | 'note' | null
function classifyShortcut(e: KeyboardEvent, inTauri: boolean): ShortcutKind {
const mod = e.metaKey || e.ctrlKey
if (!mod) return null
const isTabShortcut = inTauri ? (e.altKey && !e.shiftKey) : (e.shiftKey && !e.altKey)
if (isTabShortcut && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return 'tab'
if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return 'note'
return null
}
function arrowDirection(key: string): 1 | -1 {
return (key === 'ArrowRight' || key === 'ArrowDown') ? 1 : -1
}
function useLatestRef<T>(value: T): React.RefObject<T> {
const ref = useRef(value)
ref.current = value
return ref
}
export function useKeyboardNavigation({
tabs,
activeTabPath,
entries,
selection,
allContent,
onSwitchTab,
onReplaceActiveTab,
onSelectNote,
tabs, activeTabPath, entries, selection, allContent,
onSwitchTab, onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection, allContent),
[entries, selection, allContent],
)
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const activeTabPathRef = useRef(activeTabPath)
activeTabPathRef.current = activeTabPath
const visibleNotesRef = useRef(visibleNotes)
visibleNotesRef.current = visibleNotes
const onSwitchTabRef = useRef(onSwitchTab)
onSwitchTabRef.current = onSwitchTab
const onReplaceRef = useRef(onReplaceActiveTab)
onReplaceRef.current = onReplaceActiveTab
const onSelectNoteRef = useRef(onSelectNote)
onSelectNoteRef.current = onSelectNote
const tabsRef = useLatestRef(tabs)
const activeTabPathRef = useLatestRef(activeTabPath)
const visibleNotesRef = useLatestRef(visibleNotes)
const onSwitchTabRef = useLatestRef(onSwitchTab)
const onReplaceRef = useLatestRef(onReplaceActiveTab)
const onSelectNoteRef = useLatestRef(onSelectNote)
useEffect(() => {
const isRunningInTauri = isTauri()
const inTauri = isTauri()
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const isTabShortcut = isRunningInTauri
? e.altKey && !e.shiftKey
: e.shiftKey && !e.altKey
const isNoteShortcut = e.altKey && !e.shiftKey
if (isTabShortcut && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault()
navigateTab(tabsRef, activeTabPathRef, onSwitchTabRef, e.key === 'ArrowRight' ? 1 : -1)
} else if (isNoteShortcut && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, e.key === 'ArrowDown' ? 1 : -1)
}
const kind = classifyShortcut(e, inTauri)
if (!kind) return
e.preventDefault()
if (kind === 'tab') navigateTab(tabsRef, activeTabPathRef, onSwitchTabRef, arrowDirection(e.key))
else navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, arrowDirection(e.key))
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])

View File

@@ -1,135 +1,99 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import { isTauri, addMockEntry, updateMockContent } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { useTabManagement } from './useTabManagement'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
interface Tab {
entry: VaultEntry
content: string
interface NewEntryParams {
path: string
slug: string
title: string
type: string
status: string | null
}
// Mock frontmatter helpers for browser testing
function updateMockFrontmatter(path: string, key: string, value: FrontmatterValue): string {
const content = window.__mockContent?.[path] || ''
const yamlKey = key.includes(' ') ? `"${key}"` : key
let yamlValue: string
if (Array.isArray(value)) {
yamlValue = '\n' + value.map(v => ` - "${v}"`).join('\n')
} else if (typeof value === 'boolean') {
yamlValue = value ? 'true' : 'false'
} else if (value === null) {
yamlValue = 'null'
} else {
yamlValue = String(value)
}
if (!content.startsWith('---\n')) {
return `---\n${yamlKey}: ${yamlValue}\n---\n${content}`
}
const fmEnd = content.indexOf('\n---', 4)
if (fmEnd === -1) return content
const fm = content.slice(4, fmEnd)
const rest = content.slice(fmEnd + 4)
const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm')
if (keyPattern.test(fm)) {
const lines = fm.split('\n')
const newLines: string[] = []
let i = 0
while (i < lines.length) {
if (keyPattern.test(lines[i])) {
i++
while (i < lines.length && lines[i].startsWith(' - ')) i++
if (Array.isArray(value)) {
newLines.push(`${yamlKey}:${yamlValue}`)
} else {
newLines.push(`${yamlKey}: ${yamlValue}`)
}
continue
}
newLines.push(lines[i])
i++
}
return `---\n${newLines.join('\n')}\n---${rest}`
} else {
if (Array.isArray(value)) {
return `---\n${fm}\n${yamlKey}:${yamlValue}\n---${rest}`
} else {
return `---\n${fm}\n${yamlKey}: ${yamlValue}\n---${rest}`
}
function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry {
const now = Math.floor(Date.now() / 1000)
return {
path, filename: `${slug}.md`, title, isA: type,
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {}, icon: null, color: null, order: null,
}
}
function deleteMockFrontmatterProperty(path: string, key: string): string {
const content = window.__mockContent?.[path] || ''
if (!content.startsWith('---\n')) return content
const fmEnd = content.indexOf('\n---', 4)
if (fmEnd === -1) return content
const fm = content.slice(4, fmEnd)
const rest = content.slice(fmEnd + 4)
const keyPattern = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`, 'm')
const lines = fm.split('\n')
const newLines: string[] = []
let i = 0
while (i < lines.length) {
if (keyPattern.test(lines[i])) {
i++
while (i < lines.length && lines[i].startsWith(' - ')) i++
continue
}
newLines.push(lines[i])
i++
}
return `---\n${newLines.join('\n')}\n---${rest}`
function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
const TAB_ORDER_KEY = 'laputa-tab-order'
function saveTabOrder(tabs: Tab[]) {
try {
localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabs.map(t => t.entry.path)))
} catch { /* localStorage may be unavailable */ }
function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean {
if (e.title.toLowerCase() === targetLower) return true
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (pathStem.toLowerCase() === targetLower) return true
const fileStem = e.filename.replace(/\.md$/, '')
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
return e.title.toLowerCase() === targetAsWords
}
function loadTabOrder(): string[] {
try {
const stored = localStorage.getItem(TAB_ORDER_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
return invoke<string>(command, args)
}
async function loadNoteContent(path: string): Promise<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
const content = updateMockFrontmatter(path, key, value)
updateMockContent(path, content)
return content
}
async function replaceTabWithEntry(
entry: VaultEntry,
currentPath: string,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
) {
const applyReplace = (content: string) => {
setTabs((prev) => prev.map((t) =>
t.entry.path === currentPath ? { entry, content } : t
))
setActiveTabPath(entry.path)
}
try {
applyReplace(await loadNoteContent(entry.path))
} catch (err) {
console.warn('Failed to load note content for replace:', err)
applyReplace('')
function applyMockFrontmatterDelete(path: string, key: string): string {
const content = deleteMockFrontmatterProperty(path, key)
updateMockContent(path, content)
return content
}
const TYPE_FOLDER_MAP: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
}
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) {
if (!isTauri()) addMockEntry(entry, content)
addEntry(entry, content)
}
function buildNoteContent(title: string, type: string, status: string | null): string {
const lines = ['---', `title: ${title}`, `is_a: ${type}`]
if (status) lines.push(`status: ${status}`)
lines.push('---')
return `${lines.join('\n')}\n\n# ${title}\n\n`
}
function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
const slug = slugify(title)
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent(title, type, status) }
}
function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\nIs A: Type\n---\n\n# ${typeName}\n\n` }
}
async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise<string> {
if (op === 'update') {
return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!)
}
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
}
export function useNoteActions(
@@ -138,263 +102,51 @@ export function useNoteActions(
entries: VaultEntry[],
setToastMessage: (msg: string | null) => void,
) {
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
const activeTabPathRef = useRef(activeTabPath)
activeTabPathRef.current = activeTabPath
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
const tabMgmt = useTabManagement()
const { setTabs, handleSelectNote } = tabMgmt
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
// If already open, just switch — instant
if (tabsRef.current.some((t) => t.entry.path === entry.path)) {
setActiveTabPath(entry.path)
return
}
// Load content async, then add tab and set active together
try {
const content = await loadNoteContent(entry.path)
setTabs((prev) => {
if (prev.some((t) => t.entry.path === entry.path)) return prev
return [...prev, { entry, content }]
})
setActiveTabPath(entry.path)
} catch (err) {
console.warn('Failed to load note content:', err)
setTabs((prev) => {
if (prev.some((t) => t.entry.path === entry.path)) return prev
return [...prev, { entry, content: '' }]
})
setActiveTabPath(entry.path)
}
}, [])
const handleCloseTab = useCallback((path: string) => {
setTabs((prev) => {
const next = prev.filter((t) => t.entry.path !== path)
if (path === activeTabPathRef.current && next.length > 0) {
const closedIdx = prev.findIndex((t) => t.entry.path === path)
const newIdx = Math.min(closedIdx, next.length - 1)
setActiveTabPath(next[newIdx].entry.path)
} else if (next.length === 0) {
setActiveTabPath(null)
}
return next
})
}, [])
handleCloseTabRef.current = handleCloseTab
const handleSwitchTab = useCallback((path: string) => {
setActiveTabPath(path)
}, [])
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
updateContent(path, newContent)
}, [setTabs, updateContent])
const handleNavigateWikilink = useCallback((target: string) => {
const targetLower = target.toLowerCase()
const slugToWords = (s: string) => s.replace(/-/g, ' ').toLowerCase()
const targetAsWords = slugToWords(target.split('/').pop() ?? target)
const found = entries.find((e) => {
if (e.title.toLowerCase() === targetLower) return true
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (pathStem.toLowerCase() === targetLower) return true
const fileStem = e.filename.replace(/\.md$/, '')
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
if (e.title.toLowerCase() === targetAsWords) return true
return false
})
if (found) {
handleSelectNote(found)
} else {
console.warn(`Navigation target not found: ${target}`)
}
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
const found = entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords))
if (found) handleSelectNote(found)
else console.warn(`Navigation target not found: ${target}`)
}, [entries, handleSelectNote])
const handleCreateNote = useCallback(async (title: string, type: string) => {
const typeToFolder: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
}
// Custom types use lowercased type name as folder
const folder = typeToFolder[type] || type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const path = `/Users/luca/Laputa/${folder}/${slug}.md`
const now = Math.floor(Date.now() / 1000)
const noStatusTypes = new Set(['Topic', 'Person'])
const newEntry: VaultEntry = {
path, filename: `${slug}.md`, title, isA: type,
aliases: [], belongsTo: [], relatedTo: [],
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, order: null,
}
const frontmatter = [
'---', `title: ${title}`, `is_a: ${type}`,
...(newEntry.status ? [`status: ${newEntry.status}`] : []),
'---',
].join('\n')
const content = `${frontmatter}\n\n# ${title}\n\n`
if (!isTauri()) {
addMockEntry(newEntry, content)
}
addEntry(newEntry, content)
handleSelectNote(newEntry)
const handleCreateNote = useCallback((title: string, type: string) => {
const { entry, content } = resolveNewNote(title, type)
addEntryWithMock(entry, content, addEntry)
handleSelectNote(entry)
}, [handleSelectNote, addEntry])
const handleCreateType = useCallback(async (typeName: string) => {
const slug = typeName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const path = `/Users/luca/Laputa/type/${slug}.md`
const now = Math.floor(Date.now() / 1000)
const newEntry: VaultEntry = {
path, filename: `${slug}.md`, title: typeName, isA: 'Type',
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, order: null,
}
const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n`
if (!isTauri()) {
addMockEntry(newEntry, content)
}
addEntry(newEntry, content)
handleSelectNote(newEntry)
const handleCreateType = useCallback((typeName: string) => {
const { entry, content } = resolveNewType(typeName)
addEntryWithMock(entry, content, addEntry)
handleSelectNote(entry)
}, [handleSelectNote, addEntry])
const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const runFrontmatterOp = useCallback(async (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => {
try {
let newContent: string
if (isTauri()) {
let rustValue: unknown = value
if (Array.isArray(value)) rustValue = value
else if (typeof value === 'boolean') rustValue = value
else if (typeof value === 'number') rustValue = value
else if (value === null) rustValue = null
else rustValue = String(value)
newContent = await invoke<string>('update_frontmatter', { path, key, value: rustValue })
} else {
newContent = updateMockFrontmatter(path, key, value)
updateMockContent(path, newContent)
}
setTabs((prev) => prev.map((t) =>
t.entry.path === path ? { ...t, content: newContent } : t
))
updateContent(path, newContent)
setToastMessage('Property updated')
updateTabContent(path, await executeFrontmatterOp(op, path, key, value))
setToastMessage(op === 'update' ? 'Property updated' : 'Property deleted')
} catch (err) {
console.error('Failed to update frontmatter:', err)
setToastMessage('Failed to update property')
console.error(`Failed to ${op} frontmatter:`, err)
setToastMessage(`Failed to ${op} property`)
}
}, [updateContent, setToastMessage])
const handleDeleteProperty = useCallback(async (path: string, key: string) => {
try {
let newContent: string
if (isTauri()) {
newContent = await invoke<string>('delete_frontmatter_property', { path, key })
} else {
newContent = deleteMockFrontmatterProperty(path, key)
updateMockContent(path, newContent)
}
setTabs((prev) => prev.map((t) =>
t.entry.path === path ? { ...t, content: newContent } : t
))
updateContent(path, newContent)
setToastMessage('Property deleted')
} catch (err) {
console.error('Failed to delete property:', err)
setToastMessage('Failed to delete property')
}
}, [updateContent, setToastMessage])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
return handleUpdateFrontmatter(path, key, value)
}, [handleUpdateFrontmatter])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
const currentPath = activeTabPathRef.current
if (!currentPath) { handleSelectNote(entry); return }
if (currentPath === entry.path) return
replaceTabWithEntry(entry, currentPath, setTabs, setActiveTabPath)
}, [handleSelectNote])
const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setTabs((prev) => {
const next = [...prev]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
saveTabOrder(next)
return next
})
}, [])
// Persist tab order to localStorage whenever tabs change
useEffect(() => {
if (tabs.length > 0) {
saveTabOrder(tabs)
} else {
try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ }
}
}, [tabs])
// Restore tab order from localStorage on mount
useEffect(() => {
const savedOrder = loadTabOrder()
if (savedOrder.length === 0) return
setTabs((prev) => {
if (prev.length <= 1) return prev
const pathToTab = new Map(prev.map(t => [t.entry.path, t]))
const ordered: Tab[] = []
for (const path of savedOrder) {
const tab = pathToTab.get(path)
if (tab) {
ordered.push(tab)
pathToTab.delete(path)
}
}
// Append any tabs not in saved order (newly opened)
for (const tab of pathToTab.values()) {
ordered.push(tab)
}
return ordered
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
}, [])
}, [updateTabContent, setToastMessage])
return {
tabs,
activeTabPath,
activeTabPathRef,
handleCloseTabRef,
handleSelectNote,
handleCloseTab,
handleSwitchTab,
handleReorderTabs,
...tabMgmt,
handleNavigateWikilink,
handleCreateNote,
handleCreateType,
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,
handleReplaceActiveTab,
closeAllTabs,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
}
}

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
interface Tab {
entry: VaultEntry
content: string
}
const TAB_ORDER_KEY = 'laputa-tab-order'
function saveTabOrder(tabs: Tab[]) {
try {
localStorage.setItem(TAB_ORDER_KEY, JSON.stringify(tabs.map(t => t.entry.path)))
} catch { /* localStorage may be unavailable */ }
}
function loadTabOrder(): string[] {
try {
const stored = localStorage.getItem(TAB_ORDER_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
function clearTabOrder() {
try { localStorage.removeItem(TAB_ORDER_KEY) } catch { /* noop */ }
}
async function loadNoteContent(path: string): Promise<string> {
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
}
function addTabIfAbsent(prev: Tab[], entry: VaultEntry, content: string): Tab[] {
if (prev.some((t) => t.entry.path === entry.path)) return prev
return [...prev, { entry, content }]
}
function resolveNextActiveTab(prev: Tab[], closedPath: string): string | null {
const next = prev.filter((t) => t.entry.path !== closedPath)
if (next.length === 0) return null
const closedIdx = prev.findIndex((t) => t.entry.path === closedPath)
const newIdx = Math.min(closedIdx, next.length - 1)
return next[newIdx].entry.path
}
function replaceTabEntry(prev: Tab[], targetPath: string, entry: VaultEntry, content: string): Tab[] {
return prev.map((t) => t.entry.path === targetPath ? { entry, content } : t)
}
function reorderArray(tabs: Tab[], fromIndex: number, toIndex: number): Tab[] {
const next = [...tabs]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}
function restoreOrder(prev: Tab[], savedOrder: string[]): Tab[] {
if (prev.length <= 1) return prev
const pathToTab = new Map(prev.map(t => [t.entry.path, t]))
const ordered: Tab[] = []
for (const path of savedOrder) {
const tab = pathToTab.get(path)
if (tab) {
ordered.push(tab)
pathToTab.delete(path)
}
}
for (const tab of pathToTab.values()) {
ordered.push(tab)
}
return ordered
}
export type { Tab }
export function useTabManagement() {
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
const activeTabPathRef = useRef(activeTabPath)
activeTabPathRef.current = activeTabPath
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
if (tabsRef.current.some((t) => t.entry.path === entry.path)) {
setActiveTabPath(entry.path)
return
}
try {
const content = await loadNoteContent(entry.path)
setTabs((prev) => addTabIfAbsent(prev, entry, content))
} catch (err) {
console.warn('Failed to load note content:', err)
setTabs((prev) => addTabIfAbsent(prev, entry, ''))
}
setActiveTabPath(entry.path)
}, [])
const handleCloseTab = useCallback((path: string) => {
setTabs((prev) => {
const next = prev.filter((t) => t.entry.path !== path)
if (path === activeTabPathRef.current) {
setActiveTabPath(resolveNextActiveTab(prev, path))
}
return next
})
}, [])
handleCloseTabRef.current = handleCloseTab
const handleSwitchTab = useCallback((path: string) => {
setActiveTabPath(path)
}, [])
const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => {
setTabs((prev) => {
const next = reorderArray(prev, fromIndex, toIndex)
saveTabOrder(next)
return next
})
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
const currentPath = activeTabPathRef.current
if (!currentPath) { handleSelectNote(entry); return }
if (currentPath === entry.path) return
try {
const content = await loadNoteContent(entry.path)
setTabs((prev) => replaceTabEntry(prev, currentPath, entry, content))
} catch (err) {
console.warn('Failed to load note content for replace:', err)
setTabs((prev) => replaceTabEntry(prev, currentPath, entry, ''))
}
setActiveTabPath(entry.path)
}, [handleSelectNote])
const closeAllTabs = useCallback(() => {
setTabs([])
setActiveTabPath(null)
}, [])
useEffect(() => {
if (tabs.length > 0) saveTabOrder(tabs)
else clearTabOrder()
}, [tabs])
useEffect(() => {
const savedOrder = loadTabOrder()
if (savedOrder.length > 0) {
setTabs((prev) => restoreOrder(prev, savedOrder))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {
tabs,
setTabs,
activeTabPath,
activeTabPathRef,
handleCloseTabRef,
handleSelectNote,
handleCloseTab,
handleSwitchTab,
handleReorderTabs,
handleReplaceActiveTab,
closeAllTabs,
}
}

View File

@@ -3,60 +3,55 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
async function loadVaultData(vaultPath: string) {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
console.log(`Vault scan complete: ${entries.length} entries found`)
const allContent = isTauri() ? {} : await mockInvoke<Record<string, string>>('get_all_content', { path: vaultPath })
return { entries, allContent }
}
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
}
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
}
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
useEffect(() => {
setEntries([])
setAllContent({})
setModifiedFiles([])
const loadVault = async () => {
try {
let result: VaultEntry[]
if (isTauri()) {
result = await invoke<VaultEntry[]>('list_vault', { path: vaultPath })
} else {
console.info('[mock] Using mock Tauri data for browser testing')
result = await mockInvoke<VaultEntry[]>('list_vault', { path: vaultPath })
}
console.log(`Vault scan complete: ${result.length} entries found`)
setEntries(result)
let content: Record<string, string>
if (isTauri()) {
content = {}
} else {
content = await mockInvoke<Record<string, string>>('get_all_content', { path: vaultPath })
}
setAllContent(content)
} catch (err) {
console.warn('Vault scan failed:', err)
}
}
loadVault()
setEntries([]); setAllContent({}); setModifiedFiles([])
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
}, [vaultPath])
const loadModifiedFiles = useCallback(async () => {
try {
let files: ModifiedFile[]
if (isTauri()) {
files = await invoke<ModifiedFile[]>('get_modified_files', { vaultPath })
} else {
files = await mockInvoke<ModifiedFile[]>('get_modified_files', {})
}
setModifiedFiles(files)
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
} catch (err) {
console.warn('Failed to load modified files:', err)
setModifiedFiles([])
}
}, [vaultPath])
useEffect(() => {
loadModifiedFiles()
}, [loadModifiedFiles])
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles])
const addEntry = useCallback((entry: VaultEntry, content: string) => {
setEntries((prev) => [entry, ...prev])
@@ -72,53 +67,25 @@ export function useVaultLoader(vaultPath: string) {
}, [])
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
try {
if (isTauri()) {
return await invoke<GitCommit[]>('get_file_history', { vaultPath, path })
} else {
return await mockInvoke<GitCommit[]>('get_file_history', { path })
}
} catch (err) {
console.warn('Failed to load git history:', err)
return []
}
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
catch (err) { console.warn('Failed to load git history:', err); return [] }
}, [vaultPath])
const loadDiffAtCommit = useCallback(async (path: string, commitHash: string): Promise<string> => {
if (isTauri()) {
return invoke<string>('get_file_diff_at_commit', { vaultPath, path, commitHash })
} else {
return mockInvoke<string>('get_file_diff_at_commit', { path, commitHash })
}
}, [vaultPath])
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }),
[vaultPath])
const loadDiff = useCallback(async (path: string): Promise<string> => {
if (isTauri()) {
return invoke<string>('get_file_diff', { vaultPath, path })
} else {
return mockInvoke<string>('get_file_diff', { path })
}
}, [vaultPath])
const loadDiff = useCallback((path: string): Promise<string> =>
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }),
[vaultPath])
const isFileModified = useCallback((path: string): boolean => {
return modifiedFiles.some((f) => f.path === path)
}, [modifiedFiles])
const isFileModified = useCallback((path: string): boolean =>
modifiedFiles.some((f) => f.path === path),
[modifiedFiles])
const commitAndPush = useCallback(async (message: string): Promise<string> => {
if (isTauri()) {
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
} else {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
}
}, [vaultPath])
const commitAndPush = useCallback((message: string): Promise<string> =>
commitWithPush(vaultPath, message),
[vaultPath])
return {
entries,

View File

@@ -4,69 +4,65 @@ export interface ParsedFrontmatter {
[key: string]: FrontmatterValue
}
function unquote(s: string): string {
return s.replace(/^["']|["']$/g, '')
}
function collapseList(items: string[]): FrontmatterValue {
return items.length === 1 ? items[0] : items
}
function isBlockScalar(value: string): boolean {
return value === '' || value === '|' || value === '>'
}
function parseInlineArray(value: string): FrontmatterValue {
const items = value.slice(1, -1).split(',').map(s => unquote(s.trim()))
return collapseList(items)
}
function parseScalar(value: string): FrontmatterValue {
const clean = unquote(value)
if (clean.toLowerCase() === 'true') return true
if (clean.toLowerCase() === 'false') return false
return clean
}
/** Parse YAML frontmatter from content */
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
if (!content) return {}
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return {}
const yaml = match[1]
const result: ParsedFrontmatter = {}
let currentKey: string | null = null
let currentList: string[] = []
let inList = false
const lines = yaml.split('\n')
for (const line of lines) {
for (const line of match[1].split('\n')) {
const listMatch = line.match(/^ - (.*)$/)
if (listMatch && currentKey) {
inList = true
currentList.push(listMatch[1].replace(/^["']|["']$/g, ''))
currentList.push(unquote(listMatch[1]))
continue
}
if (inList && currentKey) {
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
result[currentKey] = collapseList(currentList)
currentList = []
inList = false
}
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
if (kvMatch) {
currentKey = kvMatch[1].trim()
const value = kvMatch[2].trim()
if (!kvMatch) continue
currentKey = kvMatch[1].trim()
const value = kvMatch[2].trim()
if (value === '' || value === '|' || value === '>') {
continue
}
if (value.startsWith('[') && value.endsWith(']')) {
const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''))
result[currentKey] = items.length === 1 ? items[0] : items
continue
}
const unquoted = value.replace(/^["']|["']$/g, '')
if (unquoted.toLowerCase() === 'true') {
result[currentKey] = true
continue
}
if (unquoted.toLowerCase() === 'false') {
result[currentKey] = false
continue
}
result[currentKey] = unquoted
}
}
if (inList && currentKey) {
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
if (isBlockScalar(value)) continue
if (value.startsWith('[') && value.endsWith(']')) { result[currentKey] = parseInlineArray(value); continue }
result[currentKey] = parseScalar(value)
}
if (inList && currentKey) result[currentKey] = collapseList(currentList)
return result
}

View File

@@ -0,0 +1,201 @@
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
export interface RelationshipGroup {
label: string
entries: VaultEntry[]
}
export function relativeDate(ts: number | null): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 0) {
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function getDisplayDate(entry: VaultEntry): number | null {
return entry.modifiedAt ?? entry.createdAt
}
function refsMatch(refs: string[], entry: VaultEntry): boolean {
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const fileStem = entry.filename.replace(/\.md$/, '')
return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return inner === stem || inner.split('/').pop() === fileStem
})
}
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs
.map((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === inner) return true
const fileStem = e.filename.replace(/\.md$/, '')
return fileStem === inner.split('/').pop()
})
})
.filter((e): e is VaultEntry => e !== undefined)
}
export function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'title', label: 'Title' },
{ value: 'status', label: 'Status' },
]
const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
switch (option) {
case 'modified':
return sortByModified
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
case 'title':
return (a, b) => a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
export function loadSortPreferences(): Record<string, SortOption> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
export function saveSortPreferences(prefs: Record<string, SortOption>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const targets = [entity.title, ...entity.aliases]
return allEntries.filter((e) => {
if (e.path === entity.path) return false
const content = allContent[e.path]
if (!content) return false
for (const t of targets) {
if (content.includes(`[[${t}]]`)) return true
}
if (content.includes(`[[${stem}]]`)) return true
if (content.includes(`[[${pathStem}]]`)) return true
return content.includes(`[[${pathStem}|`)
})
}
class GroupBuilder {
readonly groups: RelationshipGroup[] = []
private readonly seen: Set<string>
private readonly allEntries: VaultEntry[]
constructor(entityPath: string, allEntries: VaultEntry[]) {
this.seen = new Set([entityPath])
this.allEntries = allEntries
}
add(label: string, entries: VaultEntry[]) {
const unseen = entries.filter((e) => !this.seen.has(e.path))
if (unseen.length > 0) {
this.groups.push({ label, entries: unseen })
unseen.forEach((e) => this.seen.add(e.path))
}
}
addFromRefs(label: string, refs: string[]) {
if (refs.length > 0) {
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
}
}
filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) {
this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified))
}
}
export function buildRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
allContent: Record<string, string>,
): RelationshipGroup[] {
const b = new GroupBuilder(entity.path, allEntries)
const rels = entity.relationships ?? {}
if (entity.isA === 'Type') {
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
}
b.addFromRefs('Has', rels['Has'] ?? [])
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.addFromRefs('Topics', rels['Topics'] ?? [])
const handledKeys = new Set(['Has', 'Topics'])
Object.keys(rels)
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
return b.groups
}
const isActive = (e: VaultEntry) => !e.archived && !e.trashed
function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'sectionGroup') {
return entries.filter((e) => e.isA === selection.type && isActive(e))
}
if (selection.kind === 'topic') {
return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e))
}
return filterByFilterType(entries, selection.filter)
}
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)
return []
}
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] {
return filterByKind(entries, selection)
}