@goodbones/browser 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/index.d.ts +12 -0
  3. package/build/dts/index.d.ts.map +1 -0
  4. package/build/dts/main.d.ts +3 -0
  5. package/build/dts/main.d.ts.map +1 -0
  6. package/build/dts/model/atlas.d.ts +157 -0
  7. package/build/dts/model/atlas.d.ts.map +1 -0
  8. package/build/dts/model/campaigns.d.ts +141 -0
  9. package/build/dts/model/campaigns.d.ts.map +1 -0
  10. package/build/dts/server/collect.d.ts +16 -0
  11. package/build/dts/server/collect.d.ts.map +1 -0
  12. package/build/dts/server/compose.d.ts +13 -0
  13. package/build/dts/server/compose.d.ts.map +1 -0
  14. package/build/dts/server/export.d.ts +3 -0
  15. package/build/dts/server/export.d.ts.map +1 -0
  16. package/build/dts/server/handler.d.ts +6 -0
  17. package/build/dts/server/handler.d.ts.map +1 -0
  18. package/build/dts/server/serve.d.ts +15 -0
  19. package/build/dts/server/serve.d.ts.map +1 -0
  20. package/build/dts/server/site.d.ts +2 -0
  21. package/build/dts/server/site.d.ts.map +1 -0
  22. package/build/dts/server/source.d.ts +19 -0
  23. package/build/dts/server/source.d.ts.map +1 -0
  24. package/build/dts/server/vite-plugin.d.ts +8 -0
  25. package/build/dts/server/vite-plugin.d.ts.map +1 -0
  26. package/build/dts/server/watch.d.ts +10 -0
  27. package/build/dts/server/watch.d.ts.map +1 -0
  28. package/build/esm/index.js +14 -0
  29. package/build/esm/index.js.map +1 -0
  30. package/build/esm/main.js +134 -0
  31. package/build/esm/main.js.map +1 -0
  32. package/build/esm/model/atlas.js +431 -0
  33. package/build/esm/model/atlas.js.map +1 -0
  34. package/build/esm/model/campaigns.js +181 -0
  35. package/build/esm/model/campaigns.js.map +1 -0
  36. package/build/esm/server/collect.js +83 -0
  37. package/build/esm/server/collect.js.map +1 -0
  38. package/build/esm/server/compose.js +50 -0
  39. package/build/esm/server/compose.js.map +1 -0
  40. package/build/esm/server/export.js +19 -0
  41. package/build/esm/server/export.js.map +1 -0
  42. package/build/esm/server/handler.js +67 -0
  43. package/build/esm/server/handler.js.map +1 -0
  44. package/build/esm/server/serve.js +80 -0
  45. package/build/esm/server/serve.js.map +1 -0
  46. package/build/esm/server/site.js +6 -0
  47. package/build/esm/server/site.js.map +1 -0
  48. package/build/esm/server/source.js +67 -0
  49. package/build/esm/server/source.js.map +1 -0
  50. package/build/esm/server/vite-plugin.js +21 -0
  51. package/build/esm/server/vite-plugin.js.map +1 -0
  52. package/build/esm/server/watch.js +56 -0
  53. package/build/esm/server/watch.js.map +1 -0
  54. package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
  55. package/build/site/_astro/client.Buyw3Q1S.js +9 -0
  56. package/build/site/_astro/index.DQrWs1cb.css +1 -0
  57. package/build/site/_astro/react.DJY1zw8Z.js +1 -0
  58. package/build/site/favicon.svg +1 -0
  59. package/build/site/index.html +8 -0
  60. package/package.json +78 -0
  61. package/src/app/components/Browser.tsx +153 -0
  62. package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
  63. package/src/app/components/architecture/Detail.tsx +655 -0
  64. package/src/app/components/architecture/ManifestPane.tsx +178 -0
  65. package/src/app/components/architecture/Tree.tsx +205 -0
  66. package/src/app/components/architecture/rows.ts +181 -0
  67. package/src/app/components/architecture/selection.ts +6 -0
  68. package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
  69. package/src/app/components/campaigns/Detail.tsx +653 -0
  70. package/src/app/components/campaigns/Ladder.tsx +326 -0
  71. package/src/app/components/campaigns/pick.ts +7 -0
  72. package/src/app/components/lib/data.ts +132 -0
  73. package/src/app/pages/index.astro +34 -0
  74. package/src/app/public/favicon.svg +1 -0
  75. package/src/app/styles/theme.css +993 -0
  76. package/src/index.ts +37 -0
  77. package/src/main.ts +155 -0
  78. package/src/model/atlas.ts +751 -0
  79. package/src/model/campaigns.ts +376 -0
  80. package/src/server/collect.ts +132 -0
  81. package/src/server/compose.ts +79 -0
  82. package/src/server/export.ts +24 -0
  83. package/src/server/handler.ts +77 -0
  84. package/src/server/serve.ts +100 -0
  85. package/src/server/site.ts +6 -0
  86. package/src/server/source.ts +89 -0
  87. package/src/server/vite-plugin.ts +32 -0
  88. package/src/server/watch.ts +65 -0
