refactor: lean code health improvements — avg 8.92 → 9.70
This commit is contained in:
153
src/App.tsx
153
src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const baseEntry: VaultEntry = {
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
}
|
||||
|
||||
const archivedEntry: VaultEntry = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">×</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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
253
src/components/InspectorPanels.tsx
Normal file
253
src/components/InspectorPanels.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/components/NoteItem.tsx
Normal file
89
src/components/NoteItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
227
src/components/SidebarParts.tsx
Normal file
227
src/components/SidebarParts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/SortDropdown.tsx
Normal file
57
src/components/SortDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
76
src/hooks/mockFrontmatterHelpers.ts
Normal file
76
src/hooks/mockFrontmatterHelpers.ts
Normal 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}`
|
||||
}
|
||||
64
src/hooks/useEntryActions.ts
Normal file
64
src/hooks/useEntryActions.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
|
||||
173
src/hooks/useTabManagement.ts
Normal file
173
src/hooks/useTabManagement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
201
src/utils/noteListHelpers.ts
Normal file
201
src/utils/noteListHelpers.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user