fix: deduplicate inspector panels (Relations, Backlinks, Referenced By) (#82)

* fix: deduplicate inspector panels by excluding frontmatter from outgoing links

The extract_outgoing_links function was scanning the entire file content
including YAML frontmatter, causing wikilinks in frontmatter fields
(e.g. belongsTo, relatedTo) to appear in both the relationships map AND
outgoingLinks. This made the same note show up in both "Referenced By"
and "Backlinks" panels.

Backend: pass gray_matter's parsed body (no frontmatter) to
extract_outgoing_links instead of raw file content.

Frontend: filter useBacklinks to exclude entries already shown in
Referenced By, ensuring strictly non-overlapping panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger after disk space cleanup [skip precommit]

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 03:35:18 +01:00
committed by GitHub
parent ee663df4fe
commit 807a4904a2
3 changed files with 51 additions and 7 deletions

View File

@@ -60,7 +60,7 @@ pub struct VaultEntry {
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// All wikilink targets found in the note content (body + frontmatter).
/// All wikilink targets found in the note body (excludes frontmatter).
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
@@ -268,7 +268,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let title = extract_title(&parsed.content, &filename);
let snippet = extract_snippet(&content);
let outgoing_links = extract_outgoing_links(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
let (modified_at, file_size) = read_file_metadata(path)?;
let created_at = parse_created_at(&frontmatter);
let is_a = resolve_is_a(frontmatter.is_a, path);
@@ -951,11 +951,11 @@ References:
}
#[test]
fn test_outgoing_links_includes_frontmatter_wikilinks() {
fn test_outgoing_links_excludes_frontmatter_wikilinks() {
let dir = TempDir::new().unwrap();
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/bob]].";
let entry = parse_test_entry(&dir, "note/test.md", content);
assert!(entry.outgoing_links.contains(&"task/design".to_string()));
assert!(!entry.outgoing_links.contains(&"task/design".to_string()));
assert!(entry.outgoing_links.contains(&"person/bob".to_string()));
}

View File

@@ -575,6 +575,47 @@ Status: Active
expect(screen.getByText(/via Topics/)).toBeInTheDocument()
})
it('excludes entries from backlinks when already shown in referenced-by', () => {
const noteA: VaultEntry = {
path: '/Users/luca/Laputa/essay/on-writing.md',
filename: 'on-writing.md',
title: 'On Writing Well',
isA: 'Essay',
aliases: [],
belongsTo: ['[[responsibility/grow-newsletter]]'],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 300,
snippet: '',
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
icon: null,
color: null,
order: null,
// Body text also links to grow-newsletter
outgoingLinks: ['responsibility/grow-newsletter'],
}
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, noteA]}
/>
)
// noteA shows in Referenced By (via Belongs to)
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches)
expect(screen.getByText('No backlinks')).toBeInTheDocument()
})
it('does not show self-references', () => {
const selfRef: VaultEntry = {
...targetEntry,

View File

@@ -25,7 +25,7 @@ interface InspectorProps {
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
}
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[]): VaultEntry[] {
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
return useMemo(() => {
if (!entry) return []
const matchTargets = new Set([
@@ -34,13 +34,16 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[]): VaultEnt
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() ?? '')
)
})
}, [entry, entries])
}, [entry, entries, referencedBy])
}
function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): ReferencedByItem[] {
@@ -118,8 +121,8 @@ export function Inspector({
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const backlinks = useBacklinks(entry, entries)
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}