Files
tolaria/src/components/Inspector.tsx
lucaronin 2eb0705f00 feat: show type label on relationship items in Inspector
Each related note in the Relationships section now displays the note
title on the left and the type label on the right.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:21:20 +01:00

289 lines
12 KiB
TypeScript

import { useMemo, useCallback } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
interface InspectorProps {
collapsed: boolean
onToggle: () => void
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
allContent: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
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 resolveRefType(ref: string, entries: VaultEntry[]): string | undefined {
const target = wikilinkTarget(ref)
const match = 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
})
return match?.isA
}
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="mb-1 block text-xs font-semibold text-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const refType = resolveRefType(ref, entries)
return (
<button
key={`${ref}-${idx}`}
className="flex items-center justify-between gap-2 border-none text-left text-primary cursor-pointer hover:opacity-80"
style={{ background: 'var(--accent-blue-light)', borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500 }}
onClick={() => onNavigate(wikilinkTarget(ref))}
>
<span className="flex-1 truncate">{wikilinkDisplay(ref)}</span>
{refType && <span className="shrink-0 text-[11px] text-muted-foreground">{refType}</span>}
</button>
)
})}
</div>
</div>
)
}
function DynamicRelationshipsPanel({ frontmatter, entries, onNavigate }: { frontmatter: ParsedFrontmatter; entries: VaultEntry[]; onNavigate: (target: string) => void }) {
const relationshipEntries = useMemo(() => {
return Object.entries(frontmatter)
.filter(([key, value]) => 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])
return (
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
{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} />
))
)}
<button
className="mt-2 w-full cursor-not-allowed border border-border bg-transparent text-center text-muted-foreground"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.6 }}
disabled title="Coming soon"
>
+ 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 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
})
}, [entry, entries, allContent])
}
function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) {
return (
<div>
<h4 className="mb-2 font-semibold text-foreground" style={{ fontSize: 12 }}>
Backlinks {backlinks.length > 0 && <span className="ml-1 text-muted-foreground" style={{ fontSize: 11, fontWeight: 500 }}>{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) => (
<button
key={e.path}
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] text-primary cursor-pointer hover:opacity-80"
onClick={() => onNavigate(e.title)}
>
<span className="flex-1 truncate">{e.title}</span>
{e.isA && <span className="shrink-0 text-[11px] text-muted-foreground">{e.isA}</span>}
</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 }: { commits: GitCommit[] }) {
return (
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide 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">
<span className="font-mono text-foreground" style={{ fontSize: 11 }}>{c.shortHash}</span>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
</div>
))}
</div>
<button className="mt-2.5 cursor-not-allowed border-none bg-transparent p-0 py-1 text-xs text-muted-foreground" disabled>
View all revisions
</button>
</>
)}
</div>
)
}
function EmptyInspector() {
return (
<>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
</div>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
</div>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backlinks</h4>
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
</div>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
</div>
</>
)
}
export function Inspector({
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const backlinks = useBacklinks(entry, entries, allContent)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => {
if (entry && onUpdateFrontmatter) onUpdateFrontmatter(entry.path, key, value)
}, [entry, onUpdateFrontmatter])
const handleDeleteProperty = useCallback((key: string) => {
if (entry && onDeleteProperty) onDeleteProperty(entry.path, key)
}, [entry, onDeleteProperty])
const handleAddProperty = useCallback((key: string, value: FrontmatterValue) => {
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
}, [entry, onAddProperty])
return (
<aside className={cn(
"flex 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>
{!collapsed && (
<div className="flex flex-col gap-4 p-3">
{entry ? (
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
/>
<DynamicRelationshipsPanel frontmatter={frontmatter} entries={entries} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} />
</>
) : (
<EmptyInspector />
)}
</div>
)}
</aside>
)
}