From 9cfd95694086c908f6a6e5176015b643ff4424ab Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:45:31 +0100 Subject: [PATCH 1/3] design: Referenced By panel wireframes Three frames showing the new bidirectional relationships UI: 1. Inspector with multiple references grouped by relationship type 2. Empty state (no references) 3. Many backlinks scenario with references from multiple relationship types Co-Authored-By: Claude Opus 4.6 --- design/relazioni-bidirezionali.pen | 775 +++++++++++++++++++++++++++++ 1 file changed, 775 insertions(+) create mode 100644 design/relazioni-bidirezionali.pen diff --git a/design/relazioni-bidirezionali.pen b/design/relazioni-bidirezionali.pen new file mode 100644 index 00000000..f1df35ad --- /dev/null +++ b/design/relazioni-bidirezionali.pen @@ -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": {} +} \ No newline at end of file From 5b91b25713fc01a3c5b5dcc51ac3e419e0213643 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:47:05 +0100 Subject: [PATCH 2/3] 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) From bd58557a712844411bc70f72a30e68fa95151217 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:48:26 +0100 Subject: [PATCH 3/3] test: add tests for ReferencedByPanel and useReferencedBy hook 14 new tests covering: - ReferencedByPanel component: empty state, grouping by viaKey, count badge, navigation, archived/trashed styling - useReferencedBy integration: forward relationship resolution, grouped display, count, navigation, Type exclusion, alias resolution, self-reference prevention Co-Authored-By: Claude Opus 4.6 --- src/components/Inspector.test.tsx | 246 ++++++++++++++++++++++++ src/components/InspectorPanels.test.tsx | 66 ++++++- 2 files changed, 311 insertions(+), 1 deletion(-) diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 0d771cfa..6ffc6abb 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -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( + + ) + 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( + + ) + expect(screen.getByText(/via Belongs to/)).toBeInTheDocument() + expect(screen.getByText(/via Related to/)).toBeInTheDocument() + }) + + it('shows count badge for referenced-by entries', () => { + render( + + ) + // 2 entries reference via Belongs to + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('shows "No references" when no entries reference the current note', () => { + render( + + ) + expect(screen.getByText('No references')).toBeInTheDocument() + }) + + it('navigates when clicking a referenced-by entry', () => { + const onNavigate = vi.fn() + render( + + ) + 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( + + ) + // 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( + + ) + 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( + + ) + expect(screen.getByText('No references')).toBeInTheDocument() + }) + }) }) diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index d57dfdb4..18d6cd4b 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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 => ({ @@ -225,6 +226,69 @@ describe('BacklinksPanel', () => { }) }) +describe('ReferencedByPanel', () => { + const onNavigate = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows "No references" when items is empty', () => { + render() + 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() + + 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() + 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() + 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() + 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() + expect(screen.getByTitle('Trashed')).toBeInTheDocument() + }) +}) + describe('GitHistoryPanel', () => { const onViewCommitDiff = vi.fn()