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>
178 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|