Files
tolaria/src/components/Inspector.tsx
Luca Rossi 9c8f7907b8 feat: enhance backlinks panel with collapsible section and paragraph context (#167)
The backlinks section in the Inspector is now collapsed by default with a
"Backlinks (N)" toggle header. Expanding reveals each backlink entry with
a paragraph preview showing the surrounding context of the [[wikilink]].

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:01:24 +01:00

178 lines
7.2 KiB
TypeScript

import { useMemo, useCallback } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import { extractBacklinkContext } from '../utils/wikilinks'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
export type FrontmatterValue = string | number | boolean | string[] | null
interface InspectorProps {
collapsed: boolean
onToggle: () => void
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
allContent?: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: 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>
}
function useBacklinks(
entry: VaultEntry | null,
entries: VaultEntry[],
referencedBy: ReferencedByItem[],
allContent?: Record<string, string>,
): BacklinkItem[] {
return useMemo(() => {
if (!entry) return []
const matchTargets = new Set([
entry.title, ...entry.aliases,
entry.filename.replace(/\.md$/, ''),
entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, ''),
])
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
return entries
.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
})
.map((e) => ({
entry: e,
context: allContent?.[e.path]
? extractBacklinkContext(allContent[e.path], matchTargets)
: null,
}))
}, [entry, entries, referencedBy, allContent])
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
return refs.some((ref) => {
const target = wikilinkTarget(ref)
return targets.has(target) || targets.has(target.split('/').pop() ?? '')
})
}
function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): ReferencedByItem[] {
return useMemo(() => {
if (!entry) return []
const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const filenameStem = entry.filename.replace(/\.md$/, '')
const matchTargets = new Set([pathStem, filenameStem, entry.title, ...entry.aliases])
const results: ReferencedByItem[] = []
for (const other of entries) {
if (other.path === entry.path) continue
for (const [key, refs] of Object.entries(other.relationships)) {
if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) {
results.push({ entry: other, viaKey: key })
}
}
}
return results
}, [entry, entries])
}
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
const { onMouseDown } = useDragRegion()
return (
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '6px 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
{collapsed ? (
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle}>
<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}>
<X size={16} />
</button>
</>
)}
</div>
)
}
function EmptyInspector() {
return (
<div><p className="m-0 text-[13px] text-muted-foreground">No note selected</p></div>
)
}
export function Inspector({
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
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 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-1 flex-col overflow-hidden 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-1 flex-col gap-4 overflow-y-auto p-3">
{entry ? (
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries} allContent={allContent}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onNavigate={onNavigate}
/>
<DynamicRelationshipsPanel
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
/>
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (
<EmptyInspector />
)}
</div>
)}
</aside>
)
}