From 5b91b25713fc01a3c5b5dcc51ac3e419e0213643 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:47:05 +0100 Subject: [PATCH] feat: add Referenced By panel for bidirectional relationships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/Inspector.tsx | 38 +++++++++++++++++++- src/components/InspectorPanels.tsx | 58 +++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index edfe5693..f344a515 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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 (
@@ -65,6 +95,10 @@ function EmptyInspector() { <>

No note selected

No relationships

+
+

Referenced by

+

No references

+

Backlinks

No backlinks

@@ -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} /> + diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index c84693ac..12d8be91 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -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() + 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 ( +
+

+ Referenced by {items.length > 0 && {items.length}} +

+ {items.length === 0 + ?

No references

+ : ( +
+ {grouped.map(([viaKey, groupEntries]) => ( +
+ + via {viaKey} + +
+ {groupEntries.map((e) => ( + onNavigate(e.title)} + title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} + TypeIcon={getTypeIcon(e.isA ?? undefined)} + /> + ))} +
+
+ ))} +
+ ) + } +
+ ) +} + function formatRelativeDate(timestamp: number): string { const now = Math.floor(Date.now() / 1000) const days = Math.floor((now - timestamp) / 86400)