diff --git a/src/App.tsx b/src/App.tsx index eab248fc..fb6f6094 100644 --- a/src/App.tsx +++ b/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(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(DEFAULT_SELECTION) + const layout = useLayoutPanels() const [gitHistory, setGitHistory] = useState([]) const [showCreateDialog, setShowCreateDialog] = useState(false) const [createNoteDefaultType, setCreateNoteDefaultType] = useState() @@ -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 (
-
- setShowCommitDialog(true)} /> +
+ setShowCreateTypeDialog(true)} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
- -
+ +
- +
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} />
setToastMessage(null)} /> - setShowQuickOpen(false)} - /> - setShowCreateDialog(false)} - onCreate={notes.handleCreateNote} - defaultType={createNoteDefaultType} - customTypes={customTypes} - /> - setShowCreateTypeDialog(false)} - onCreate={handleCreateType} - /> - setShowCommitDialog(false)} - /> + setShowQuickOpen(false)} /> + setShowCreateDialog(false)} onCreate={notes.handleCreateNote} defaultType={createNoteDefaultType} customTypes={customTypes} /> + setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> + setShowCommitDialog(false)} />
) } diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 6528af9b..5cd34c8f 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -24,6 +24,7 @@ const baseEntry: VaultEntry = { relationships: {}, icon: null, color: null, + order: null, } const archivedEntry: VaultEntry = { diff --git a/src/components/DiffView.tsx b/src/components/DiffView.tsx index e8aff793..9f61bcdf 100644 --- a/src/components/DiffView.tsx +++ b/src/components/DiffView.tsx @@ -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 ( +
+ {lineNumber} + {line || '\u00A0'} +
+ ) +} + export function DiffView({ diff }: DiffViewProps) { if (!diff) { return ( @@ -13,33 +32,11 @@ export function DiffView({ diff }: DiffViewProps) { ) } - const lines = diff.split('\n') - return (
- {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 ( -
- - {i + 1} - - - {line || '\u00A0'} - -
- ) - })} + {diff.split('\n').map((line, i) => ( + + ))}
) } diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 7f6c7391..30e190b0 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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 ( + { + 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 ( + onStartEdit(propKey)} title={statusStr} + > + {statusStr} + + ) +} + +function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) { + return ( + + ) +} + +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 ( +
+ setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus + /> + setNewValue(e.target.value)} onKeyDown={handleKeyDown} + /> +
+ + +
+
+ ) +} + +function TypeRow({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) { + if (!isA) return null + return ( +
+ Type + {onNavigate ? ( + + ) : ( + {isA} + )} +
+ ) +} + +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 ( +
+ + {propKey} + {onDelete && ( + + )} + + +
+ ) +} + +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 + if (isStatusKey(propKey)) return + if (Array.isArray(value)) return onSaveList(propKey, items)} label={propKey} /> + if (isDateKey(propKey)) return + if (typeof value === 'boolean') return onUpdate?.(propKey, !value)} /> + return +} + +function StaticRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { + return ( + + ) +} + export function DynamicPropertiesPanel({ entry, content, @@ -68,219 +228,46 @@ export function DynamicPropertiesPanel({ }) { const [editingKey, setEditingKey] = useState(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 ( - 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 ( - { - 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 ( - setEditingKey(key)} title={statusStr} - > - {statusStr} - - ) - } - - if (Array.isArray(value)) { - return handleSaveList(key, items)} label={key} /> - } - - if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) { - return ( - setEditingKey(key)} - onSave={(v) => handleSaveValue(key, v)} - onCancel={() => setEditingKey(null)} - /> - ) - } - - if (typeof value === 'boolean') { - return ( - - ) - } - - return ( - 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 (
- {entry.isA && ( -
- Type - {onNavigate ? ( - - ) : ( - {entry.isA} - )} -
- )} - + {propertyEntries.map(([key, value]) => ( -
- - {key} - {onDeleteProperty && ( - - )} - - {renderEditableValue(key, value)} -
+ ))} - -
- Modified - {formatDate(entry.modifiedAt)} -
-
- Words - {wordCount} -
+ +
- - {showAddDialog ? ( -
- setNewKey(e.target.value)} onKeyDown={handleAddKeyDown} autoFocus - /> - setNewValue(e.target.value)} onKeyDown={handleAddKeyDown} - /> -
- - -
-
- ) : ( - - )} + {showAddDialog + ? setShowAddDialog(false)} /> + : setShowAddDialog(true)} disabled={!onAddProperty} /> + }
) } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 4f40c373..7ad58467 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -70,22 +70,22 @@ const TYPE_COLOR_MAP: Record = { 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( diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 26724061..edfe5693 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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>> = { - Project: Wrench, - Experiment: Flask, - Responsibility: Target, - Procedure: ArrowsClockwise, - Person: Users, - Event: CalendarBlank, - Topic: Tag, - Type: StackSimple, -} - -function getTypeIcon(isA: string | undefined): ComponentType> { - 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 } -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 ( -
- {label} -
- {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 ( - - ) - })} -
-
- ) -} - -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(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 ( -
- {relationshipEntries.length === 0 ? ( -

No relationships

- ) : ( - relationshipEntries.map(({ key, refs }) => ( - - )) - )} - - {showForm ? ( -
- - {noteTitles.map(t => - setRelKey(e.target.value)} - /> - setRelTarget(e.target.value)} - /> -
- - -
-
- ) : ( - - )} -
- ) -} - function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allContent: Record): 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 ( -
-

- Backlinks {backlinks.length > 0 && {backlinks.length}} -

- {backlinks.length === 0 ? ( -

No backlinks

+
+ {collapsed ? ( + ) : ( -
- {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 ( - - ) - })} -
- )} -
- ) -} - -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 ( -
-

History

- {commits.length === 0 ? ( -

No revision history

- ) : ( -
- {commits.map((c) => ( -
-
- - {formatRelativeDate(c.date)} -
-
{c.message}
- {c.author && ( -
{c.author}
- )} -
- ))} -
+ <> + + Properties + + )}
) @@ -334,12 +63,8 @@ function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; function EmptyInspector() { return ( <> -
-

No note selected

-
-
-

No relationships

-
+

No note selected

+

No relationships

Backlinks

No backlinks

@@ -372,25 +97,8 @@ export function Inspector({ }, [entry, onAddProperty]) return ( -