Merge pull request #25 from refactoringhq/task/relazioni-bidirezionali

feat: bidirectional relationships (Referenced By panel)
This commit is contained in:
Luca Rossi
2026-02-23 21:02:40 +01:00
committed by GitHub
5 changed files with 1180 additions and 3 deletions

View File

@@ -0,0 +1,775 @@
{
"children": [
{
"type": "frame",
"id": "rbF01",
"x": 0,
"y": 0,
"name": "Inspector — Referenced By (Multiple)",
"theme": { "Mode": "Light" },
"clip": true,
"width": 320,
"height": 700,
"fill": "$--background",
"layout": "vertical",
"gap": 16,
"padding": 16,
"children": [
{
"type": "frame",
"id": "rbF01h",
"name": "Inspector Header",
"width": "fill_container",
"height": 36,
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "rbF01hL",
"name": "leftGroup",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "rbF01hI",
"name": "propIcon",
"width": 16,
"height": 16,
"iconFontName": "sliders-horizontal",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "rbF01hT",
"name": "inspTitle",
"fill": "$--muted-foreground",
"content": "Properties",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
}
]
},
{
"type": "icon_font",
"id": "rbF01hX",
"name": "closeIcon",
"width": 16,
"height": 16,
"iconFontName": "x",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "rbF01rel",
"name": "Relationships Section",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "rbF01relT",
"fill": "$--muted-foreground",
"content": "BELONGS TO",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "frame",
"id": "rbF01relB",
"name": "relBtn",
"width": "fill_container",
"fill": "$--accent-blue-light",
"cornerRadius": 6,
"padding": [6, 10],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "rbF01relBT",
"fill": "$--accent-blue",
"content": "Grow Newsletter",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01relBI",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "target",
"iconFontFamily": "phosphor",
"fill": "$--accent-blue"
}
]
}
]
},
{
"type": "frame",
"id": "rbF01ref",
"name": "Referenced By Section",
"width": "fill_container",
"layout": "vertical",
"gap": 10,
"children": [
{
"type": "frame",
"id": "rbF01refH",
"name": "refByHeader",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "rbF01refHT",
"fill": "$--muted-foreground",
"content": "REFERENCED BY",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "text",
"id": "rbF01refHC",
"fill": "$--muted-foreground",
"content": "5",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "rbF01refG1",
"name": "refByGroup — via Belongs to",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "rbF01refG1T",
"fill": "$--muted-foreground",
"opacity": 0.7,
"content": "VIA BELONGS TO",
"fontFamily": "IBM Plex Mono",
"fontSize": 9
},
{
"type": "frame",
"id": "rbF01refG1B1",
"name": "refItem1",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "rbF01refG1B1T",
"fill": "$--accent-orange",
"content": "Write Weekly Essays",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01refG1B1I",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "arrows-clockwise",
"iconFontFamily": "phosphor",
"fill": "$--accent-orange"
}
]
},
{
"type": "frame",
"id": "rbF01refG1B2",
"name": "refItem2",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "rbF01refG1B2T",
"fill": "$--accent-purple",
"content": "On Writing Well",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01refG1B2I",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "file-text",
"iconFontFamily": "phosphor",
"fill": "$--accent-purple"
}
]
},
{
"type": "frame",
"id": "rbF01refG1B3",
"name": "refItem3",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "rbF01refG1B3T",
"fill": "$--accent-purple",
"content": "AI Agents Primer",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01refG1B3I",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "file-text",
"iconFontFamily": "phosphor",
"fill": "$--accent-purple"
}
]
}
]
},
{
"type": "frame",
"id": "rbF01refG2",
"name": "refByGroup — via Related to",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "rbF01refG2T",
"fill": "$--muted-foreground",
"opacity": 0.7,
"content": "VIA RELATED TO",
"fontFamily": "IBM Plex Mono",
"fontSize": 9
},
{
"type": "frame",
"id": "rbF01refG2B1",
"name": "refItem4",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "rbF01refG2B1T",
"fill": "$--accent-red",
"content": "Failed SEO Experiment",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01refG2B1I",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "flask",
"iconFontFamily": "phosphor",
"fill": "$--accent-red"
}
]
},
{
"type": "frame",
"id": "rbF01refG2B2",
"name": "refItem5",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "rbF01refG2B2T",
"fill": "$--accent-red",
"content": "Twitter Thread Experiment",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "rbF01refG2B2I",
"width": 14,
"height": 14,
"opacity": 0.5,
"iconFontName": "flask",
"iconFontFamily": "phosphor",
"fill": "$--accent-red"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "rbF01bl",
"name": "Backlinks Section",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "rbF01blT",
"fill": "$--muted-foreground",
"content": "BACKLINKS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "text",
"id": "rbF01blI1",
"fill": "$--primary",
"content": "Write Weekly Essays",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "rbF02",
"x": 360,
"y": 0,
"name": "Inspector — Referenced By (Empty)",
"theme": { "Mode": "Light" },
"clip": true,
"width": 320,
"height": 500,
"fill": "$--background",
"layout": "vertical",
"gap": 16,
"padding": 16,
"children": [
{
"type": "frame",
"id": "rbF02h",
"name": "Inspector Header",
"width": "fill_container",
"height": 36,
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "rbF02hL",
"name": "leftGroup",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "rbF02hI",
"width": 16,
"height": 16,
"iconFontName": "sliders-horizontal",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "rbF02hT",
"fill": "$--muted-foreground",
"content": "Properties",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
}
]
},
{
"type": "icon_font",
"id": "rbF02hX",
"width": 16,
"height": 16,
"iconFontName": "x",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "rbF02rel",
"name": "Relationships Section",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "rbF02relT",
"fill": "$--muted-foreground",
"content": "No relationships",
"fontFamily": "Inter",
"fontSize": 13
}
]
},
{
"type": "frame",
"id": "rbF02ref",
"name": "Referenced By Section (Empty)",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "rbF02refT",
"fill": "$--muted-foreground",
"content": "REFERENCED BY",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "text",
"id": "rbF02refE",
"fill": "$--muted-foreground",
"content": "No references",
"fontFamily": "Inter",
"fontSize": 13
}
]
},
{
"type": "frame",
"id": "rbF02bl",
"name": "Backlinks Section",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "rbF02blT",
"fill": "$--muted-foreground",
"content": "BACKLINKS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "text",
"id": "rbF02blE",
"fill": "$--muted-foreground",
"content": "No backlinks",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "frame",
"id": "rbF03",
"x": 720,
"y": 0,
"name": "Inspector — Referenced By (Many Backlinks)",
"theme": { "Mode": "Light" },
"clip": true,
"width": 320,
"height": 800,
"fill": "$--background",
"layout": "vertical",
"gap": 16,
"padding": 16,
"children": [
{
"type": "frame",
"id": "rbF03h",
"name": "Inspector Header",
"width": "fill_container",
"height": 36,
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "rbF03hL",
"name": "leftGroup",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "rbF03hI",
"width": 16,
"height": 16,
"iconFontName": "sliders-horizontal",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "rbF03hT",
"fill": "$--muted-foreground",
"content": "Properties",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
}
]
},
{
"type": "icon_font",
"id": "rbF03hX",
"width": 16,
"height": 16,
"iconFontName": "x",
"iconFontFamily": "phosphor",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "rbF03rel",
"name": "Relationships Section",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"children": [
{
"type": "frame",
"id": "rbF03relG1",
"name": "relGroup — Has",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "rbF03relG1T",
"fill": "$--muted-foreground",
"content": "HAS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "frame",
"id": "rbF03relG1B1",
"width": "fill_container",
"fill": "$--accent-purple-light",
"cornerRadius": 6,
"padding": [6, 10],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{ "type": "text", "id": "rbF03relG1B1T", "fill": "$--accent-purple", "content": "On Writing Well", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03relG1B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" }
]
},
{
"type": "frame",
"id": "rbF03relG1B2",
"width": "fill_container",
"fill": "$--accent-purple-light",
"cornerRadius": 6,
"padding": [6, 10],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{ "type": "text", "id": "rbF03relG1B2T", "fill": "$--accent-purple", "content": "Engineering Leadership 101", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03relG1B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" }
]
}
]
}
]
},
{
"type": "frame",
"id": "rbF03ref",
"name": "Referenced By Section (Many)",
"width": "fill_container",
"layout": "vertical",
"gap": 10,
"children": [
{
"type": "frame",
"id": "rbF03refH",
"name": "refByHeader",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "rbF03refHT",
"fill": "$--muted-foreground",
"content": "REFERENCED BY",
"fontFamily": "IBM Plex Mono",
"fontSize": 10
},
{
"type": "text",
"id": "rbF03refHC",
"fill": "$--muted-foreground",
"content": "8",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "rbF03refG1",
"name": "refByGroup — via Belongs to",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{ "type": "text", "id": "rbF03refG1T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA BELONGS TO", "fontFamily": "IBM Plex Mono", "fontSize": 9 },
{
"type": "frame", "id": "rbF03refG1B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG1B1T", "fill": "$--accent-orange", "content": "Write Weekly Essays", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG1B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "arrows-clockwise", "iconFontFamily": "phosphor", "fill": "$--accent-orange" }
]
},
{
"type": "frame", "id": "rbF03refG1B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG1B2T", "fill": "$--accent-purple", "content": "On Writing Well", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG1B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" }
]
},
{
"type": "frame", "id": "rbF03refG1B3", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG1B3T", "fill": "$--accent-purple", "content": "Engineering Leadership 101", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG1B3I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" }
]
},
{
"type": "frame", "id": "rbF03refG1B4", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG1B4T", "fill": "$--accent-purple", "content": "AI Agents Primer", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG1B4I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" }
]
}
]
},
{
"type": "frame",
"id": "rbF03refG2",
"name": "refByGroup — via Related to",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{ "type": "text", "id": "rbF03refG2T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA RELATED TO", "fontFamily": "IBM Plex Mono", "fontSize": 9 },
{
"type": "frame", "id": "rbF03refG2B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG2B1T", "fill": "$--accent-red", "content": "Failed SEO Experiment", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG2B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "flask", "iconFontFamily": "phosphor", "fill": "$--accent-red" }
]
},
{
"type": "frame", "id": "rbF03refG2B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG2B2T", "fill": "$--accent-red", "content": "Twitter Thread Experiment", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG2B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "flask", "iconFontFamily": "phosphor", "fill": "$--accent-red" }
]
}
]
},
{
"type": "frame",
"id": "rbF03refG3",
"name": "refByGroup — via Topics",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{ "type": "text", "id": "rbF03refG3T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA TOPICS", "fontFamily": "IBM Plex Mono", "fontSize": 9 },
{
"type": "frame", "id": "rbF03refG3B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG3B1T", "fill": "$--accent-blue", "content": "Build Laputa App", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG3B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "wrench", "iconFontFamily": "phosphor", "fill": "$--accent-blue" }
]
},
{
"type": "frame", "id": "rbF03refG3B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0],
"children": [
{ "type": "text", "id": "rbF03refG3B2T", "fill": "$--accent-blue", "content": "Newsletter Strategy 2026", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" },
{ "type": "icon_font", "id": "rbF03refG3B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "wrench", "iconFontFamily": "phosphor", "fill": "$--accent-blue" }
]
}
]
}
]
},
{
"type": "frame",
"id": "rbF03bl",
"name": "Backlinks Section",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "frame",
"id": "rbF03blH",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "text", "id": "rbF03blT", "fill": "$--muted-foreground", "content": "BACKLINKS", "fontFamily": "IBM Plex Mono", "fontSize": 10 },
{ "type": "text", "id": "rbF03blC", "fill": "$--muted-foreground", "content": "3", "fontFamily": "IBM Plex Mono", "fontSize": 10, "fontWeight": "normal" }
]
},
{ "type": "text", "id": "rbF03blI1", "fill": "$--primary", "content": "Write Weekly Essays", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" },
{ "type": "text", "id": "rbF03blI2", "fill": "$--primary", "content": "Monthly Review Template", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" },
{ "type": "text", "id": "rbF03blI3", "fill": "$--primary", "content": "Q1 Planning", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" }
]
}
]
}
],
"variables": {}
}

