feat: add Referenced By panel for bidirectional relationships
Computed reverse relationships — purely frontend, no Rust changes. The useReferencedBy hook scans all entries' relationships maps to find those referencing the current note via frontmatter wikilinks. References are grouped by relationship key (e.g. "via Belongs to", "via Related to") and displayed between Relationships and Backlinks. Architecture decision: "Referenced by" is read-only. To remove a reverse reference, navigate to the source note. Deleting a note automatically removes it from all computed reverse references. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ import { cn } from '@/lib/utils'
|
||||
import { SlidersHorizontal, X } from '@phosphor-icons/react'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, wikilinkTarget } from './InspectorPanels'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
@@ -40,6 +41,35 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allConten
|
||||
}, [entry, entries, allContent])
|
||||
}
|
||||
|
||||
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') continue
|
||||
for (const ref of refs) {
|
||||
const target = wikilinkTarget(ref)
|
||||
if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) {
|
||||
results.push({ entry: other, viaKey: key })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}, [entry, entries])
|
||||
}
|
||||
|
||||
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }} data-tauri-drag-region>
|
||||
@@ -65,6 +95,10 @@ function EmptyInspector() {
|
||||
<>
|
||||
<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">Referenced by</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No references</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>
|
||||
@@ -82,6 +116,7 @@ export function Inspector({
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
}: InspectorProps) {
|
||||
const backlinks = useBacklinks(entry, entries, allContent)
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
|
||||
const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => {
|
||||
@@ -111,6 +146,7 @@ export function Inspector({
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<DynamicRelationshipsPanel frontmatter={frontmatter} entries={entries} onNavigate={onNavigate} onAddProperty={onAddProperty ? handleAddProperty : undefined} />
|
||||
<ReferencedByPanel items={referencedBy} onNavigate={onNavigate} />
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
</>
|
||||
|
||||
@@ -32,7 +32,7 @@ function wikilinkDisplay(ref: string): string {
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function wikilinkTarget(ref: string): string {
|
||||
export function wikilinkTarget(ref: string): string {
|
||||
const inner = ref.replace(/^\[\[|\]\]$/g, '')
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
@@ -217,6 +217,62 @@ export function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntr
|
||||
)
|
||||
}
|
||||
|
||||
export interface ReferencedByItem {
|
||||
entry: VaultEntry
|
||||
viaKey: string
|
||||
}
|
||||
|
||||
export function ReferencedByPanel({ items, onNavigate }: {
|
||||
items: ReferencedByItem[]
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry[]>()
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.viaKey)
|
||||
if (existing) existing.push(item.entry)
|
||||
else map.set(item.viaKey, [item.entry])
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">
|
||||
Referenced by {items.length > 0 && <span className="ml-1" style={{ fontWeight: 400 }}>{items.length}</span>}
|
||||
</h4>
|
||||
{items.length === 0
|
||||
? <p className="m-0 text-[13px] text-muted-foreground">No references</p>
|
||||
: (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
|
||||
via {viaKey}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.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>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp: number): string {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const days = Math.floor((now - timestamp) / 86400)
|
||||
|
||||
Reference in New Issue
Block a user