new plug: graph view (imported from separate repo)

This commit is contained in:
Zef Hemel
2026-06-09 17:10:16 +02:00
parent b1f7343f28
commit b7d690905e
26 changed files with 3795 additions and 2 deletions
+1
View File
@@ -62,6 +62,7 @@ clean:
rm -f silverbullet silverbullet-arm64 silverbullet-amd64 silverbullet-arm silverbullet.exe silverbullet-server-*.zip
rm -f sb sb.exe sb-*.zip
rm -rf plugs/configuration-manager/assets
rm -rf plugs/object-graph/assets
check:
npm run check
+359
View File
@@ -34,9 +34,11 @@
"@replit/codemirror-vim": "6.3.0",
"commander": "^14.0.3",
"crelt": "1.0.6",
"d3-force": "^3.0.0",
"esbuild": "^0.27.3",
"fast-diff": "1.3.0",
"fast-glob": "^3.3.3",
"force-graph": "^1.49.0",
"gitignore-parser": "0.0.2",
"idb": "8.0.3",
"js-yaml": "^4.1.1",
@@ -55,6 +57,7 @@
"@biomejs/biome": "2.4.6",
"@playwright/test": "^1.58.2",
"@preact/preset-vite": "^2.10.3",
"@types/d3-force": "^3.0.10",
"@types/gitignore-parser": "^0.0.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
@@ -2102,6 +2105,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2113,6 +2122,13 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2279,6 +2295,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2315,6 +2340,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bezier-js": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -2389,6 +2424,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-color-tracker": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
"license": "MIT",
"dependencies": {
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2466,6 +2513,236 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2719,6 +2996,46 @@
"node": ">=8"
}
},
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/force-graph": {
"version": "1.51.4",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"bezier-js": "3 - 6",
"canvas-color-tracker": "^1.3",
"d3-array": "1 - 3",
"d3-drag": "2 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"d3-selection": "2 - 3",
"d3-zoom": "2 - 3",
"float-tooltip": "^1.7",
"index-array-by": "1",
"kapsule": "^1.16",
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2799,6 +3116,24 @@
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"license": "MIT"
},
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2874,6 +3209,18 @@
"node": ">=6"
}
},
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -2881,6 +3228,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3396,6 +3749,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+3
View File
@@ -96,8 +96,10 @@
"@replit/codemirror-vim": "6.3.0",
"commander": "^14.0.3",
"crelt": "1.0.6",
"d3-force": "^3.0.0",
"esbuild": "^0.27.3",
"fast-diff": "1.3.0",
"force-graph": "^1.49.0",
"fast-glob": "^3.3.3",
"gitignore-parser": "0.0.2",
"idb": "8.0.3",
@@ -114,6 +116,7 @@
"@biomejs/biome": "2.4.6",
"@playwright/test": "^1.58.2",
"@preact/preset-vite": "^2.10.3",
"@types/d3-force": "^3.0.10",
"@types/gitignore-parser": "^0.0.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
+1
View File
@@ -7,6 +7,7 @@ export const builtinPlugNames = [
"emoji",
"image-viewer",
"configuration-manager",
"object-graph",
];
export const builtinPlugPaths = builtinPlugNames.map(
+1
View File
@@ -0,0 +1 @@
assets/
+22
View File
@@ -0,0 +1,22 @@
name: object-graph
build:
- in: ./ui/index.tsx
out: ./assets/object-graph.js
- type: sass
in: ./ui/object-graph.scss
out: ./assets/object-graph.css
assets:
- "assets/**"
functions:
showGraph:
path: ./src/object_graph.ts:showGraph
command:
name: "Graph: Explore"
key: "Ctrl-Shift-g"
mac: "Cmd-Shift-g"
showGlobalGraph:
path: ./src/object_graph.ts:showGlobalGraph
command:
name: "Graph: Global Page Map"
expandObject:
path: ./src/object_graph.ts:expandObject
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import {
classifyKind,
collapseEdges,
deriveTagFields,
endpointMatchesRef,
isCoreTag,
type RelationRow,
stripPagePos,
} from "./graph_builder.ts";
describe("stripPagePos", () => {
it("returns bare refs unchanged", () => {
expect(stripPagePos("Page")).toBe("Page");
expect(stripPagePos("Drivers/Max Verstappen")).toBe(
"Drivers/Max Verstappen",
);
});
it("strips @pos, #header, $anchor", () => {
expect(stripPagePos("Page@744")).toBe("Page");
expect(stripPagePos("Page#header")).toBe("Page");
expect(stripPagePos("Page$anchor")).toBe("Page");
});
it("leaves url and file refs untouched", () => {
expect(stripPagePos("https://example.com/path#x")).toBe(
"https://example.com/path#x",
);
expect(stripPagePos("docs.pdf")).toBe("docs.pdf");
});
});
describe("endpointMatchesRef", () => {
it("matches exact refs", () => {
expect(endpointMatchesRef("Page", "Page")).toBe(true);
});
it("matches positional / header / anchor variants", () => {
expect(endpointMatchesRef("Page@123", "Page")).toBe(true);
expect(endpointMatchesRef("Page#h1", "Page")).toBe(true);
expect(endpointMatchesRef("Page$anchor", "Page")).toBe(true);
});
it("rejects prefix overlap that isn't a real boundary", () => {
expect(endpointMatchesRef("Pages", "Page")).toBe(false);
expect(endpointMatchesRef("Page/sub", "Page")).toBe(false);
expect(endpointMatchesRef("Page-x", "Page")).toBe(false);
});
it("rejects unrelated endpoints", () => {
expect(endpointMatchesRef("Other", "Page")).toBe(false);
});
});
describe("classifyKind", () => {
it("identifies URLs", () => {
expect(classifyKind("https://example.com")).toBe("url");
expect(classifyKind("http://localhost:3000")).toBe("url");
});
it("identifies files (single dot, non-md)", () => {
expect(classifyKind("doc.pdf")).toBe("file");
expect(classifyKind("notes.txt")).toBe("file");
});
it("treats .md and pathy refs as pages", () => {
expect(classifyKind("Notes")).toBe("page");
expect(classifyKind("Notes/2025")).toBe("page");
expect(classifyKind("Notes.md")).toBe("page");
});
});
describe("isCoreTag", () => {
it("recognises structural tags", () => {
expect(isCoreTag("page")).toBe(true);
expect(isCoreTag("item")).toBe(true);
expect(isCoreTag("task")).toBe(true);
expect(isCoreTag("block")).toBe(true);
});
it("recognises meta and meta/* tags", () => {
expect(isCoreTag("meta")).toBe(true);
expect(isCoreTag("meta/library")).toBe(true);
});
it("rejects user tags", () => {
expect(isCoreTag("driver")).toBe(false);
expect(isCoreTag("project")).toBe(false);
});
});
describe("deriveTagFields", () => {
it("uses hostTag as rootTag for ordinary structural objects", () => {
const r = deriveTagFields(["page", "driver"], "page");
expect(r.rootTag).toBe("page");
expect(r.primaryTag).toBe("driver");
expect(r.tags).toEqual(["driver"]);
});
it("promotes item to task when the task tag is present", () => {
const r = deriveTagFields(["item", "task", "todo"], "item");
expect(r.rootTag).toBe("task");
expect(r.primaryTag).toBe("todo");
});
it("filters core + meta tags out of the user-tag list", () => {
const r = deriveTagFields(
["page", "meta", "meta/template", "research"],
"page",
);
expect(r.tags).toEqual(["research"]);
expect(r.primaryTag).toBe("research");
});
it("returns null rootTag for non-core host tags", () => {
expect(deriveTagFields(["foo"], "foo").rootTag).toBeNull();
});
});
describe("collapseEdges", () => {
const baseRow = (overrides: Partial<RelationRow>): RelationRow => ({
from: "A",
to: "B",
kind: "mention",
page: "A",
...overrides,
});
it("passes through a single mention as-is", () => {
const out = collapseEdges([baseRow({})]);
expect(out).toHaveLength(1);
expect(out[0].source).toBe("A");
expect(out[0].target).toBe("B");
expect(out[0].label).toBe("mention");
expect(out[0].undirected).toBe(false);
});
it("collapses reciprocal co-mention pairs into one undirected edge", () => {
const rows: RelationRow[] = [
baseRow({ kind: "co-mention", via: "page1" }),
baseRow({
from: "B",
to: "A",
kind: "co-mention",
via: "page1",
page: "page1",
}),
];
const out = collapseEdges(rows);
expect(out).toHaveLength(1);
expect(out[0].undirected).toBe(true);
expect(out[0].refs).toHaveLength(2);
});
it("keeps distinct co-mention pairs separate when 'via' differs", () => {
const rows: RelationRow[] = [
baseRow({ kind: "co-mention", via: "p1" }),
baseRow({
from: "B",
to: "A",
kind: "co-mention",
via: "p2",
}),
];
expect(collapseEdges(rows)).toHaveLength(2);
});
it("uses the relation kind as the edge label", () => {
const out = collapseEdges([baseRow({ kind: "principal" })]);
expect(out[0].label).toBe("principal");
});
it("preserves provenance positions from the range tuple", () => {
const out = collapseEdges([baseRow({ range: [42, 60], snippet: "hello" })]);
expect(out[0].refs[0]).toEqual({ page: "A", pos: 42, snippet: "hello" });
});
});
+401
View File
@@ -0,0 +1,401 @@
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
Edge,
EdgeProvenance,
ExpansionResult,
GraphUniverse,
ObjectKind,
ObjectNode,
} from "./model.ts";
/**
* Core tags published by SilverBullet's indexer. These are NEVER shown in
* the "Tags" sidebar (which is reserved for user tags); they appear in the
* "Root tags" sidebar instead, and a single one is stored as each node's
* `rootTag`.
*/
const CORE_TAGS = new Set([
"page",
"item",
"task",
"header",
"paragraph",
"block",
"meta",
"aspiring-page",
"document",
"url",
]);
export function isCoreTag(t: string): boolean {
return CORE_TAGS.has(t) || t.startsWith("meta/");
}
export type RelationRow = {
from: string;
to: string;
fromTag?: string;
toTag?: string;
kind: string;
via?: string;
page: string;
range?: [number, number];
snippet?: string;
};
type IndexedObject = {
ref?: string;
name?: string;
tag?: string;
tags?: string[];
pageDecoration?: { prefix?: string };
[k: string]: unknown;
};
/**
* Strip positional / header / anchor suffixes from a page-style ref so a
* link to `Page@744` (or `Page#header`, `Page$anchor`) lands on the bare
* `Page` node. URL / file refs are untouched.
*/
export function stripPagePos(ref: string): string {
if (classifyKind(ref) !== "page") return ref;
return ref.split(/[@#$]/)[0] || ref;
}
/**
* Graph cache populated lazily on the first call and reused across every
* subsequent `expandObject` / `buildGlobalGraph` round-trip the panel makes.
* SilverBullet's `index.queryLuaObjects` isn't cheap; one bulk pull + in-memory filter is much faster overall than
* a per-ref query for every expansion. The cache is cleared by
* `invalidateGraphCache()` at the start of each top-level command so a
* freshly opened modal sees fresh data.
*/
type GraphCache = {
relations: RelationRow[];
pagesByName: Map<string, IndexedObject>;
};
let cache: GraphCache | null = null;
export function invalidateGraphCache(): void {
cache = null;
}
async function ensureCache(): Promise<GraphCache> {
if (cache) return cache;
const [relations, pages] = (await Promise.all([
index.queryLuaObjects<RelationRow>("relation", { objectVariable: "r" }),
index.queryLuaObjects<IndexedObject>("page", { objectVariable: "p" }),
])) as [RelationRow[], IndexedObject[]];
const pagesByName = new Map<string, IndexedObject>();
for (const p of pages) {
const name = String(p.name ?? "");
if (name) pagesByName.set(name, p);
}
cache = { relations, pagesByName };
return cache;
}
/**
* Match an edge endpoint against a bare ref, including positional /
* header / anchor variants (`Page@123`, `Page#header`, `Page$anchor`).
*/
export function endpointMatchesRef(endpoint: string, ref: string): boolean {
if (endpoint === ref) return true;
if (!endpoint.startsWith(ref)) return false;
const c = endpoint.charAt(ref.length);
return c === "@" || c === "#" || c === "$";
}
async function queryRelations(ref: string): Promise<RelationRow[]> {
const { relations } = await ensureCache();
return relations.filter(
(r) => endpointMatchesRef(r.from, ref) || endpointMatchesRef(r.to, ref),
);
}
/**
* Collapse co-mention pairs in opposite directions sharing the same `via`
* into a single undirected edge. Other parallel edges (different `kind`)
* remain distinct.
*/
export function collapseEdges(rows: RelationRow[]): Edge[] {
const out: Edge[] = [];
const seenCoMention = new Map<string, number>(); // key -> out index
for (const r of rows) {
const label = r.kind;
const prov: EdgeProvenance = {
page: r.page,
pos: r.range?.[0],
snippet: r.snippet,
};
if (r.kind === "co-mention") {
const k = [r.from, r.to, r.via ?? ""].sort().join(" ");
const existing = seenCoMention.get(k);
if (existing !== undefined) {
out[existing].refs.push(prov);
out[existing].undirected = true;
continue;
}
seenCoMention.set(k, out.length);
out.push({
source: r.from,
target: r.to,
label,
kind: r.kind,
refs: [prov],
undirected: false, // becomes true when the reverse pair lands
});
continue;
}
out.push({
source: r.from,
target: r.to,
label,
kind: r.kind,
refs: [prov],
undirected: false,
});
}
return out;
}
/**
* Resolve a ref to an indexed object. Pages are served instantly from the
* in-memory cache (the common case). Items and blocks fall back to a
* single targeted query each — they're rare as relation endpoints, so
* pre-loading all of them would cost more than the occasional miss.
*/
async function findIndexed(
ref: string,
): Promise<{ tag: string; obj: IndexedObject } | null> {
const { pagesByName } = await ensureCache();
const page = pagesByName.get(ref);
if (page) return { tag: "page", obj: page };
const byRefExpr = await lua.parseExpression("o.ref == ref");
const queryFor = (tag: string) =>
index.queryLuaObjects<IndexedObject>(
tag,
{ objectVariable: "o", where: byRefExpr },
{ ref },
) as Promise<IndexedObject[]>;
const [items, blocks] = await Promise.all([
queryFor("item"),
queryFor("block"),
]);
if (items.length) return { tag: "item", obj: items[0] };
if (blocks.length) return { tag: "block", obj: blocks[0] };
return null;
}
export function classifyKind(ref: string): ObjectKind {
if (/^https?:\/\//.test(ref)) return "url";
if (/^[^/]+\.[^/]+$/.test(ref) && !ref.endsWith(".md")) return "file";
return "page";
}
function deriveTitle(
ref: string,
kind: ObjectKind,
obj: IndexedObject | null,
): string {
if (obj?.name) return String(obj.name);
if (kind === "url" || kind === "file") return ref.split(/[\\/]/).pop() ?? ref;
return ref;
}
export function deriveTagFields(
allTags: string[],
hostTag: string,
): {
rootTag: string | null;
primaryTag: string | null;
tags: string[];
} {
// `hostTag` is the structural tag the indexer keyed on (page/item/block).
// SilverBullet's indexer also exposes additional user tags via `tags`.
const hasTask = allTags.includes("task");
let rootTag: string | null;
if (hostTag === "item" && hasTask) rootTag = "task";
else if (CORE_TAGS.has(hostTag)) rootTag = hostTag;
else rootTag = null;
const userTags = allTags.filter((t) => !isCoreTag(t));
return {
rootTag,
primaryTag: userTags[0] ?? null,
tags: userTags,
};
}
// Project an already-fetched indexed object into the panel's ObjectNode shape.
function projectIndexed(
ref: string,
structuralTag: string,
obj: IndexedObject,
): ObjectNode {
const tagsField = Array.isArray(obj.tags) ? obj.tags.map(String) : [];
const allTags = tagsField.includes(structuralTag)
? tagsField
: [structuralTag, ...tagsField];
const { rootTag, primaryTag, tags } = deriveTagFields(allTags, structuralTag);
const kind: ObjectKind =
structuralTag === "page"
? "page"
: structuralTag === "item" || structuralTag === "task"
? "item"
: "block";
return {
ref,
kind,
title: deriveTitle(ref, kind, obj),
rootTag,
primaryTag,
tags,
dangling: false,
prefix: obj.pageDecoration?.prefix,
attributes: obj as Record<string, unknown>,
};
}
function stubNode(
ref: string,
kind: ObjectKind,
dangling: boolean,
): ObjectNode {
return {
ref,
kind,
title: deriveTitle(ref, kind, null),
rootTag: null,
primaryTag: null,
tags: [],
dangling,
attributes: { ref, ...(dangling ? { dangling: true } : {}) },
};
}
async function resolveObject(ref: string): Promise<ObjectNode> {
const kindHint = classifyKind(ref);
if (kindHint === "url" || kindHint === "file") {
return stubNode(ref, kindHint, false);
}
const found = await findIndexed(ref);
if (!found) return stubNode(ref, "page", true);
return projectIndexed(ref, found.tag, found.obj);
}
export async function expandObject(ref: string): Promise<ExpansionResult> {
const normRef = stripPagePos(ref);
const rows = await queryRelations(normRef);
// Normalize endpoints so positional variants (`Page@123`) collapse to the
// bare page ref across the whole object graph.
const edges = collapseEdges(rows).map((e) => ({
...e,
source: stripPagePos(e.source),
target: stripPagePos(e.target),
}));
const neighborRefs = new Set<string>();
for (const e of edges) {
if (e.source !== normRef) neighborRefs.add(e.source);
if (e.target !== normRef) neighborRefs.add(e.target);
}
const [object, neighbors] = await Promise.all([
resolveObject(normRef),
Promise.all([...neighborRefs].map(resolveObject)),
]);
return { object, neighbors, edges };
}
/**
* Build the data for the global view: every page + every relation in the
* space. The caller is expected to default the label filter to "mention
* only" — but the data is here so the user can toggle other labels on
* without a refetch. `rootRef` picks the anchor (current page if available).
*/
export async function buildGlobalGraph(
rootRef: string | null,
): Promise<ExpansionResult | null> {
const { relations, pagesByName } = await ensureCache();
const allPages = [...pagesByName.values()];
// Equivalent of `index.contentPages()`: drop meta-tagged pages.
const isMeta = (p: IndexedObject) => {
const tags = Array.isArray(p.tags) ? (p.tags as string[]) : [];
return tags.some((t) => t === "meta" || t.startsWith("meta/"));
};
const pages = allPages.filter((p) => !isMeta(p));
const metaRefs = new Set(
allPages.filter(isMeta).map((p) => String(p.name ?? "")),
);
if (pages.length === 0) return null;
const rows = relations;
const nodeByRef = new Map<string, ObjectNode>();
for (const p of pages) {
const ref = String(p.name ?? p.ref ?? "");
if (!ref) continue;
nodeByRef.set(ref, projectIndexed(ref, "page", p));
}
// Drop edges that touch meta pages, and constrain the global view to
// page-to-page edges only: URL / file / item / block endpoints are
// out of scope here. They'd otherwise drag in non-page nodes (random
// URLs and so on) that the Global Page Map promises not to show.
const edges = collapseEdges(rows)
.map((e) => ({
...e,
source: stripPagePos(e.source),
target: stripPagePos(e.target),
}))
.filter(
(e) =>
!metaRefs.has(e.source) &&
!metaRefs.has(e.target) &&
nodeByRef.has(e.source) &&
nodeByRef.has(e.target),
);
// Choose the root: prefer the caller-supplied ref when available;
// otherwise pick an arbitrary page (first by name).
const anchor =
rootRef && nodeByRef.has(rootRef)
? rootRef
: [...nodeByRef.keys()].sort()[0];
const object = nodeByRef.get(anchor)!;
const neighbors = [...nodeByRef.values()].filter((n) => n.ref !== anchor);
return { object, neighbors, edges };
}
/**
* Distinct root tags, user tags, and relation labels present in the space.
* Drives the sidebar's option lists so checkboxes show every value the
* user might want to toggle, not just what's in the current explored set.
*/
export async function buildUniverse(): Promise<GraphUniverse> {
const { relations, pagesByName } = await ensureCache();
// User tags: every non-core tag attached to any page in the space.
const tagSet = new Set<string>();
for (const p of pagesByName.values()) {
const tags = Array.isArray(p.tags) ? (p.tags as string[]) : [];
for (const t of tags) {
if (isCoreTag(t)) continue;
tagSet.add(t);
}
}
const tags = [...tagSet].sort();
// Relation labels: distinct `kind` across all relations.
const labelSet = new Set<string>();
for (const r of relations) labelSet.add(r.kind);
const labels = [...labelSet].sort();
return { tags, labels };
}
+27
View File
@@ -0,0 +1,27 @@
import { asset } from "@silverbulletmd/silverbullet/syscalls";
import { panelStyles } from "@silverbulletmd/silverbullet/ui";
import type { RootViewModel } from "./model.ts";
const PLUG_NAME = "object-graph";
export async function buildGraphHtml(
view: RootViewModel,
): Promise<{ html: string; script: string }> {
const [preamble, css, jsTemplate] = await Promise.all([
// Shared component stylesheet (`.sb-*`) + the user's space styles, so the
// standard UI components render themed; our own CSS is appended after so
// it stays highest-precedence for graph-specific styling.
panelStyles(),
asset.readAsset(PLUG_NAME, "assets/object-graph.css"),
asset.readAsset(PLUG_NAME, "assets/object-graph.js"),
]);
const html =
`${preamble}<style>${css}</style><div id="graph-root" tabindex="-1"></div>`;
// `var` (not `const`) so the declaration hoists into the eval'd scope and
// is lexically visible to the IIFE the bundled script wraps us in.
const script = `var __GRAPH = ${JSON.stringify(view)};\n${jsTemplate}`;
return { html, script };
}
+114
View File
@@ -0,0 +1,114 @@
// The relation `kind` is either a reserved structural value (`mention`,
// `co-mention`) or a user predicate (e.g. `spouse`). Mirrors `plugs/index/relation.ts`.
export const STRUCTURAL_KINDS = new Set(["mention", "co-mention"]);
export type ObjectKind = "page" | "item" | "block" | "url" | "file";
/**
* A node in the explored graph. Carries enough data for the panel to render
* the node, drive filters, and populate the right-panel object view.
*/
export type ObjectNode = {
ref: string;
kind: ObjectKind;
title: string;
// Single core tag (page/item/task/block); drives the Root-tags filter.
rootTag: string | null;
// First non-core tag; drives node color. null when none.
primaryTag: string | null;
// All non-core tags; drives the Tags filter.
tags: string[];
dangling: boolean;
// pageDecoration.prefix passthrough (e.g. emoji prefix).
prefix?: string;
// Full indexed payload, rendered verbatim into the object view.
attributes: Record<string, unknown>;
};
export type EdgeProvenance = {
page: string;
pos?: number;
snippet?: string;
};
/**
* One edge between two ObjectNodes. Parallel typed edges between the same
* pair stay distinct. Co-mention pairs in opposite directions are collapsed
* into one record with `undirected: true`.
*/
export type Edge = {
source: string;
target: string;
label: string; // == kind
kind: string;
refs: EdgeProvenance[];
undirected: boolean;
};
export type ExpansionResult = {
object: ObjectNode;
neighbors: ObjectNode[];
edges: Edge[];
};
export type Filters = {
hiddenTags: string[];
hiddenLabels: string[];
// When true, edge labels on the canvas are suppressed entirely.
hideEdgeLabels: boolean;
// When true, nodes with no visible incoming or outgoing relation in
// the current filter set are hidden (except the root).
hideOrphans: boolean;
};
// Tunable force-simulation knobs exposed as sliders in the sidebar.
export type ForceSettings = {
centerStrength: number;
chargeStrength: number;
linkDistance: number;
linkStrength: number;
};
export const defaultForceSettings: ForceSettings = {
centerStrength: 0.18,
chargeStrength: -430,
linkDistance: 223,
linkStrength: 0.1,
};
export const defaultFilters: Filters = {
hiddenTags: [],
hiddenLabels: [],
hideEdgeLabels: false,
hideOrphans: true,
};
/**
* Universe of filter options that exist in the whole space, regardless of
* what the user has currently explored. Drives the sidebar's option lists
* so checkboxes for not-yet-visible tags / labels remain togglable.
* Counts in the sidebar still reflect the explored subgraph.
*/
export type GraphUniverse = {
tags: string[];
labels: string[];
};
export type RootViewModel = {
root: ExpansionResult;
universe: GraphUniverse;
filters: Filters;
forces: ForceSettings;
/**
* When true, the panel marks all of `root.neighbors` as already-expanded
* (instead of the default ghost state). Used by the global view, which
* ships every page at once and lets the user trim down rather than walk out.
*/
initialAllExpanded?: boolean;
/**
* When false, the panel does not persist in-session filter changes back
* to the datastore. Used by transient views like the global view so they
* don't pollute the local-view filter preferences.
*/
persistFilters?: boolean;
};
+99
View File
@@ -0,0 +1,99 @@
import { datastore, editor } from "@silverbulletmd/silverbullet/syscalls";
import {
buildGlobalGraph,
buildUniverse,
expandObject as expandObjectImpl,
invalidateGraphCache,
} from "./graph_builder.ts";
import { buildGraphHtml } from "./graph_html.ts";
import {
defaultFilters,
defaultForceSettings,
type ExpansionResult,
type Filters,
type ForceSettings,
type RootViewModel,
} from "./model.ts";
const FILTERS_KEY = ["object-graph", "filters"];
const FORCES_KEY = ["object-graph", "forces"];
async function loadFilters(): Promise<Filters> {
const raw = (await datastore.get(FILTERS_KEY)) as
| Partial<Filters>
| undefined;
if (!raw) return defaultFilters;
return { ...defaultFilters, ...raw } as Filters;
}
async function loadForces(): Promise<ForceSettings> {
const raw = (await datastore.get(FORCES_KEY)) as
| Partial<ForceSettings>
| undefined;
if (!raw) return defaultForceSettings;
return { ...defaultForceSettings, ...raw } as ForceSettings;
}
export async function showGraph() {
const currentPage = await editor.getCurrentPage();
if (!currentPage) {
await editor.flashNotification("Graph: no current page", "error");
return;
}
// Each top-level open of the modal gets fresh data. Subsequent
// `expandObject` round-trips from the panel reuse the populated cache.
invalidateGraphCache();
const [root, universe, filters, forces] = await Promise.all([
expandObjectImpl(currentPage),
buildUniverse(),
loadFilters(),
loadForces(),
]);
const view: RootViewModel = { root, universe, filters, forces };
const { html, script } = await buildGraphHtml(view);
await editor.showPanel("modal", 100, html, script);
}
/**
* Called from the panel via system.invokeFunction("object-graph.expandObject", ref).
* Pure: takes a ref, returns an ExpansionResult, no side effects.
*/
export async function expandObject(ref: string): Promise<ExpansionResult> {
return expandObjectImpl(ref);
}
/**
* "Graph: Global Page Map" — all pages + every relation in the space,
* pre-expanded. Filter state defaults to "mention only" and is not
* persisted (so the local view's saved filters aren't disturbed).
*/
export async function showGlobalGraph() {
invalidateGraphCache();
const [root, universe] = await Promise.all([
buildGlobalGraph((await editor.getCurrentPage()) ?? null),
buildUniverse(),
]);
if (!root) {
await editor.flashNotification("Graph: no pages in space", "error");
return;
}
// Default the global view to "mention only" — every other relation
// label in the universe is hidden but can be toggled on without a
// refetch (the full relation set is already loaded).
const hiddenLabels = universe.labels.filter((l) => l !== "mention");
const view: RootViewModel = {
root,
universe,
filters: {
hiddenTags: [],
hiddenLabels,
hideEdgeLabels: true,
hideOrphans: false,
},
forces: await loadForces(),
initialAllExpanded: true,
persistFilters: false,
};
const { html, script } = await buildGraphHtml(view);
await editor.showPanel("modal", 100, html, script);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { colorForTag, UNTAGGED_COLOR_VAR } from "./colors.ts";
describe("colorForTag", () => {
it("returns the untagged CSS variable for null", () => {
expect(colorForTag(null)).toBe(UNTAGGED_COLOR_VAR);
});
it("is deterministic — same tag yields the same color", () => {
expect(colorForTag("driver")).toBe(colorForTag("driver"));
expect(colorForTag("research")).toBe(colorForTag("research"));
});
it("produces different colors for distinct tags (typically)", () => {
// Hash collisions are possible in principle; the F1 demo tags do not collide.
expect(colorForTag("driver")).not.toBe(colorForTag("team"));
expect(colorForTag("driver")).not.toBe(colorForTag("person"));
});
it("returns hsl() strings", () => {
expect(colorForTag("anything")).toMatch(/^hsl\(\d+ \d+% \d+%\)$/);
});
});
+15
View File
@@ -0,0 +1,15 @@
// Color used for nodes/swatches with no user tag. The CSS custom
// property is defined in the stylesheet; the hex is the static fallback
// for canvas drawing (which can't resolve vars).
export const UNTAGGED_COLOR_VAR = "var(--gv-untagged, #9e9e9e)";
// Deterministic color per user tag: stable hash → HSL hue with fixed
// saturation and lightness. `null` (no tag) returns the untagged fallback.
export function colorForTag(tag: string | null): string {
if (!tag) return UNTAGGED_COLOR_VAR;
let h = 0;
for (let i = 0; i < tag.length; i++) {
h = (h * 31 + tag.charCodeAt(i)) >>> 0;
}
return `hsl(${h % 360} 60% 55%)`;
}
+368
View File
@@ -0,0 +1,368 @@
import { datastore, system } from "@silverbulletmd/silverbullet/syscalls";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "preact/hooks";
import type {
Edge,
ExpansionResult,
Filters,
ForceSettings,
ObjectNode,
RootViewModel,
} from "../../src/model.ts";
import { GraphCanvas } from "./graph_canvas.tsx";
import { Header } from "./header.tsx";
import { Sidebar } from "./sidebar.tsx";
import { computeVisible, type NodeState } from "./visibility.ts";
const FILTERS_KEY = ["plug", "object-graph", "filters"];
const FORCES_KEY = ["plug", "object-graph", "forces"];
function edgeKey(e: Edge): string {
return `${e.source} ${e.target} ${e.label} ${e.kind}`;
}
export function App({ vm }: { vm: RootViewModel }) {
const [nodes, setNodes] = useState<Map<string, NodeState>>(() => {
const m = new Map<string, NodeState>();
const initialStatus: NodeState["status"] = vm.initialAllExpanded
? "expanded"
: "ghost";
m.set(vm.root.object.ref, { node: vm.root.object, status: "expanded" });
for (const n of vm.root.neighbors) {
if (!m.has(n.ref)) m.set(n.ref, { node: n, status: initialStatus });
}
return m;
});
const [edges, setEdges] = useState<Edge[]>(() => [...vm.root.edges]);
// Derived dedupe index kept in lock-step with `edges`. Lives in a ref
// (not state) because we mutate it in place and never want it to be a
// trigger for re-renders.
const edgeKeysRef = useRef<Set<string>>(new Set(vm.root.edges.map(edgeKey)));
const [selectedRef, setSelectedRef] = useState<string | null>(
vm.root.object.ref,
);
const [filters, setFilters] = useState<Filters>(vm.filters);
const [sidebarWidth, setSidebarWidth] = useState<number>(230);
const [forces, setForces] = useState<ForceSettings>(vm.forces);
const [cache] = useState<Map<string, ExpansionResult>>(() => {
const m = new Map<string, ExpansionResult>();
m.set(vm.root.object.ref, vm.root);
return m;
});
// Persist filters whenever they change. Disabled for transient modes
// (e.g. the global view) so they don't trample the local view's saved
// preferences. Skip the initial mount — those values came FROM the
// datastore (or the view's preset) and writing them back is pointless.
const persistFilters = vm.persistFilters !== false;
const didMountRef = useRef(false);
useEffect(() => {
if (!persistFilters) return;
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
void datastore.set(FILTERS_KEY, filters);
}, [filters, persistFilters]);
// Forces persist regardless of view (global and explore share them).
const didMountForcesRef = useRef(false);
useEffect(() => {
if (!didMountForcesRef.current) {
didMountForcesRef.current = true;
return;
}
void datastore.set(FORCES_KEY, forces);
}, [forces]);
const fetchExpansion = useCallback(
async (ref: string): Promise<ExpansionResult> => {
const cached = cache.get(ref);
if (cached) return cached;
const result = (await system.invokeFunction(
"object-graph.expandObject",
ref,
)) as ExpansionResult;
cache.set(ref, result);
return result;
},
[cache],
);
const applyExpansions = useCallback((results: ExpansionResult[]) => {
if (results.length === 0) return;
setNodes((prev) => {
const next = new Map(prev);
for (const r of results) {
next.set(r.object.ref, { node: r.object, status: "expanded" });
for (const n of r.neighbors) {
if (!next.has(n.ref)) next.set(n.ref, { node: n, status: "ghost" });
}
}
return next;
});
setEdges((prev) => {
const additions: Edge[] = [];
const keys = edgeKeysRef.current;
for (const r of results) {
for (const e of r.edges) {
const k = edgeKey(e);
if (!keys.has(k)) {
keys.add(k);
additions.push(e);
}
}
}
return additions.length ? [...prev, ...additions] : prev;
});
}, []);
const expandRef = useCallback(
async (ref: string) => {
applyExpansions([await fetchExpansion(ref)]);
},
[applyExpansions, fetchExpansion],
);
const onNodeClick = useCallback(
(ref: string) => {
const state = nodes.get(ref);
setSelectedRef(ref);
if (state && state.status === "ghost") {
void expandRef(ref);
}
},
[nodes, expandRef],
);
const removeNode = useCallback((ref: string) => {
setNodes((prev) => {
if (!prev.has(ref)) return prev;
const next = new Map(prev);
next.delete(ref);
return next;
});
setEdges((prev) => {
const remaining: Edge[] = [];
const keys = edgeKeysRef.current;
for (const e of prev) {
if (e.source === ref || e.target === ref) {
keys.delete(edgeKey(e));
continue;
}
remaining.push(e);
}
return remaining.length === prev.length ? prev : remaining;
});
setSelectedRef((sel) => (sel === ref ? null : sel));
}, []);
const selected: ObjectNode | null = selectedRef
? (nodes.get(selectedRef)?.node ?? null)
: null;
// Stable ref to the current visibleNodes so callbacks always see the latest
// set without forcing the callback identity to churn on every filter tweak.
const visibleNodesRef = useRef<NodeState[]>([]);
// Transitive expansion: follow every relation outward, round by round,
// until no new ghosts remain along allowed labels. Hidden-label edges
// are recorded (so toggling them on later reveals them) but they
// don't seed further exploration. Safety-capped at 5000 nodes.
const expandAllVisibleGhosts = useCallback(async () => {
const SAFETY_CAP = 5000;
const hiddenLabels = new Set(filters.hiddenLabels);
const expanded = new Set<string>();
const pending = new Set<string>();
for (const ns of visibleNodesRef.current) {
if (ns.status === "expanded") expanded.add(ns.node.ref);
else pending.add(ns.node.ref);
}
while (pending.size > 0) {
if (expanded.size + pending.size > SAFETY_CAP) break;
const refs = [...pending];
pending.clear();
const results = await Promise.all(refs.map(fetchExpansion));
applyExpansions(results);
for (const r of results) {
expanded.add(r.object.ref);
// Only follow edges whose label is currently visible.
for (const e of r.edges) {
if (hiddenLabels.has(e.label)) continue;
for (const ref of [e.source, e.target]) {
if (!expanded.has(ref) && !pending.has(ref)) pending.add(ref);
}
}
}
}
}, [fetchExpansion, applyExpansions, filters.hiddenLabels]);
/**
* Reset the explored set to "currently-selected object + its 1-hop
* ghosts". The selection stays put; only the broader exploration is
* collapsed back to that node's immediate neighborhood.
*
* Always issues a fresh worker call (skipping the cache) because the
* cache may hold a wide expansion for this ref — notably, in the
* Global Page Map view the cache entry for the anchor IS the global
* graph, and returning that here would defeat the whole point of Focus.
*/
const collapseAll = useCallback(async () => {
const anchor = selectedRef ?? vm.root.object.ref;
const result = (await system.invokeFunction(
"object-graph.expandObject",
anchor,
)) as ExpansionResult;
cache.set(anchor, result);
const m = new Map<string, NodeState>();
m.set(result.object.ref, { node: result.object, status: "expanded" });
for (const n of result.neighbors) {
if (!m.has(n.ref)) m.set(n.ref, { node: n, status: "ghost" });
}
setNodes(m);
setEdges([...result.edges]);
const keys = edgeKeysRef.current;
keys.clear();
for (const e of result.edges) keys.add(edgeKey(e));
setSelectedRef(result.object.ref);
}, [selectedRef, cache, vm.root.object.ref]);
// Keyboard: Backspace / Delete removes selected, unless focused in an input.
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
if (ev.key !== "Backspace" && ev.key !== "Delete") return;
const tag = (ev.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if (!selectedRef) return;
ev.preventDefault();
removeNode(selectedRef);
};
globalThis.addEventListener("keydown", handler);
return () => globalThis.removeEventListener("keydown", handler);
}, [selectedRef, removeNode]);
// Visible subgraph (view-layer only). Filter rules:
// • The root is always visible — it's the user's anchor into the graph.
// • Every other node (expanded or ghost) must pass the tag filter.
// • Edges must pass the label filter and connect two visible nodes.
// • A ghost that has no remaining edges to a visible neighbor is
// hidden — otherwise unchecking an edge label leaves orphan ghosts
// floating with no visible reason to be there.
const rootRef = vm.root.object.ref;
const { visibleNodes, visibleEdges } = useMemo(
() => computeVisible(nodes.values(), edges, filters, rootRef),
[nodes, edges, filters, rootRef],
);
// Keep the ref in sync so expandAllVisibleGhosts sees the latest set.
visibleNodesRef.current = visibleNodes;
const ghostCount = visibleNodes.reduce(
(a, ns) => a + (ns.status === "ghost" ? 1 : 0),
0,
);
// Stable arrays for the sidebar's tally inputs (the underlying state
// changes only when nodes/edges actually change; without these, every
// App render would produce fresh array identities and re-tally everything).
const allObjectNodes = useMemo(
() => [...nodes.values()].map((ns) => ns.node),
[nodes],
);
const visibleObjectNodes = useMemo(
() => visibleNodes.map((ns) => ns.node),
[visibleNodes],
);
return (
<div class="gv-app">
<Header
ghostCount={ghostCount}
onExpandAll={expandAllVisibleGhosts}
onCollapseAll={collapseAll}
hideEdgeLabels={filters.hideEdgeLabels}
onToggleHideEdgeLabels={(v) =>
setFilters({ ...filters, hideEdgeLabels: v })
}
hideOrphans={filters.hideOrphans}
onToggleHideOrphans={(v) => setFilters({ ...filters, hideOrphans: v })}
/>
<div
class="gv-body"
style={{ "--gv-sidebar-width": `${sidebarWidth}px` }}
>
<Sidebar
nodes={visibleObjectNodes}
edges={visibleEdges}
allNodes={allObjectNodes}
allEdges={edges}
universe={vm.universe}
filters={filters}
onFiltersChange={setFilters}
forces={forces}
onForcesChange={setForces}
selected={selected}
/>
<SidebarResizer width={sidebarWidth} onResize={setSidebarWidth} />
<GraphCanvas
nodes={visibleNodes}
edges={visibleEdges}
selectedRef={selectedRef}
onNodeClick={onNodeClick}
hideEdgeLabels={filters.hideEdgeLabels}
forces={forces}
/>
</div>
</div>
);
}
const MIN_SIDEBAR_WIDTH = 160;
const MAX_SIDEBAR_WIDTH = 600;
function SidebarResizer({
width,
onResize,
}: {
width: number;
onResize: (w: number) => void;
}) {
const [dragging, setDragging] = useState(false);
const startRef = useRef<{ x: number; w: number } | null>(null);
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => {
const start = startRef.current;
if (!start) return;
const next = Math.min(
MAX_SIDEBAR_WIDTH,
Math.max(MIN_SIDEBAR_WIDTH, start.w + (e.clientX - start.x)),
);
onResize(next);
};
const onUp = () => setDragging(false);
globalThis.addEventListener("mousemove", onMove);
globalThis.addEventListener("mouseup", onUp);
return () => {
globalThis.removeEventListener("mousemove", onMove);
globalThis.removeEventListener("mouseup", onUp);
};
}, [dragging, onResize]);
return (
<div
class={`gv-resizer${dragging ? " dragging" : ""}`}
onMouseDown={(e) => {
startRef.current = { x: e.clientX, w: width };
setDragging(true);
e.preventDefault();
}}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import { Button, Checkbox } from "@silverbulletmd/silverbullet/ui";
type Props = {
ghostCount: number;
onExpandAll: () => void;
onCollapseAll: () => void;
hideEdgeLabels: boolean;
onToggleHideEdgeLabels: (v: boolean) => void;
hideOrphans: boolean;
onToggleHideOrphans: (v: boolean) => void;
};
export function Header({
ghostCount,
onExpandAll,
onCollapseAll,
hideEdgeLabels,
onToggleHideEdgeLabels,
hideOrphans,
onToggleHideOrphans,
}: Props) {
return (
<header class="gv-header">
<h1 class="gv-header-title">Object Graph</h1>
<div class="gv-header-actions">
<label
class="gv-header-toggle"
title="Suppress edge labels on the canvas"
>
<Checkbox
checked={hideEdgeLabels}
onChange={(e) =>
onToggleHideEdgeLabels((e.currentTarget as HTMLInputElement).checked)
}
/>
Hide labels
</label>
<label
class="gv-header-toggle"
title="Hide nodes with no visible incoming or outgoing relation"
>
<Checkbox
checked={hideOrphans}
onChange={(e) =>
onToggleHideOrphans((e.currentTarget as HTMLInputElement).checked)
}
/>
Hide orphans
</label>
<Button
title="Follow every enabled relation outward until no ghosts remain"
disabled={ghostCount === 0}
onClick={onExpandAll}
>
Expand all
</Button>
<Button
title="Reset view to the selected object and its direct relations"
onClick={onCollapseAll}
>
Focus
</Button>
<Button
variant="icon"
class="gv-close-button"
title="Close (Esc)"
onClick={() => editor.hidePanel("modal")}
>
×
</Button>
</div>
</header>
);
}
@@ -0,0 +1,346 @@
import { syscall } from "@silverbulletmd/silverbullet/syscall";
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import { Checkbox, Input } from "@silverbulletmd/silverbullet/ui";
import { useEffect, useMemo, useState } from "preact/hooks";
import {
defaultForceSettings,
type Edge,
type Filters,
type ForceSettings,
type GraphUniverse,
type ObjectNode,
} from "../../src/model.ts";
import { colorForTag } from "../colors.ts";
type Props = {
// Currently-visible nodes (used to compute counts for present-only filter UX).
nodes: ObjectNode[];
edges: Edge[];
// Full set; used so the filter list still shows recently-hidden entries.
allNodes: ObjectNode[];
allEdges: Edge[];
// Full option lists derived from the entire space.
universe: GraphUniverse;
filters: Filters;
onFiltersChange: (f: Filters) => void;
forces: ForceSettings;
onForcesChange: (f: ForceSettings) => void;
// Selected node; rendered as an object-detail section below the filters.
selected: ObjectNode | null;
};
const LONG_LIST_THRESHOLD = 10;
const MAX_VISIBLE = 50;
type Counted = { key: string; count: number };
export function Sidebar(props: Props) {
return (
<aside class="gv-sidebar">
<TagsSection {...props} />
<LabelsSection {...props} />
<ForcesSection
forces={props.forces}
onForcesChange={props.onForcesChange}
/>
<ObjectSection selected={props.selected} />
</aside>
);
}
function ForcesSection({
forces,
onForcesChange,
}: {
forces: ForceSettings;
onForcesChange: (f: ForceSettings) => void;
}) {
const [open, setOpen] = useState(false);
const update = (patch: Partial<ForceSettings>) =>
onForcesChange({ ...forces, ...patch });
const sliders: {
label: string;
key: keyof ForceSettings;
min: number;
max: number;
step: number;
}[] = [
{
label: "Center pull",
key: "centerStrength",
min: 0,
max: 0.3,
step: 0.005,
},
{
label: "Repulsion",
key: "chargeStrength",
min: -800,
max: 0,
step: 10,
},
{ label: "Link distance", key: "linkDistance", min: 10, max: 600, step: 1 },
{
label: "Link strength",
key: "linkStrength",
min: 0.01,
max: 1,
step: 0.01,
},
];
return (
<div class="gv-section">
<header class="gv-section-header" onClick={() => setOpen(!open)}>
<span class="gv-section-title">
<span class={`gv-twisty ${open ? "open" : "closed"}`}>▸</span>
Forces
</span>
<span class="gv-section-actions" onClick={(e) => e.stopPropagation()}>
<a onClick={() => onForcesChange(defaultForceSettings)}>reset</a>
</span>
</header>
{open && (
<div class="gv-section-body gv-forces-body">
{sliders.map((s) => (
<label class="gv-force-row" key={s.key}>
<div class="gv-force-label">
<span>{s.label}</span>
<span class="gv-force-value">
{forces[s.key].toFixed(s.step < 0.1 ? 3 : 0)}
</span>
</div>
<input
type="range"
min={s.min}
max={s.max}
step={s.step}
value={forces[s.key]}
onInput={(e) =>
update({
[s.key]: Number((e.target as HTMLInputElement).value),
})
}
/>
</label>
))}
</div>
)}
</div>
);
}
function ObjectSection({ selected }: { selected: ObjectNode | null }) {
const [yamlText, setYamlText] = useState<string>("");
useEffect(() => {
if (!selected) return;
let cancelled = false;
void syscall("yaml.stringify", selected.attributes).then((text: string) => {
if (!cancelled) setYamlText(text);
});
return () => {
cancelled = true;
};
}, [selected]);
if (!selected) return null;
const swatch = colorForTag(selected.primaryTag);
const navigate = async () => {
try {
await editor.navigate(selected.ref);
await editor.hidePanel("modal");
} catch (err) {
console.error("object-graph: object navigation failed", err);
}
};
return (
<div class="gv-object-section">
<header class="gv-object-header">
<a
class="gv-object-title gv-object-title-link"
title={`Open ${selected.title}`}
onClick={navigate}
>
<span class="gv-swatch" style={{ background: swatch }} />
{selected.title}
</a>
</header>
<pre class="gv-object-body">{yamlText}</pre>
</div>
);
}
function Section({
title,
rows,
hidden,
onToggle,
onAll,
onNone,
renderRow,
}: {
title: string;
rows: Counted[];
hidden: string[];
onToggle: (key: string) => void;
onAll: () => void;
onNone: () => void;
renderRow: (r: Counted) => preact.ComponentChildren;
}) {
const [open, setOpen] = useState(true);
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const filtered = query
? rows.filter((r) => r.key.toLowerCase().includes(query.toLowerCase()))
: rows;
const display = showAll ? filtered : filtered.slice(0, MAX_VISIBLE);
const overflow = filtered.length - display.length;
return (
<div class="gv-section">
<header class="gv-section-header" onClick={() => setOpen(!open)}>
<span class="gv-section-title">
<span class={`gv-twisty ${open ? "open" : "closed"}`}>▸</span>
{title}
</span>
<span class="gv-section-actions" onClick={(e) => e.stopPropagation()}>
<a onClick={onAll}>all</a>
{" · "}
<a onClick={onNone}>none</a>
</span>
</header>
{open && (
<div class="gv-section-body">
{rows.length > LONG_LIST_THRESHOLD && (
<Input
type="text"
class="gv-section-search"
placeholder="Filter…"
value={query}
onInput={(e) => setQuery((e.currentTarget as HTMLInputElement).value)}
/>
)}
{display.map((r) => (
<label class="gv-row" key={r.key}>
<Checkbox
checked={!hidden.includes(r.key)}
onChange={() => onToggle(r.key)}
/>
{renderRow(r)}
<span class="gv-count">{r.count}</span>
</label>
))}
{overflow > 0 && (
<a class="gv-more" onClick={() => setShowAll(true)}>
show {overflow} more
</a>
)}
</div>
)}
</div>
);
}
/**
* Build a `Counted[]` whose key set is exactly `keys`, with counts pulled
* from the explored-graph tally (defaulting to 0). Sorted: present-in-graph
* entries first (by count desc), then absent entries alphabetically.
*/
function universeRows(keys: string[], counts: Map<string, number>): Counted[] {
const rows: Counted[] = keys.map((key) => ({
key,
count: counts.get(key) ?? 0,
}));
rows.sort((a, b) => {
if (a.count !== b.count) return b.count - a.count;
return a.key.localeCompare(b.key);
});
return rows;
}
function TagsSection({ allNodes, universe, filters, onFiltersChange }: Props) {
const rows = useMemo(() => {
const counts = new Map<string, number>();
for (const n of allNodes) {
if (n.tags.length === 0) {
counts.set("(untagged)", (counts.get("(untagged)") ?? 0) + 1);
continue;
}
for (const t of n.tags) {
counts.set(t, (counts.get(t) ?? 0) + 1);
}
}
// "(untagged)" is always presented at the bottom of the universe list
// — it's a bucket, not a real tag.
return universeRows([...universe.tags, "(untagged)"], counts);
}, [allNodes, universe.tags]);
const toggle = (key: string) =>
onFiltersChange({
...filters,
hiddenTags: filters.hiddenTags.includes(key)
? filters.hiddenTags.filter((x) => x !== key)
: [...filters.hiddenTags, key],
});
return (
<Section
title="Tags"
rows={rows}
hidden={filters.hiddenTags}
onToggle={toggle}
onAll={() => onFiltersChange({ ...filters, hiddenTags: [] })}
onNone={() =>
onFiltersChange({
...filters,
hiddenTags: rows.map((r) => r.key),
})
}
renderRow={(r) => (
<span class="gv-tag-row">
<span
class="gv-swatch"
style={{
background: colorForTag(r.key === "(untagged)" ? null : r.key),
}}
/>
{r.key}
</span>
)}
/>
);
}
function LabelsSection({
allEdges,
universe,
filters,
onFiltersChange,
}: Props) {
const rows = useMemo(() => {
const counts = new Map<string, number>();
for (const e of allEdges)
counts.set(e.label, (counts.get(e.label) ?? 0) + 1);
return universeRows(universe.labels, counts);
}, [allEdges, universe.labels]);
const toggle = (key: string) =>
onFiltersChange({
...filters,
hiddenLabels: filters.hiddenLabels.includes(key)
? filters.hiddenLabels.filter((x) => x !== key)
: [...filters.hiddenLabels, key],
});
return (
<Section
title="Kind"
rows={rows}
hidden={filters.hiddenLabels}
onToggle={toggle}
onAll={() => onFiltersChange({ ...filters, hiddenLabels: [] })}
onNone={() =>
onFiltersChange({
...filters,
hiddenLabels: rows.map((r) => r.key),
})
}
renderRow={(r) => <span>{r.key}</span>}
/>
);
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { Edge, Filters, ObjectNode } from "../../src/model.ts";
import { computeVisible, type NodeState } from "./visibility.ts";
const node = (
ref: string,
tags: string[] = [],
rootTag: string | null = "page",
): ObjectNode => ({
kind: "page",
ref,
title: ref,
rootTag,
primaryTag: tags[0] ?? null,
tags,
dangling: false,
attributes: {},
});
const ns = (ref: string, tags: string[] = []): NodeState => ({
node: node(ref, tags),
status: "expanded",
});
const edge = (source: string, target: string, label: string): Edge => ({
source,
target,
label,
kind: "mention",
refs: [],
undirected: false,
});
const filters = (overrides: Partial<Filters> = {}): Filters => ({
hiddenTags: [],
hiddenLabels: [],
hideEdgeLabels: false,
hideOrphans: true,
...overrides,
});
describe("computeVisible", () => {
it("keeps the root visible even with no edges", () => {
const r = computeVisible(
[ns("Root"), ns("B"), ns("C")],
[],
filters(),
"Root",
);
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
expect(r.visibleEdges).toEqual([]);
});
it("hides orphans (non-root with no labeled edge)", () => {
const r = computeVisible(
[ns("Root"), ns("B"), ns("Orphan")],
[edge("Root", "B", "mention")],
filters(),
"Root",
);
const refs = r.visibleNodes.map((n) => n.node.ref).sort();
expect(refs).toEqual(["B", "Root"]);
});
it("hideOrphans=false keeps everyone visible regardless of edges", () => {
const r = computeVisible(
[ns("Root"), ns("B"), ns("Orphan")],
[edge("Root", "B", "mention")],
filters({ hideOrphans: false }),
"Root",
);
expect(r.visibleNodes).toHaveLength(3);
});
it("drops edges with hidden labels and re-evaluates orphan status", () => {
const r = computeVisible(
[ns("Root"), ns("X")],
[edge("Root", "X", "co-mention")],
filters({ hiddenLabels: ["co-mention"] }),
"Root",
);
// X is now an orphan because its only edge was filtered out.
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
expect(r.visibleEdges).toEqual([]);
});
it("applies the tag filter uniformly (root excepted)", () => {
const r = computeVisible(
[ns("Root", []), ns("Driver", ["driver"]), ns("Engineer", ["engineer"])],
[edge("Root", "Driver", "mention"), edge("Root", "Engineer", "mention")],
filters({ hiddenTags: ["engineer"] }),
"Root",
);
const refs = r.visibleNodes.map((n) => n.node.ref).sort();
expect(refs).toEqual(["Driver", "Root"]);
expect(r.visibleEdges).toHaveLength(1);
expect(r.visibleEdges[0].target).toBe("Driver");
});
it("treats untagged nodes as a separate hideable bucket", () => {
const r = computeVisible(
[ns("Root"), ns("Untagged", [])],
[edge("Root", "Untagged", "mention")],
filters({ hiddenTags: ["(untagged)"] }),
"Root",
);
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
});
it("drops edges that touch a tag-filtered endpoint", () => {
const r = computeVisible(
[ns("Root"), ns("Visible", ["driver"]), ns("Hidden", ["engineer"])],
[
edge("Visible", "Hidden", "mention"),
edge("Root", "Visible", "mention"),
],
filters({ hiddenTags: ["engineer"] }),
"Root",
);
expect(r.visibleEdges).toHaveLength(1);
expect(r.visibleEdges[0].source).toBe("Root");
});
it("keeps an edge if any tag on a node passes (multi-tag union)", () => {
const r = computeVisible(
[ns("Root"), ns("Both", ["driver", "person"])],
[edge("Root", "Both", "mention")],
filters({ hiddenTags: ["driver"] }),
"Root",
);
expect(r.visibleNodes.map((n) => n.node.ref).sort()).toEqual([
"Both",
"Root",
]);
});
});
@@ -0,0 +1,67 @@
import type { Edge, Filters, ObjectNode } from "../../src/model.ts";
export type NodeState = { node: ObjectNode; status: "expanded" | "ghost" };
export type VisibilityResult = {
visibleNodes: NodeState[];
visibleEdges: Edge[];
};
// Pure derivation of what the panel paints from the explored-graph state.
// Three stages:
// 1. candidate = nodes that pass the tag filter (root is always a candidate).
// 2. labeledEdges = edges with a visible label that connect two candidates.
// 3. orphan rule: with `hideOrphans` on, any candidate other than the root
// is dropped if it has no labeledEdge incidence.
export function computeVisible(
nodes: Iterable<NodeState>,
edges: Edge[],
filters: Filters,
rootRef: string,
): VisibilityResult {
function passesTags(n: ObjectNode): boolean {
if (n.ref === rootRef) return true;
if (n.tags.length === 0) return !filters.hiddenTags.includes("(untagged)");
return n.tags.some((t) => !filters.hiddenTags.includes(t));
}
const candidate = new Set<string>();
const nodeList: NodeState[] = [];
for (const ns of nodes) {
nodeList.push(ns);
if (passesTags(ns.node)) candidate.add(ns.node.ref);
}
const labeledEdges = edges.filter(
(e) =>
candidate.has(e.source) &&
candidate.has(e.target) &&
!filters.hiddenLabels.includes(e.label),
);
const degree = new Map<string, number>();
for (const e of labeledEdges) {
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
}
const out: NodeState[] = [];
const hideOrphans = filters.hideOrphans;
for (const ns of nodeList) {
if (!candidate.has(ns.node.ref)) continue;
if (
!hideOrphans ||
ns.node.ref === rootRef ||
(degree.get(ns.node.ref) ?? 0) > 0
) {
out.push(ns);
}
}
const visibleRefSet = new Set(out.map((ns) => ns.node.ref));
const visibleEdges = labeledEdges.filter(
(e) => visibleRefSet.has(e.source) && visibleRefSet.has(e.target),
);
return { visibleNodes: out, visibleEdges };
}
+30
View File
@@ -0,0 +1,30 @@
import { render } from "preact";
import { useEffect, useRef } from "preact/hooks";
import type { RootViewModel } from "../src/model.ts";
import { App } from "./components/app.tsx";
import { useEscape } from "./use_escape.ts";
declare const __GRAPH: RootViewModel;
function Root({ vm }: { vm: RootViewModel }) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEscape();
useEffect(() => {
rootRef.current?.focus();
}, []);
return (
<div ref={rootRef} class="graph-root-inner" tabIndex={-1}>
<App vm={vm} />
</div>
);
}
const root = document.getElementById("graph-root");
if (!root) throw new Error("graph-root not found");
// Focus the iframe document BEFORE rendering so the first keydown (often
// Escape) is delivered to us rather than the parent SilverBullet editor.
root.setAttribute("tabindex", "-1");
(root as HTMLElement).focus();
render(<Root vm={__GRAPH} />, root);
+464
View File
@@ -0,0 +1,464 @@
// Visual language aligned with SilverBullet's configuration-manager plug
// — color tokens, section header style, button style, and border radii
// match `silverbullet/plugs/configuration-manager/ui/configuration.scss`.
// Dark mode is driven by both `prefers-color-scheme` (for iframes that
// don't see the parent's data-theme) AND `html[data-theme="dark"]` (for
// when SilverBullet propagates its explicit theme into the iframe).
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
font-size: 14px;
// Chrome colors (background, text, borders, sections, hover, accent) come
// from the shared panel-surface palette in components.css. Only the
// graph-specific node/edge palette below is defined locally.
// Graph palette — node-color tokens are object-graph specific.
--gv-node: #111;
--gv-node-current: #1e63d8;
--gv-node-dim: #9e4705; // missing-link rust
--gv-label: #333;
--gv-label-dim: #777;
--gv-link: #c8c8c8;
--gv-link-dim: #e6e6e6;
--gv-link-hot: #464cfc;
--gv-accent: #650007; // SB meta-color — current-page ring
--gv-label-halo: rgba(255, 255, 255, 0.78);
--gv-tag-bg: #4070d0;
--gv-tag-fg: #ffffff;
--gv-tag-border: #2a5099;
--gv-untagged: #9e9e9e;
}
// Dark tokens — same values configuration-manager uses, applied for either
// the explicit theme attribute or the user's system preference.
@mixin gv-dark-tokens {
color-scheme: dark;
--gv-node: #fff;
--gv-node-current: #6e9bff;
--gv-node-dim: #c97c4a;
--gv-label: #d8d8d8;
--gv-label-dim: #888;
--gv-link: #555;
--gv-link-dim: #333;
--gv-link-hot: #6e74ff;
--gv-accent: #d6222e;
--gv-label-halo: rgba(30, 30, 30, 0.78);
--gv-tag-bg: #5b85e8;
--gv-tag-fg: #ffffff;
--gv-tag-border: #3868c8;
--gv-untagged: #6e6e6e;
}
html[data-theme="dark"] { @include gv-dark-tokens; }
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) { @include gv-dark-tokens; }
}
html, body { height: 100%; background: var(--ui-surface-background-color); color: var(--ui-surface-color); }
#graph-root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--ui-surface-background-color);
color: var(--ui-surface-color);
// No border/radius: the host modal panel already frames the iframe.
overflow: hidden;
outline: none;
}
.graph-root-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
outline: none;
}
.graph-app {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.graph-canvas {
position: absolute;
inset: 0;
}
// Floating controls overlay (bottom-right of the canvas stage).
// Pan pad on the left, zoom in/out stacked on the right.
.graph-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 8px;
z-index: 2;
pointer-events: none; // children opt in
button {
pointer-events: auto;
background: var(--ui-surface-background-color);
color: var(--ui-surface-color);
border: 1px solid var(--ui-surface-border-color);
border-radius: 6px;
cursor: pointer;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
&:hover { background: var(--ui-surface-hover-background-color); }
&:active { background: var(--ui-surface-section-background-color); }
}
}
.graph-zoom {
display: flex;
flex-direction: column;
gap: 4px;
button {
flex: 1 1 0;
height: auto; // let flex stretch determine the height
}
}
// 3x3 directional pad — middle row has ←, center, →; top/bottom rows
// have just the up/down arrow in the middle column.
.graph-pan {
display: grid;
grid-template-columns: repeat(3, 28px);
grid-template-rows: repeat(3, 28px);
gap: 4px;
.graph-pan-up { grid-column: 2; grid-row: 1; }
.graph-pan-left { grid-column: 1; grid-row: 2; }
.graph-pan-center{ grid-column: 2; grid-row: 2; }
.graph-pan-right { grid-column: 3; grid-row: 2; }
.graph-pan-down { grid-column: 2; grid-row: 3; }
}
.gv-app {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--ui-surface-background-color);
color: var(--ui-surface-color);
}
.gv-body {
display: grid;
grid-template-columns: var(--gv-sidebar-width, 230px) 5px minmax(0, 1fr);
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.gv-resizer {
cursor: col-resize;
background: transparent;
border-left: 1px solid var(--ui-surface-border-color);
user-select: none;
transition: background 120ms ease;
}
.gv-resizer:hover,
.gv-resizer.dragging {
background: var(--ui-accent-color, rgba(70, 76, 252, 0.25));
}
.gv-canvas-wrap {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.gv-canvas-wrap .graph-canvas {
position: absolute;
inset: 0;
}
/* Object section inside the sidebar (selected node attributes). */
.gv-object-section {
margin-top: auto;
padding: 6px 14px 10px;
border-top: 1px solid var(--ui-surface-border-color);
background: var(--ui-surface-background-color);
}
.gv-object-section .gv-object-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 4px;
margin-bottom: 4px;
border-bottom: 1px solid var(--ui-surface-border-color);
font-size: 13px;
font-weight: 600;
color: var(--ui-surface-color);
}
.gv-object-section .gv-object-title {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gv-object-section .gv-object-title-link {
cursor: pointer;
color: var(--ui-accent-color);
text-decoration: none;
}
.gv-object-section .gv-object-title-link:hover { text-decoration: underline; }
.gv-object-section .gv-object-body {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
color: var(--ui-surface-color);
}
/* Header — sized roughly like configuration-manager's #cfg-header. */
.gv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--ui-surface-border-color);
flex-shrink: 0;
}
.gv-header-title {
font-size: 18px;
font-weight: 600;
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gv-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
// Toolbar buttons + checkboxes now use the shared `.sb-button` / `.sb-checkbox`
// components; only the toggle-label layout and close-button positioning remain
// graph-specific.
.gv-header-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--ui-surface-color);
user-select: none;
cursor: pointer;
}
// Close button uses the `.sb-button-icon` component; keep only positioning and
// the larger glyph size for the `×`.
.gv-close-button {
margin-left: auto;
font-size: 18px;
line-height: 1;
}
/* Ghost-node hover tooltip */
.gv-ghost-tooltip {
position: absolute;
z-index: 20;
max-width: 280px;
background: var(--ui-surface-background-color);
border: 1px solid var(--ui-surface-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
padding: 5px 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 12px;
color: var(--ui-surface-color);
pointer-events: none;
line-height: 1.35;
}
/* Edge hover tooltip */
.gv-edge-tooltip {
position: absolute;
z-index: 20;
max-width: 360px;
background: var(--ui-surface-background-color);
border: 1px solid var(--ui-surface-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
padding: 6px 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 12px;
color: var(--ui-surface-color);
pointer-events: none;
line-height: 1.4;
}
.gv-edge-tooltip-label {
font-weight: 600;
margin-bottom: 4px;
}
.gv-edge-tooltip-snippet {
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
color: var(--ui-surface-color);
white-space: pre-wrap;
word-break: break-word;
max-height: 8em;
overflow: hidden;
}
.gv-edge-tooltip-empty {
color: var(--ui-surface-subtle-color);
font-style: italic;
}
/* Sidebar */
.gv-sidebar {
border-right: 1px solid var(--ui-surface-border-color);
overflow: auto;
font-size: 13px;
}
// Filter sections — title underline matches configuration-manager's
// .lib-section-header / .cfg-category-title pattern.
.gv-section { padding: 12px 14px 6px; }
.gv-section + .gv-section { padding-top: 14px; }
.gv-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
margin-bottom: 4px;
border-bottom: 1px solid var(--ui-surface-border-color);
font-size: 13px;
font-weight: 600;
color: var(--ui-surface-subtle-color);
cursor: pointer;
user-select: none;
}
.gv-section-title { display: inline-flex; align-items: center; }
.gv-twisty {
display: inline-block;
transition: transform 0.12s;
margin-right: 6px;
font-size: 10px;
color: var(--ui-surface-subtle-color);
}
.gv-twisty.open { transform: rotate(90deg); }
.gv-section-actions {
font-size: 11px;
letter-spacing: 0.5px;
}
.gv-section-actions a {
cursor: pointer;
color: var(--ui-surface-subtle-color);
text-decoration: none;
}
.gv-section-actions a:hover { color: var(--ui-surface-color); text-decoration: underline; }
.gv-section-body { padding: 0; }
.gv-forces-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
.gv-force-row { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
.gv-force-row input[type="range"] { width: 100%; }
.gv-force-label { display: flex; justify-content: space-between; color: var(--ui-surface-subtle-color); }
.gv-force-value { font-variant-numeric: tabular-nums; }
// Search field uses the shared `.sb-input`; keep only layout here.
.gv-section-search {
width: 100%;
margin: 4px 0 8px;
}
.gv-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 13px;
}
.gv-count { margin-left: auto; color: var(--ui-surface-subtle-color); font-size: 12px; }
.gv-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.gv-tag-row { display: inline-flex; align-items: center; gap: 6px; }
.gv-more {
display: block;
margin-top: 6px;
cursor: pointer;
font-size: 12px;
color: var(--ui-accent-color);
}
.gv-more:hover { text-decoration: underline; }
/* Object view */
.gv-object-view {
border-left: 1px solid var(--ui-surface-border-color);
background: var(--ui-surface-background-color);
overflow: auto;
display: flex;
flex-direction: column;
}
.gv-object-view-empty {
align-items: center;
justify-content: center;
color: var(--ui-surface-subtle-color);
font-size: 12px;
padding: 18px;
}
.gv-object-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--ui-surface-border-color);
}
.gv-object-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.gv-object-body {
margin: 0;
padding: 10px 12px;
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: var(--ui-surface-color);
}
/* Empty graph overlay */
.gv-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--ui-surface-subtle-color);
font-size: 13px;
}
+15
View File
@@ -0,0 +1,15 @@
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import { useEffect } from "preact/hooks";
export function useEscape() {
useEffect(() => {
const handler = async (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
await editor.hidePanel("modal");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}
+3 -2
View File
@@ -3,7 +3,8 @@ An attempt at documenting the changes/new features introduced in each release.
## Edge
Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be released as a docker image with the `:v2` tag, and a binary in the [edge release](https://github.com/silverbulletmd/silverbullet/releases/tag/edge). If you want to live on the bleeding edge of SilverBullet goodness (or regression) this is where to do it.
* New [[Object/relation]] indexed object capturing generalized object-to-object relationships: typed edges from frontmatter, inline `[key: value]` attributes, and `#tag` fenced data blocks; untyped mentions; and co-mention edges between refs co-occurring in the same item, nested item, or paragraph. The (now) legacy `link` is reimplemented as a virtual collection on top of `relation` now and should keep acting as before. This _should_ be backwards compatible, but watch for regressions. A space reindex should trigger upon upgrade automatically. If not run `Space: Reindex` manually on your clients.
* New [[Object/relation]] indexed object capturing generalized object-to-object relationships. This is a successor to [[Object/link]], which still exists as a virtual collection built on top of `relation`.
* New experimental [[Object Graph]]: an interactive, force-directed graph explorer over the [[Object/relation]] index. Try it via ${widgets.commandButton("Graph: Explore")} and ${widgets.commandButton("Graph: Global Page Map")}.
* Picker fuzzy search: replaced Fuse.js with a custom scorer that supports multi-token queries, path-aware ranking, and some typo tolerance.
* UX: a _lot_ of little visual tweaks and usability fixes all over the place that hopefully will trigger less of your OCD, including:
* On narrow viewports (<800px) header `#` markers no longer get pushed off-screen when the cursor enters a heading
@@ -11,7 +12,7 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be
* List/outline alignment: bullets, checkboxes and ordered-list numbers now line up in a clean column regardless of nesting depth, list type, or whether items are tasks, see [[Outline Stress Test]].
* Note: potentially **breaking CSS change for theme authors**: per-nesting-level indent values previously carried by `.sb-line-ul.sb-line-li-N`, `.sb-line-ol.sb-line-li-N`, `.sb-line-task` and `.sb-line-blockquote.sb-line-li-N` selectors have been removed.
* Task checkboxes are now drawn in CSS (`appearance: none` + bordered box + rotated-rectangle checkmark) instead of relying on the native checkboxes. Should improve rendering on webkit browsers, and gives more control over the width.
* Start of shared UI components (between SB core and plugs): component styles (for buttons, inputs, selects, checkboxes, tabs, alerts, badges, progress bars), and a `@silverbulletmd/silverbullet/ui` package export providing optional Preact wrappers. See [[Plugs/Development/Reference]] for notes on how to use this as a plug author. Built-in plugs like [[Configuration Manager]] now also load [[Space Style]] inside the iframe, so components should become themable.
* Start of shared UI components (between SB core and plugs): component styles (for buttons, inputs, selects, checkboxes, tabs, alerts, badges, progress bars), and a `@silverbulletmd/silverbullet/ui` package export providing optional Preact wrappers. See [[Plugs/Development/Reference]] for notes on how to use this as a plug author. Built-in plugs like [[Configuration Manager]] and [[Object Graph]] use these components already. In addition, these now also load [[Space Style]] inside the iframe, so components should become themable.
* Technical simplification: replaced the CodeMirror-based mini-editor used in the page/command picker, prompt dialogs, and the top-bar page-name field with native text inputs, improving accessibility, mobile keyboard behavior, and removing several Safari/layout hacks.
* Potentially **breaking CSS change for theme authors**: these three fields are no longer CodeMirror instances, so any styling that targeted them via `.sb-mini-editor`, the `.cm-content` / `.cm-line` / `.cm-scroller` rules inside `.sb-modal-box`, or the `.cm-scroller` / `.cm-content` rules under `#sb-current-page`, no longer applies. They are now native `<input>` elements sharing the `.sb-input` base class, each with a context-specific class to retarget:
* Picker / command-palette filter: `.sb-input.sb-filter-input` (inside `.sb-modal-box .sb-header`)
+36
View File
@@ -0,0 +1,36 @@
#maturity/experimental
An interactive object-graph explorer visualizes [[Object|objects]] (pages, items, blocks) and the typed [[Object/relation|relations]] between them — letting you navigate your space by structure rather than by name.
# Commands
* ${widgets.commandButton("Graph: Explore")} (`Ctrl/Cmd-Shift-G`): Opens an exploratory graph anchored on the currently open page. Its 1-hop neighborhood is loaded immediately, expand more by clicking ghost nodes.
* ${widgets.commandButton("Graph: Global Page Map")}: Loads every content page in the space and the `mention` relations between them, all pre-expanded. Useful for a bird's-eye view; toggle other relation labels on from the sidebar to bring in further edges without a refetch.
# Interacting with the graph
* **Click a translucent (ghost) node** to expand its 1-hop neighborhood into the graph.
* **Click an expanded (solid) node** to select it — its attributes appear in the sidebar’s Object section.
* **Double-click any node or edge** to navigate to its definition.
* **Hover an edge** to see its label and snippet.
* **Drag** a node to pin it, the layout reflows around it.
* **Hover** a node to dim everything outside its neighborhood.
* **Scroll / pinch** to zoom; drag empty space to pan.
* **`Esc`** or the `×` button closes the modal.
# Panels
* **Header** — buttons for **Expand** (open every visible ghost in one batch), **Collapse** (reset the explored set to the selected object + its 1-hop), and a **Hide labels** checkbox to suppress edge labels on dense graphs.
* **Sidebar**
* **Root tags** — toggle visibility by structural object kind.
* **Tags** — toggle visibility by user [[Tag]]. Untagged objects share an `(untagged)` bucket.
* **Relations** — toggle [[Object/relation|relation]] labels (`mention`, `co-mention`, custom relation types e.g. `spouse`, `team`). `co-mention` is hidden by default in Explore mode; only `mention` is visible by default in Global Page Map mode.
* **Object** — the selected node's full indexed attributes rendered as YAML. Click the title to navigate to the object. The `×` button (or `Delete` / `Backspace` while the node is selected) removes the node from the graph.
Each filter section has `all` / `none` shortcuts, a search box when the list is long, and shows counts from the current graph.
# Visual conventions
* **Solid circles** — expanded nodes (their 1-hop neighborhood is loaded).
* **Translucent circles** — ghost nodes, click to expand.
* **Dashed outline** — dangling refs (referenced by some relation but not yet indexed — see [[Aspiring Pages]]).
* **Color** is driven by the node’s primary user tag, auto-derived from the tag name. Untagged nodes use a neutral gray.
* **Edge labels** are the relation `kind` (a reserved value like `mention`, or a user predicate like `spouse`), drawn along the edge. Directed edges carry an arrowhead at the target; relations that connect the same pair in both directions get arrows at both ends; collapsed `co-mention` pairs are rendered undirected.
The graph is powered entirely by the [[Object/relation]] index.
+4
View File
@@ -6,6 +6,10 @@ SilverBullet automatically maintains an [[Object Index]] extracted from all [[Ma
Objects are a feature that powers a lot of SilverBullet functionality, including the [[Page Picker]], [[Linked Mention|Linked Mentions]] and many others. They can also be queried by the user directly, typically via [[Space Lua/Integrated Query]].
There are various ways to visualize what objects exist:
* [[X-Ray]]: an in-editor overlay visualizing indexed objects
* [[Object Graph]]: a graph-style visualization of indexed objects and their relationships.
# Terminology
* [[Object]]: represent _things_ in your space at various level of granularity. Examples include [[Object/page]] at the highest level, but also more granular things like [[Object/task]] and [[Object/link]]. In relational database parlance, you can think of Objects as **database rows**.
* [[Tag]]: represent Object **types** or **tables** (in relational database parlance). Every Object has at least one tag, but can have additional tags attached explicitly, usually through the [[Markdown/Hashtags]] syntax.
+2
View File
@@ -1,5 +1,7 @@
X-Ray is an editor lens that shows you the structure SilverBullet’s indexer sees in your document. When switched on, every text range the indexer extracts as an [[Object]] is underlined. When you hover your mouse over it, it will show you all the attributes extracted as [[YAML]].
You may also be interested in the [[Object Graph]].
# Activation
Run ${widgets.commandButton "Editor: Toggle X-Ray"}. The setting is sticky, it persists across page navigations and reloads until you run the command again.