View File

@@ -324,4 +324,250 @@ This is a test note with some words to count.
expect(row.className).not.toContain('cursor-pointer')
})
})
describe('Referenced By (bidirectional relationships)', () => {
const targetEntry: VaultEntry = {
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
filename: 'grow-newsletter.md',
title: 'Grow Newsletter',
isA: 'Responsibility',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 500,
snippet: '',
relationships: { 'Type': ['[[type/responsibility]]'] },
icon: null,
color: null,
order: null,
}
const essayEntry: 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,
}
const procedureEntry: VaultEntry = {
path: '/Users/luca/Laputa/procedure/write-essays.md',
filename: 'write-essays.md',
title: 'Write Weekly Essays',
isA: 'Procedure',
aliases: [],
belongsTo: ['[[responsibility/grow-newsletter]]'],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 400,
snippet: '',
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/procedure]]'] },
icon: null,
color: null,
order: null,
}
const experimentEntry: VaultEntry = {
path: '/Users/luca/Laputa/experiment/seo.md',
filename: 'seo.md',
title: 'SEO Experiment',
isA: 'Experiment',
aliases: [],
belongsTo: [],
relatedTo: ['[[responsibility/grow-newsletter]]'],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 200,
snippet: '',
relationships: { 'Related to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/experiment]]'] },
icon: null,
color: null,
order: null,
}
const targetContent = `---
title: Grow Newsletter
is_a: Responsibility
Status: Active
---
# Grow Newsletter
`
it('shows entries that reference the current note via frontmatter relationships', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry, procedureEntry, experimentEntry]}
allContent={{}}
/>
)
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
})
it('groups referenced-by entries by relationship key', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry, experimentEntry]}
allContent={{}}
/>
)
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
})
it('shows count badge for referenced-by entries', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry, procedureEntry]}
allContent={{}}
/>
)
// 2 entries reference via Belongs to
expect(screen.getByText('2')).toBeInTheDocument()
})
it('shows "No references" when no entries reference the current note', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry]}
allContent={{}}
/>
)
expect(screen.getByText('No references')).toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
const onNavigate = vi.fn()
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry]}
allContent={{}}
onNavigate={onNavigate}
/>
)
fireEvent.click(screen.getByText('On Writing Well'))
expect(onNavigate).toHaveBeenCalledWith('On Writing Well')
})
it('skips Type relationships in referenced-by computation', () => {
const typeEntry: VaultEntry = {
...targetEntry,
path: '/Users/luca/Laputa/type/responsibility.md',
filename: 'responsibility.md',
title: 'Responsibility',
isA: 'Type',
relationships: {},
}
// essayEntry has Type: [[type/responsibility]] — should NOT show as referenced-by
render(
<Inspector
{...defaultProps}
entry={typeEntry}
content="---\nIs A: Type\n---\n# Responsibility\n"
entries={[typeEntry, essayEntry]}
allContent={{}}
/>
)
// On Writing Well references responsibility via "Belongs to" (path match), not via "Type"
// But the Type entry is at type/responsibility.md, so wikilinks to
// responsibility/grow-newsletter won't match. Should show "No references"
expect(screen.getByText('No references')).toBeInTheDocument()
})
it('resolves references via aliased wikilinks', () => {
const aliasedTarget: VaultEntry = {
...targetEntry,
aliases: ['Newsletter'],
}
const referrer: VaultEntry = {
...essayEntry,
relationships: { 'Topics': ['[[Newsletter]]'], 'Type': ['[[type/essay]]'] },
}
render(
<Inspector
{...defaultProps}
entry={aliasedTarget}
content={targetContent}
entries={[aliasedTarget, referrer]}
allContent={{}}
/>
)
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText(/via Topics/)).toBeInTheDocument()
})
it('does not show self-references', () => {
const selfRef: VaultEntry = {
...targetEntry,
relationships: {
...targetEntry.relationships,
'Notes': ['[[responsibility/grow-newsletter]]'],
},
}
render(
<Inspector
{...defaultProps}
entry={selfRef}
content={targetContent}
entries={[selfRef]}
allContent={{}}
/>
)
expect(screen.getByText('No references')).toBeInTheDocument()
})
})
})

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

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -225,6 +226,69 @@ describe('BacklinksPanel', () => {
})
})
describe('ReferencedByPanel', () => {
const onNavigate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('shows "No references" when items is empty', () => {
render(<ReferencedByPanel items={[]} onNavigate={onNavigate} />)
expect(screen.getByText('No references')).toBeInTheDocument()
})
it('renders referenced-by entries grouped by relationship key', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/proc/a.md', title: 'Write Essays', isA: 'Procedure' }), viaKey: 'Belongs to' },
{ entry: makeEntry({ path: '/vault/essay/b.md', title: 'On Writing Well', isA: 'Essay' }), viaKey: 'Belongs to' },
{ entry: makeEntry({ path: '/vault/exp/c.md', title: 'SEO Experiment', isA: 'Experiment' }), viaKey: 'Related to' },
]
render(<ReferencedByPanel items={items} onNavigate={onNavigate} />)
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
})
it('shows count badge when items exist', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'A' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/b.md', title: 'B' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/c.md', title: 'C' }), viaKey: 'Topics' },
]
render(<ReferencedByPanel items={items} onNavigate={onNavigate} />)
expect(screen.getByText('3')).toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'My Note' }), viaKey: 'Belongs to' },
]
render(<ReferencedByPanel items={items} onNavigate={onNavigate} />)
fireEvent.click(screen.getByText('My Note'))
expect(onNavigate).toHaveBeenCalledWith('My Note')
})
it('dims archived entries in referenced-by', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Old Note', archived: true }), viaKey: 'Has' },
]
render(<ReferencedByPanel items={items} onNavigate={onNavigate} />)
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('shows trashed indicator for trashed entries', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Trash Note', trashed: true }), viaKey: 'Has' },
]
render(<ReferencedByPanel items={items} onNavigate={onNavigate} />)
expect(screen.getByTitle('Trashed')).toBeInTheDocument()
})
})
describe('GitHistoryPanel', () => {
const onViewCommitDiff = vi.fn()

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)