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:
lucaronin
2026-02-23 20:47:05 +01:00
parent 7e65e0a0fb
commit 57f00f8e7b
2 changed files with 94 additions and 2 deletions

View File

@@ -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} />
</>

View File

@@ -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)