@@ -0,0 +1,8 @@
1
+ <!DOCTYPE html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>goodbones browser</title><meta name="description" content="The Architecture Browser and the Campaign Browser: a repository's tree with its architecture.yaml laid over it, and every campaign's phases, sectors and holdouts."><link rel="icon" href="favicon.svg" type="image/svg+xml"><script>
2
+ try {
3
+ const held = window.localStorage.getItem("goodbones-theme");
4
+ if (held === "light" || held === "dark") document.documentElement.dataset.theme = held;
5
+ else if (window.matchMedia("(prefers-color-scheme: light)").matches)
6
+ document.documentElement.dataset.theme = "light";
7
+ } catch {}
8
+ </script><link rel="stylesheet" href="/_astro/index.DQrWs1cb.css"></head><body><style>astro-island,astro-slot,astro-static-slot{display:contents}</style><script>(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event("astro:only"));})();</script><script>(()=>{var g=Object.defineProperty;var w=(a,s,c)=>s in a?g(a,s,{enumerable:!0,configurable:!0,writable:!0,value:c}):a[s]=c;var l=(a,s,c)=>w(a,typeof s!="symbol"?s+"":s,c);var E=new Set(["__proto__","constructor","prototype"]);{let a={0:t=>y(t),1:t=>c(t),2:t=>new RegExp(t),3:t=>new Date(t),4:t=>new Map(c(t)),5:t=>new Set(c(t)),6:t=>BigInt(t),7:t=>new URL(t),8:t=>new Uint8Array(t),9:t=>new Uint16Array(t),10:t=>new Uint32Array(t),11:t=>Number.POSITIVE_INFINITY*t},s=t=>{let[p,e]=t;return p in a?a[p](e):void 0},c=t=>t.map(s),y=t=>typeof t!="object"||t===null?t:Object.fromEntries(Object.entries(t).map(([p,e])=>[p,s(e)]));class f extends HTMLElement{constructor(){super(...arguments);l(this,"Component");l(this,"hydrator");l(this,"hydrate",async()=>{var b;if(!this.hydrator||!this.isConnected)return;let e=(b=this.parentElement)==null?void 0:b.closest("astro-island[ssr]");if(e){e.addEventListener("astro:hydrate",this.hydrate,{once:!0});return}let r=this.querySelectorAll("astro-slot"),n={},d=this.querySelectorAll("template[data-astro-template]");for(let o of d){let i=o.closest(this.tagName);i!=null&&i.isSameNode(this)&&(n[o.getAttribute("data-astro-template")||"default"]=o.innerHTML,o.remove())}for(let o of r){let i=o.closest(this.tagName);i!=null&&i.isSameNode(this)&&(n[o.getAttribute("name")||"default"]=o.innerHTML)}let u;try{u=this.hasAttribute("props")?y(JSON.parse(this.getAttribute("props"))):{}}catch(o){let i=this.getAttribute("component-url")||"<unknown>",v=this.getAttribute("component-export");throw v&&(i+=` (export ${v})`),console.error(`[hydrate] Error parsing props for component ${i}`,this.getAttribute("props"),o),o}let h;await this.hydrator(this)(this.Component,u,n,{client:this.getAttribute("client")}),this.removeAttribute("ssr"),this.dispatchEvent(new CustomEvent("astro:hydrate"))});l(this,"unmount",()=>{this.isConnected||this.dispatchEvent(new CustomEvent("astro:unmount"))})}disconnectedCallback(){document.removeEventListener("astro:after-swap",this.unmount),document.addEventListener("astro:after-swap",this.unmount,{once:!0})}connectedCallback(){if(!this.hasAttribute("await-children")||document.readyState==="interactive"||document.readyState==="complete")this.childrenConnectedCallback();else{let e=()=>{document.removeEventListener("DOMContentLoaded",e),r.disconnect(),this.childrenConnectedCallback()},r=new MutationObserver(()=>{var n;((n=this.lastChild)==null?void 0:n.nodeType)===Node.COMMENT_NODE&&this.lastChild.nodeValue==="astro:end"&&(this.lastChild.remove(),e())});r.observe(this,{childList:!0}),document.addEventListener("DOMContentLoaded",e)}}async childrenConnectedCallback(){let e=this.getAttribute("before-hydration-url");e&&await import(e),this.start()}getRetryImportUrl(e){let r=new URL(e,document.baseURI);return r.searchParams.set("astro-retry",Date.now().toString()),r.toString()}async importWithRetry(e){try{return await import(e)}catch(r){return await new Promise(n=>setTimeout(n,1e3)),import(this.getRetryImportUrl(e))}}handleHydrationError(e){let r=this.getAttribute("component-url"),n=new CustomEvent("astro:hydration-error",{cancelable:!0,bubbles:!0,composed:!0,detail:{error:e,componentUrl:r}});this.dispatchEvent(n)&&console.error(`[astro-island] Error hydrating ${r}`,e)}async start(){let e=JSON.parse(this.getAttribute("opts")),r=this.getAttribute("client");if(Astro[r]===void 0){window.addEventListener(`astro:${r}`,()=>this.start(),{once:!0});return}try{await Astro[r](async()=>{let n=this.getAttribute("renderer-url");try{let[d,{default:u}]=await Promise.all([this.importWithRetry(this.getAttribute("component-url")),n?this.importWithRetry(n):Promise.resolve({default:()=>()=>{}})]),h=this.getAttribute("component-export")||"default";if(h.includes(".")){this.Component=d;for(let m of h.split(".")){if(E.has(m)||!this.Component||typeof this.Component!="object"&&typeof this.Component!="function"||!Object.hasOwn(this.Component,m))throw new Error(`Invalid component export path: ${h}`);this.Component=this.Component[m]}}else{if(E.has(h))throw new Error(`Invalid component export path: ${h}`);this.Component=d[h]}return this.hydrator=u,this.hydrate}catch(d){return this.handleHydrationError(d),()=>{}}},e,this)}catch(n){this.handleHydrationError(n)}}attributeChangedCallback(){this.hydrate()}}l(f,"observedAttributes",["props"]),customElements.get("astro-island")||customElements.define("astro-island",f)}})();</script><astro-island uid="Z1JrR2L" component-url="/_astro/Browser.yxldcnQ3.js" component-export="Browser" renderer-url="/_astro/client.Buyw3Q1S.js" props="{}" ssr client="only" opts="{&quot;name&quot;:&quot;Browser&quot;,&quot;value&quot;:&quot;react&quot;}"></astro-island><noscript>The goodbones browser needs JavaScript.</noscript></body></html>
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@goodbones/browser",
3
+ "version": "0.1.0-beta.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "The Architecture Browser and the Campaign Browser for @goodbones: an Astro app, prebuilt and served by one bin, that draws a repository's tree with its architecture.yaml laid over it and every campaign's phases, sectors and holdouts — and redraws as the code changes.",
7
+ "author": "zacharyweidenbach",
8
+ "keywords": [
9
+ "architecture",
10
+ "lint",
11
+ "import-boundaries",
12
+ "campaigns",
13
+ "diagram",
14
+ "monorepo"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/dataquail/goodbones.git",
22
+ "directory": "packages/browser"
23
+ },
24
+ "homepage": "https://dataquail.github.io/goodbones",
25
+ "bugs": {
26
+ "url": "https://github.com/dataquail/goodbones/issues"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./build/dts/index.d.ts",
31
+ "default": "./build/esm/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "types": "./build/dts/index.d.ts",
36
+ "bin": {
37
+ "goodbones-browser": "./build/esm/main.js"
38
+ },
39
+ "files": [
40
+ "build/dts",
41
+ "build/esm",
42
+ "build/site",
43
+ "src",
44
+ "!src/**/*.test.ts",
45
+ "!src/**/*.test.tsx",
46
+ "!src/**/*.test-helper.ts"
47
+ ],
48
+ "dependencies": {
49
+ "effect": "4.0.0-beta.94",
50
+ "@goodbones/campaigns": "0.1.1-beta.2",
51
+ "@goodbones/core": "0.1.0-beta.13",
52
+ "@goodbones/ast-grep": "0.1.3-beta.2",
53
+ "@goodbones/typescript": "0.1.0-beta.13"
54
+ },
55
+ "devDependencies": {
56
+ "@astrojs/react": "^7.0.0",
57
+ "@testing-library/react": "^16.3.3",
58
+ "@types/node": "^25.9.4",
59
+ "@types/react": "^19.2.18",
60
+ "@types/react-dom": "^19.2.7",
61
+ "astro": "^7.2.2",
62
+ "jsdom": "^30.0.1",
63
+ "react": "^19.2.8",
64
+ "react-dom": "^19.2.8",
65
+ "vite": "^8.2.2"
66
+ },
67
+ "nx": {
68
+ "includedScripts": []
69
+ },
70
+ "scripts": {
71
+ "build": "tsc -b tsconfig.build.json && astro build",
72
+ "check": "tsc -b tsconfig.json",
73
+ "clean": "rm -rf build .tsbuildinfo .astro",
74
+ "dev": "astro dev",
75
+ "test": "vitest run",
76
+ "coverage": "vitest run --coverage"
77
+ }
78
+ }
@@ -0,0 +1,153 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import type { Atlas } from "../../model/atlas.js";
4
+ import type { CampaignView } from "../../model/campaigns.js";
5
+ import { ArchitectureBrowser } from "./architecture/ArchitectureBrowser.js";
6
+ import { CampaignBrowser } from "./campaigns/CampaignBrowser.js";
7
+ import { useFeed, useModel, useRoute } from "./lib/data.js";
8
+
9
+ // The page: two browsers under one bar. The bar says which repository, how
10
+ // fresh the drawing is and whether it will redraw on its own; the hash says
11
+ // which browser is open and what is selected, so a view is a link.
12
+
13
+ type Theme = "dark" | "light";
14
+
15
+ const storedTheme = (): Theme | null => {
16
+ try {
17
+ const held = window.localStorage.getItem("goodbones-theme");
18
+ return held === "dark" || held === "light" ? held : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+
24
+ const formatTime = (iso: string): string => {
25
+ const date = new Date(iso);
26
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleTimeString();
27
+ };
28
+
29
+ export const Browser = (): React.JSX.Element => {
30
+ const [route, setRoute] = useRoute();
31
+ const feed = useFeed();
32
+ const atlas = useModel<Atlas>("atlas.json", feed.version);
33
+ const campaigns = useModel<CampaignView>("campaigns.json", feed.version);
34
+ const [theme, setTheme] = useState<Theme>(() => storedTheme() ?? "dark");
35
+ useEffect(() => {
36
+ document.documentElement.dataset.theme = theme;
37
+ try {
38
+ window.localStorage.setItem("goodbones-theme", theme);
39
+ } catch {
40
+ // A private window keeps no preference; the page still renders.
41
+ }
42
+ }, [theme]);
43
+
44
+ const name = atlas.value?.name ?? campaigns.value?.name ?? "";
45
+ const generatedAt = atlas.value?.generatedAt ?? campaigns.value?.generatedAt ?? null;
46
+ const error = atlas.error ?? campaigns.error;
47
+ const loading = atlas.state === "loading" || campaigns.state === "loading";
48
+
49
+ return (
50
+ <div className="page">
51
+ <header className="bar">
52
+ <div className="brand">
53
+ <span className="mark" aria-hidden="true" />
54
+ <span className="brand-name">goodbones</span>
55
+ {name !== "" ? <span className="repo">{name}</span> : null}
56
+ </div>
57
+ <nav className="tabs" aria-label="Browsers">
58
+ <button
59
+ type="button"
60
+ className={route.tab === "architecture" ? "tab active" : "tab"}
61
+ onClick={() => {
62
+ setRoute({ tab: "architecture", selection: null });
63
+ }}
64
+ >
65
+ Architecture Browser
66
+ </button>
67
+ <button
68
+ type="button"
69
+ className={route.tab === "campaigns" ? "tab active" : "tab"}
70
+ onClick={() => {
71
+ setRoute({ tab: "campaigns", selection: null });
72
+ }}
73
+ >
74
+ Campaign Browser
75
+ {campaigns.value !== null && campaigns.value.campaigns.length > 0 ? (
76
+ <span className="count">{campaigns.value.campaigns.length}</span>
77
+ ) : null}
78
+ </button>
79
+ </nav>
80
+ <div className="status">
81
+ <span
82
+ className={`live ${feed.live ? "on" : "off"}`}
83
+ title={
84
+ feed.live
85
+ ? "Live: the page redraws when the repository changes."
86
+ : "A snapshot: no feed of changes is reachable."
87
+ }
88
+ >
89
+ <span className="dot" aria-hidden="true" />
90
+ {feed.live ? "live" : "snapshot"}
91
+ </span>
92
+ {generatedAt !== null ? (
93
+ <span className="stamp" title={generatedAt}>
94
+ {loading ? "redrawing…" : `drawn ${formatTime(generatedAt)}`}
95
+ </span>
96
+ ) : null}
97
+ <button
98
+ type="button"
99
+ className="theme"
100
+ onClick={() => {
101
+ setTheme(theme === "dark" ? "light" : "dark");
102
+ }}
103
+ aria-label="Toggle light and dark"
104
+ title="Toggle light and dark"
105
+ >
106
+ {theme === "dark" ? "☾" : "☀"}
107
+ </button>
108
+ </div>
109
+ </header>
110
+ {error !== null ? (
111
+ <div className="banner error" role="alert">
112
+ <strong>The repository could not be read.</strong> {error}
113
+ {atlas.value !== null ? " Showing the last drawing." : ""}
114
+ </div>
115
+ ) : null}
116
+ {feed.changed.length > 0 && loading ? (
117
+ <div className="banner">
118
+ Changed: {feed.changed.slice(0, 4).join(", ")}
119
+ {feed.changed.length > 4 ? ` and ${String(feed.changed.length - 4)} more` : ""}
120
+ </div>
121
+ ) : null}
122
+ <main className="body">
123
+ {route.tab === "architecture" ? (
124
+ atlas.value === null ? (
125
+ <Placeholder loading={atlas.state === "loading"} what="the architecture" />
126
+ ) : (
127
+ <ArchitectureBrowser
128
+ atlas={atlas.value}
129
+ selection={route.selection}
130
+ onSelect={(selection) => {
131
+ setRoute({ tab: "architecture", selection });
132
+ }}
133
+ />
134
+ )
135
+ ) : campaigns.value === null ? (
136
+ <Placeholder loading={campaigns.state === "loading"} what="the campaigns" />
137
+ ) : (
138
+ <CampaignBrowser
139
+ view={campaigns.value}
140
+ selection={route.selection}
141
+ onSelect={(selection) => {
142
+ setRoute({ tab: "campaigns", selection });
143
+ }}
144
+ />
145
+ )}
146
+ </main>
147
+ </div>
148
+ );
149
+ };
150
+
151
+ const Placeholder = ({ loading, what }: { loading: boolean; what: string }): React.JSX.Element => (
152
+ <div className="placeholder">{loading ? `Reading ${what}…` : `Nothing to draw for ${what}.`}</div>
153
+ );
@@ -0,0 +1,240 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
+ import type { Atlas, AtlasNode, AtlasPosition } from "../../../model/atlas.js";
4
+ import { Detail } from "./Detail.js";
5
+ import { ManifestPane } from "./ManifestPane.js";
6
+ import { arcsOf, initiallyExpanded, relationsOf, rowsOf } from "./rows.js";
7
+ import type { Selection } from "./selection.js";
8
+ import { Tree } from "./Tree.js";
9
+
10
+ // The Architecture Browser: the tree on the left with every edge drawn beside
11
+ // it, the manifest on the right, and what the two say about the thing under
12
+ // the pointer between them. Hover a file and its edges come forward, its
13
+ // governing node lights up in the manifest; hover a node and the files it
14
+ // governs light up in the tree. Click to keep either.
15
+
16
+ const selectionOf = (atlas: Atlas, text: string | null): Selection | null => {
17
+ if (text === null) return null;
18
+ if (text.startsWith("node:")) return { kind: "node", id: text.slice(5) };
19
+ if (atlas.files.some((file) => file.path === text)) return { kind: "file", path: text };
20
+ if (atlas.folders.some((folder) => folder.path === text)) return { kind: "folder", path: text };
21
+ return null;
22
+ };
23
+
24
+ const textOf = (selection: Selection | null): string | null =>
25
+ selection === null ? null : selection.kind === "node" ? `node:${selection.id}` : selection.path;
26
+
27
+ type Props = {
28
+ readonly atlas: Atlas;
29
+ readonly selection: string | null;
30
+ readonly onSelect: (selection: string | null) => void;
31
+ };
32
+
33
+ export const ArchitectureBrowser = ({ atlas, onSelect, selection }: Props): React.JSX.Element => {
34
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => initiallyExpanded(atlas));
35
+ const [filter, setFilter] = useState("");
36
+ const [hoverRow, setHoverRow] = useState<string | null>(null);
37
+ const [hoverNode, setHoverNode] = useState<string | null>(null);
38
+
39
+ // A folder that appears after a redraw opens as the rest did.
40
+ useEffect(() => {
41
+ setExpanded((previous) => {
42
+ const next = new Set(previous);
43
+ const known = new Set(atlas.folders.map((folder) => folder.path));
44
+ for (const folder of initiallyExpanded(atlas)) if (!previous.has(folder)) next.add(folder);
45
+ for (const folder of previous) if (!known.has(folder)) next.delete(folder);
46
+ return next;
47
+ });
48
+ }, [atlas]);
49
+
50
+ const selected = useMemo(() => selectionOf(atlas, selection), [atlas, selection]);
51
+ const nodes = useMemo(
52
+ () => new Map(atlas.manifest.nodes.map((node) => [node.id, node] as const)),
53
+ [atlas],
54
+ );
55
+ const files = useMemo(
56
+ () => new Map(atlas.files.map((file) => [file.path, file] as const)),
57
+ [atlas],
58
+ );
59
+ const folders = useMemo(
60
+ () => new Map(atlas.folders.map((folder) => [folder.path, folder] as const)),
61
+ [atlas],
62
+ );
63
+
64
+ const filtered = useMemo(() => {
65
+ if (filter.trim() === "") return null;
66
+ const needle = filter.trim().toLowerCase();
67
+ const keep = new Set<string>();
68
+ for (const file of atlas.files) {
69
+ if (!file.path.toLowerCase().includes(needle)) continue;
70
+ keep.add(file.path);
71
+ let folder = file.folder;
72
+ while (folder !== "") {
73
+ keep.add(folder);
74
+ folder = folder.slice(0, Math.max(0, folder.lastIndexOf("/")));
75
+ }
76
+ }
77
+ return keep;
78
+ }, [atlas, filter]);
79
+
80
+ const effectiveExpanded = useMemo(
81
+ () =>
82
+ filtered === null ? expanded : new Set([...filtered].filter((path) => folders.has(path))),
83
+ [expanded, filtered, folders],
84
+ );
85
+ const rows = useMemo(() => {
86
+ const all = rowsOf(atlas, effectiveExpanded);
87
+ return filtered === null ? all : all.filter((row) => filtered.has(row.path));
88
+ }, [atlas, effectiveExpanded, filtered]);
89
+ const arcs = useMemo(
90
+ () => arcsOf(atlas, rows, effectiveExpanded),
91
+ [atlas, rows, effectiveExpanded],
92
+ );
93
+
94
+ // The focus: what the pointer is over, else what was clicked.
95
+ const focusRow =
96
+ hoverRow ?? (selected !== null && selected.kind !== "node" ? selected.path : null);
97
+ const focusIsFolder = focusRow !== null && folders.has(focusRow);
98
+ const relations = useMemo(
99
+ () => relationsOf(atlas, focusRow, focusIsFolder),
100
+ [atlas, focusRow, focusIsFolder],
101
+ );
102
+ const focusNode: AtlasNode | null = (() => {
103
+ const id =
104
+ hoverNode ??
105
+ (selected?.kind === "node"
106
+ ? selected.id
107
+ : focusRow === null
108
+ ? null
109
+ : (files.get(focusRow)?.node ?? folders.get(focusRow)?.node ?? null));
110
+ return id === null ? null : (nodes.get(id) ?? null);
111
+ })();
112
+ const chain: ReadonlyArray<string> =
113
+ focusRow === null
114
+ ? focusNode === null
115
+ ? []
116
+ : chainOfNode(nodes, focusNode)
117
+ : (files.get(focusRow)?.chain ?? (focusNode === null ? [] : chainOfNode(nodes, focusNode)));
118
+ const governedBy: string | null = hoverNode ?? (selected?.kind === "node" ? selected.id : null);
119
+
120
+ const scrollTo: AtlasPosition | null = focusNode?.position ?? null;
121
+
122
+ const select = (next: Selection | null): void => {
123
+ onSelect(textOf(next));
124
+ };
125
+ const toggle = (folder: string): void => {
126
+ setExpanded((previous) => {
127
+ const next = new Set(previous);
128
+ if (next.has(folder)) next.delete(folder);
129
+ else next.add(folder);
130
+ return next;
131
+ });
132
+ };
133
+
134
+ return (
135
+ <div className="arch">
136
+ <section className="pane tree-pane" aria-label="Repository tree">
137
+ <div className="pane-head">
138
+ <input
139
+ className="filter"
140
+ type="search"
141
+ placeholder="filter files…"
142
+ value={filter}
143
+ onChange={(event) => {
144
+ setFilter(event.target.value);
145
+ }}
146
+ />
147
+ <button
148
+ type="button"
149
+ className="small"
150
+ onClick={() => {
151
+ setExpanded(new Set(atlas.folders.map((folder) => folder.path)));
152
+ }}
153
+ >
154
+ expand
155
+ </button>
156
+ <button
157
+ type="button"
158
+ className="small"
159
+ onClick={() => {
160
+ setExpanded(new Set());
161
+ }}
162
+ >
163
+ collapse
164
+ </button>
165
+ </div>
166
+ <Tree
167
+ rows={rows}
168
+ arcs={arcs}
169
+ focus={focusRow}
170
+ focusIsFolder={focusIsFolder}
171
+ relations={relations}
172
+ governedBy={governedBy}
173
+ selected={selected !== null && selected.kind !== "node" ? selected.path : null}
174
+ onHover={setHoverRow}
175
+ onToggle={toggle}
176
+ onSelect={(row) => {
177
+ select(
178
+ row.kind === "folder"
179
+ ? { kind: "folder", path: row.path }
180
+ : { kind: "file", path: row.path },
181
+ );
182
+ }}
183
+ />
184
+ <Legend />
185
+ </section>
186
+ <section className="pane manifest-pane" aria-label="architecture.yaml">
187
+ <ManifestPane
188
+ atlas={atlas}
189
+ activeNode={focusNode?.id ?? null}
190
+ chain={chain}
191
+ scrollTo={scrollTo}
192
+ selectedNode={selected?.kind === "node" ? selected.id : null}
193
+ onHoverNode={setHoverNode}
194
+ onSelectNode={(id) => {
195
+ select(id === null ? null : { kind: "node", id });
196
+ }}
197
+ />
198
+ </section>
199
+ <section className="pane detail-pane" aria-label="Details">
200
+ <Detail
201
+ atlas={atlas}
202
+ selected={selected}
203
+ hovered={
204
+ hoverRow === null
205
+ ? hoverNode === null
206
+ ? null
207
+ : { kind: "node", id: hoverNode }
208
+ : folders.has(hoverRow)
209
+ ? { kind: "folder", path: hoverRow }
210
+ : { kind: "file", path: hoverRow }
211
+ }
212
+ onSelect={select}
213
+ />
214
+ </section>
215
+ </div>
216
+ );
217
+ };
218
+
219
+ const chainOfNode = (
220
+ nodes: ReadonlyMap<string, AtlasNode>,
221
+ node: AtlasNode,
222
+ ): ReadonlyArray<string> => {
223
+ const chain: Array<string> = [];
224
+ let cursor: AtlasNode | undefined = node;
225
+ while (cursor !== undefined) {
226
+ chain.unshift(cursor.id);
227
+ cursor = cursor.parent === null ? undefined : nodes.get(cursor.parent);
228
+ }
229
+ return chain;
230
+ };
231
+
232
+ const Legend = (): React.JSX.Element => (
233
+ <div className="legend">
234
+ <span className="key out">imports</span>
235
+ <span className="key in">imported by</span>
236
+ <span className="key refused">refused</span>
237
+ <span className="key sibling">owed sibling</span>
238
+ <span className="key governed">governed by node</span>
239
+ </div>
240
+ );