Model graph: the whole model as one graph with queries; 0.9.0

- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram)
- union graph of every element and connection, registry and inline, built from the model index
- query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared
- side panel per element and connection with diagrams, registry line and decisions one click away
- legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js
- command Archify | Open Model Graph; integration check; unit tests for graph and queries
This commit is contained in:
2026-10-21 15:47:00 +03:00
parent 265f8c57f6
commit c8645b228b
20 changed files with 1551 additions and 30 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 0.9.0 — 2026-10-21
- **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. Drag, zoom, **Fit**, **Layout**, **Labels**, legend chips that hide a type, **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`; terms AND together, a leading `-` excludes; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
- **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions.
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
- Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from.
## 0.8.1 — 2026-10-20 ## 0.8.1 — 2026-10-20
- **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it. - **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it.
+6 -2
View File
@@ -65,6 +65,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder. - **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration). - **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
- **Model graph**: the **Graph** switch on any page of a model shows the whole model as one graph, with a query box (`type:database`, `uses:api`, `from:web to:db`, …), a side panel per element and PNG export. See [Models](#models).
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live. - **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`). - **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
- **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
@@ -73,7 +74,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
- **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files. - **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files.
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
- **Commands**: *Archify | New Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. - **Commands**: *Archify | New Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Open Model Graph*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
## 3D view ## 3D view
@@ -126,7 +127,10 @@ online-shop/
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it. - **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it. - **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`. - **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file. - **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only). The **?** button lists them; clicking an example runs it.
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop). Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
+13
View File
@@ -127,3 +127,16 @@ connections:
``` ```
The key is stripped before Archify renders. An unknown id is a problem at its line; outside a model the key is an error, like `ref`. The `main.archify` page lists all decisions; a diagram page lists the ones it points at or that name it; an element's card and dialog list the decisions about it. A `.archify-decision` file opens as its own page. Outside a model it still opens, with a note that it has lost its model. The key is stripped before Archify renders. An unknown id is a problem at its line; outside a model the key is an error, like `ref`. The `main.archify` page lists all decisions; a diagram page lists the ones it points at or that name it; an element's card and dialog list the decisions about it. A `.archify-decision` file opens as its own page. Outside a model it still opens, with a note that it has lost its model.
## The model graph
The **Model | Graph** switch on the `main.archify` page replaces it with the whole model as one graph. It is built from the model index, not from Archify's renderer:
- **Nodes** are every element any diagram draws (`components`, `nodes`, `participants`, `states`), joined by id across diagrams, plus every registry element even when no diagram draws it yet. Registry elements carry their description, file and line; inline elements only what the diagram says. The same inline id in two diagrams is one node.
- **Edges** are every `connections`, `edges`, `flows`, `transitions` and `messages` entry between two nodes of the same diagram, keyed by `from>to`; the labels different diagrams give the same pair are joined with " / ". Direction is kept.
- **Decisions** reach nodes through `elements:` in the decision file and through `decisions:` on a node in a diagram; they reach edges through `decisions:` on a connection.
Queries run in the page against that graph; the forms are listed under **?** on the page and in the README. `uses:` and `near:` ignore direction; `from: to:` follows the arrows first and ignores them only when no directed path exists. Connections are shown when both ends match, so `type:backend` shows backend-to-backend connections only.
The switch is remembered per `main.archify` in the workspace. The graph page updates in place when a file of the model changes: elements that are still there keep their position, new ones are laid out around them.
+9
View File
@@ -49,6 +49,15 @@ if (tests) {
entryPoints: ['src/viewer3d/index.ts'], entryPoints: ['src/viewer3d/index.ts'],
outfile: 'dist/viewer3d.js', outfile: 'dist/viewer3d.js',
}), }),
// The Graph page of a model; Cytoscape.js is bundled in.
esbuild.context({
...common,
format: 'iife',
platform: 'browser',
target: 'es2022',
entryPoints: ['src/graph/index.ts'],
outfile: 'dist/graph.js',
}),
]); ]);
if (watch) { if (watch) {
await Promise.all(contexts.map((ctx) => ctx.watch())); await Promise.all(contexts.map((ctx) => ctx.watch()));
+57
View File
@@ -9,10 +9,14 @@
"version": "0.8.1", "version": "0.8.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cytoscape": "^3.34.4",
"cytoscape-fcose": "^2.2.0",
"three": "^0.186.1", "three": "^0.186.1",
"yaml": "^2.9.1" "yaml": "^2.9.1"
}, },
"devDependencies": { "devDependencies": {
"@types/cytoscape": "^3.21.9",
"@types/cytoscape-fcose": "^2.2.5",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/three": "^0.186.0", "@types/three": "^0.186.0",
"@types/vscode": "~1.100.0", "@types/vscode": "~1.100.0",
@@ -1015,6 +1019,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cytoscape-fcose": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@types/cytoscape-fcose/-/cytoscape-fcose-2.2.5.tgz",
"integrity": "sha512-FaM6MM5zC4Yh72E60vkpbM4tstJS90wj3u1Vq/SMiK/MShhLaMd17QkmR39S14aUWdfNSlUEfx/g/XkDdFmXDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cytoscape": "^3.31"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.4", "version": "22.20.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
@@ -1755,6 +1776,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cose-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
"integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
"license": "MIT",
"dependencies": {
"layout-base": "^2.0.0"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -1785,6 +1815,27 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cytoscape": {
"version": "3.34.4",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.4.tgz",
"integrity": "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-fcose": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
"integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
"license": "MIT",
"dependencies": {
"cose-base": "^2.2.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2935,6 +2986,12 @@
"prebuild-install": "^7.0.1" "prebuild-install": "^7.0.1"
} }
}, },
"node_modules/layout-base": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
"integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
"license": "MIT"
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+24 -1
View File
@@ -2,7 +2,7 @@
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"displayName": "Archify Diagram Viewer", "displayName": "Archify Diagram Viewer",
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.", "description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
"version": "0.8.1", "version": "0.9.0",
"publisher": "root-at-skic", "publisher": "root-at-skic",
"license": "MIT", "license": "MIT",
"sponsor": { "sponsor": {
@@ -108,6 +108,11 @@
"command": "archify.openModel", "command": "archify.openModel",
"title": "Archify | Open Model", "title": "Archify | Open Model",
"icon": "$(symbol-structure)" "icon": "$(symbol-structure)"
},
{
"command": "archify.openModelGraph",
"title": "Archify | Open Model Graph",
"icon": "$(type-hierarchy)"
} }
], ],
"menus": { "menus": {
@@ -143,6 +148,10 @@
{ {
"command": "archify.openModel", "command": "archify.openModel",
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'" "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
},
{
"command": "archify.openModelGraph",
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
} }
], ],
"editor/title": [ "editor/title": [
@@ -180,6 +189,11 @@
"command": "archify.openModel", "command": "archify.openModel",
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused", "when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
"group": "navigation@60" "group": "navigation@60"
},
{
"command": "archify.openModelGraph",
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
"group": "navigation@61"
} }
], ],
"explorer/context": [ "explorer/context": [
@@ -222,6 +236,11 @@
"command": "archify.openModel", "command": "archify.openModel",
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/", "when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
"group": "zzz_archify@8" "group": "zzz_archify@8"
},
{
"command": "archify.openModelGraph",
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
"group": "zzz_archify@9"
} }
], ],
"editor/title/context": [ "editor/title/context": [
@@ -453,6 +472,8 @@
"test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs" "test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs"
}, },
"devDependencies": { "devDependencies": {
"@types/cytoscape": "^3.21.9",
"@types/cytoscape-fcose": "^2.2.5",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/three": "^0.186.0", "@types/three": "^0.186.0",
"@types/vscode": "~1.100.0", "@types/vscode": "~1.100.0",
@@ -468,6 +489,8 @@
}, },
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme", "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
"dependencies": { "dependencies": {
"cytoscape": "^3.34.4",
"cytoscape-fcose": "^2.2.0",
"three": "^0.186.1", "three": "^0.186.1",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+11
View File
@@ -33,6 +33,17 @@ export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active); await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
}), }),
// Opens the model's Graph page: every element and connection of the model in one picture, with a query box.
vscode.commands.registerCommand('archify.openModelGraph', async (arg?: unknown) => {
const uri = targetUri(ctx, arg);
const root = uri ? await ctx.models.rootOf(uri) : undefined;
if (!root) {
vscode.window.showInformationMessage(`Archify: this file is not inside a model (no ${MAIN_FILE} in a folder above it).`);
return;
}
await ctx.views.showGraph(root);
}),
// Creates a registry file in the clicked folder and opens it. // Creates a registry file in the clicked folder and opens it.
vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => { vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => {
const folder = await parentFolder(arg); const folder = await parentFolder(arg);
+568
View File
@@ -0,0 +1,568 @@
import cytoscape, { Core, EdgeSingular, NodeSingular } from 'cytoscape';
import fcose from 'cytoscape-fcose';
import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph';
import { QueryResult, runQuery } from '../model/graphQuery';
import { GRAPH_PAGE_STYLE } from './style';
import type { ArchifyHost } from '../webview/host';
/**
* The Graph page of a model: the whole model as one graph, a query box that
* narrows it, a side panel for the selected element or connection. Runs in
* the webview; the extension hands the graph over through the bridge
* (`window.__archifyHost.graph`) and posts a new one when the model changes.
*/
type Host = ArchifyHost;
interface SavedView {
query?: string;
labels?: boolean;
hidden?: string[];
}
cytoscape.use(fcose);
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8' };
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database'];
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8';
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
const LABELS_UP_TO = 40;
const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision:<id> · unused · -shared';
const HELP: [string, string][] = [
['database', 'elements whose id, label, sublabel, brand or type contain the word'],
['type:backend', 'elements of that type'],
['diagram:0200-*', 'what the diagrams matching the path draw (glob)'],
['decision:<id>', 'what a decision is about or is attached to'],
['uses:api', 'an element and its neighbours'],
['near:api,2', 'an element and everything within two steps'],
['from:web to:db', 'the shortest path between two elements'],
['unused', 'registry elements no diagram draws'],
['shared', 'registry elements only; -shared: inline elements only'],
['-worker', 'a leading minus excludes; terms combine with AND'],
];
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Theme colours Cytoscape needs as literals; read again when VS Code switches theme. */
function palette() {
const dark = !document.body.classList.contains('vscode-light');
return {
bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--vscode-textLink-foreground', '#22d3ee'),
edge: dark ? '#64748b' : '#94a3b8',
font: cssVar('--vscode-font-family', 'system-ui, sans-serif'),
};
}
class GraphPage {
cy!: Core;
private graph: ModelGraph;
private readonly nodesById = new Map<string, GraphNode>();
private readonly edgesById = new Map<string, GraphEdge>();
private saved: SavedView;
private queryTimer: number | undefined;
private result: QueryResult | undefined;
private readonly hiddenTypes: Set<string>;
private readonly root = el('div', 'ag');
private readonly nameEl = el('div', 'ag-name');
private readonly kickerEl = el('div', 'ag-kicker');
private readonly queryInput = el('input', 'ag-query');
private readonly countEl = el('span', 'ag-count');
private readonly labelsButton = el('button', 'ag-button', 'Labels');
private readonly legend = el('div', 'ag-legend');
private readonly side = el('aside', 'ag-side');
private readonly main = el('div', 'ag-main');
private readonly canvas = el('div', 'ag-canvas');
private readonly help = el('div', 'ag-help');
constructor(private readonly host: Host, initial: { graph: ModelGraph; query?: string }) {
this.graph = initial.graph;
this.saved = (host.getState().graphView as SavedView | undefined) ?? {};
if (initial.query !== undefined) this.saved.query = initial.query;
if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO;
this.hiddenTypes = new Set(this.saved.hidden ?? []);
this.index();
this.buildChrome();
this.buildGraph(true);
this.syncLabels();
this.renderLegend();
this.queryInput.value = this.saved.query ?? '';
this.applyQuery();
this.showEmptySide();
this.listen();
}
// ---------------------------------------------------------------- data
private index(): void {
this.nodesById.clear();
this.edgesById.clear();
for (const n of this.graph.nodes) this.nodesById.set(n.id, n);
for (const e of this.graph.edges) this.edgesById.set(e.id, e);
}
private elements() {
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', shared: n.shared ? 1 : 0, color: colorOf(n.type) } }));
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, label: e.labels.join(' / ') } }));
return [...nodes, ...edges];
}
/** A new graph from the extension: keep the positions of the elements that are still there. */
replace(graph: ModelGraph): void {
const positions = new Map<string, { x: number; y: number }>();
this.cy.nodes().forEach((n) => {
positions.set(n.id(), { ...n.position() });
});
const selected = this.cy.$(':selected').map((e) => e.id());
this.graph = graph;
this.index();
this.cy.elements().remove();
this.cy.add(this.elements());
let fresh = 0;
this.cy.nodes().forEach((n) => {
const at = positions.get(n.id());
if (at) n.position(at);
else fresh += 1;
});
if (fresh) this.layout(false);
this.renderLegend();
this.applyHidden();
this.applyQuery();
const again = this.cy.$(selected.map((id) => `#${CSS.escape(id)}`).join(','));
if (again.length) {
again.select();
this.showSide(again[0]);
} else this.showEmptySide();
this.kickerEl.textContent = `Model${graph.meta.id ? ` · ${graph.meta.id}` : ''}`;
this.nameEl.textContent = graph.meta.name || graph.meta.id || 'Untitled model';
}
// -------------------------------------------------------------- chrome
private buildChrome(): void {
const style = el('style');
style.textContent = GRAPH_PAGE_STYLE;
document.head.appendChild(style);
const top = el('header', 'ag-top');
const titles = el('div');
this.kickerEl.textContent = `Model${this.graph.meta.id ? ` · ${this.graph.meta.id}` : ''}`;
this.nameEl.textContent = this.graph.meta.name || this.graph.meta.id || 'Untitled model';
titles.append(this.kickerEl, this.nameEl);
const actions = el('div', 'ag-actions');
const sw = el('button', 'ag-switch');
sw.type = 'button';
sw.title = 'Back to the model page';
sw.setAttribute('aria-label', 'Graph view on; switch to the model page');
sw.append(el('span', 'ag-seg', 'Model'), el('span', 'ag-seg on', 'Graph'));
sw.addEventListener('click', () => this.host.post({ type: 'showModel' }));
const exportButton = el('button', 'ag-button', 'Export PNG');
exportButton.type = 'button';
exportButton.title = 'Save the graph as it is on screen, as a PNG';
exportButton.addEventListener('click', () => this.exportPng());
actions.append(sw, exportButton);
top.append(titles, actions);
const bar = el('div', 'ag-bar');
const wrap = el('div', 'ag-query-wrap');
this.queryInput.type = 'text';
this.queryInput.placeholder = PLACEHOLDER;
this.queryInput.spellcheck = false;
this.queryInput.setAttribute('aria-label', 'Query');
const clear = el('button', 'ag-clear', '×');
clear.type = 'button';
clear.title = 'Clear (Esc)';
clear.addEventListener('click', () => this.setQuery(''));
wrap.append(this.queryInput, clear);
const fit = el('button', 'ag-button', 'Fit');
fit.type = 'button';
fit.title = 'Fit the matching elements into view (0)';
fit.addEventListener('click', () => this.fit());
const relayout = el('button', 'ag-button', 'Layout');
relayout.type = 'button';
relayout.title = 'Lay the graph out again';
relayout.addEventListener('click', () => this.layout(true));
this.labelsButton.type = 'button';
this.labelsButton.title = 'Show or hide connection labels';
this.labelsButton.addEventListener('click', () => {
this.saved.labels = !this.saved.labels;
this.save();
this.syncLabels();
});
const helpButton = el('button', 'ag-button', '?');
helpButton.type = 'button';
helpButton.title = 'What can I type?';
helpButton.setAttribute('aria-expanded', 'false');
helpButton.addEventListener('click', () => {
this.help.hidden = !this.help.hidden;
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
});
bar.append(wrap, this.countEl, fit, relayout, this.labelsButton, helpButton);
const cyHolder = el('div', 'ag-cy');
this.canvas.append(cyHolder, this.legend);
this.help.hidden = true;
this.help.appendChild(el('h3', '', 'Queries'));
const table = el('table');
for (const [example, meaning] of HELP) {
const row = el('tr');
const code = el('td', '', example);
code.title = 'Try it';
code.addEventListener('click', () => {
this.setQuery(example.includes('<id>') ? example.replace('<id>', this.graph.decisions[0]?.id ?? '') : example);
});
row.append(code, el('td', '', meaning));
table.appendChild(row);
}
this.help.appendChild(table);
this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query.'));
this.main.append(this.canvas, this.side, this.help);
this.root.append(top, bar, this.main);
document.body.appendChild(this.root);
this.syncLabels();
}
private renderLegend(): void {
this.legend.textContent = '';
const counts = new Map<string, number>();
for (const n of this.graph.nodes) counts.set(n.type ?? 'other', (counts.get(n.type ?? 'other') ?? 0) + 1);
const types = [...counts.keys()].sort((a, b) => (TYPE_ORDER.indexOf(a) + 1 || 99) - (TYPE_ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b));
for (const type of types) {
const chip = el('button', 'ag-chip');
chip.type = 'button';
chip.style.setProperty('--c', colorOf(type));
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
chip.title = `Show or hide ${type} elements`;
chip.append(document.createTextNode(type), el('small', '', String(counts.get(type))));
chip.addEventListener('click', () => {
if (this.hiddenTypes.has(type)) this.hiddenTypes.delete(type);
else this.hiddenTypes.add(type);
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
this.saved.hidden = [...this.hiddenTypes];
this.save();
this.applyHidden();
this.updateCount();
});
this.legend.appendChild(chip);
}
}
// --------------------------------------------------------------- graph
private buildGraph(first: boolean): void {
const holder = this.canvas.firstElementChild as HTMLElement;
this.cy = cytoscape({
container: holder,
elements: this.elements(),
style: this.stylesheet(),
wheelSensitivity: 0.2,
minZoom: 0.1,
maxZoom: 4,
});
this.applyHidden();
this.layout(first);
this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular));
this.cy.on('tap', (event) => {
if (event.target === this.cy) this.showEmptySide();
});
}
private stylesheet(): cytoscape.StylesheetJson {
const p = palette();
return [
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(260, Math.max(84, String(n.data('label')).length * 7.5 + 34)), height: 42, 'background-color': p.panel, 'border-width': 2, 'border-color': 'data(color)', label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '230px' } },
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
{ selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '2px', 'text-rotation': 'autorotate' } },
{ selector: 'edge.labelled', style: { label: 'data(label)' } },
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
{ selector: 'node.hit', style: { 'border-width': 3 } },
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 3, 'background-color': p.panel, 'overlay-opacity': 0 } },
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'overlay-opacity': 0 } },
{ selector: '.hidden', style: { display: 'none' } },
] as unknown as cytoscape.StylesheetJson;
}
private layout(randomize: boolean): void {
const visible = this.cy.elements().not('.hidden');
visible
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: () => 150, edgeElasticity: () => 0.2, gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
.run();
}
private applyHidden(): void {
this.cy.nodes().forEach((n) => {
n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other')));
});
}
private syncLabels(): void {
const on = Boolean(this.saved.labels);
this.labelsButton.setAttribute('aria-pressed', String(on));
this.cy?.edges().toggleClass('labelled', on);
}
// --------------------------------------------------------------- query
private setQuery(text: string): void {
this.queryInput.value = text;
this.applyQuery();
this.queryInput.focus();
}
private applyQuery(): void {
const text = this.queryInput.value;
this.saved.query = text;
this.save();
this.result = runQuery(this.graph, text);
const { nodes, edges, path } = this.result;
const empty = !text.trim();
this.cy.batch(() => {
this.cy.elements().removeClass('dim hit path');
if (empty) return;
this.cy.nodes().forEach((n) => {
n.toggleClass('dim', !nodes.has(n.id())).toggleClass('hit', nodes.has(n.id()));
});
this.cy.edges().forEach((e) => {
e.toggleClass('dim', !edges.has(e.id()));
});
if (path) for (const id of edges) this.cy.$id(id).addClass('path');
});
this.updateCount();
}
private updateCount(): void {
const total = this.cy.nodes().not('.hidden').length;
const r = this.result;
if (!r || !this.queryInput.value.trim()) {
this.countEl.textContent = `${total} elements · ${this.cy.edges().not('.hidden').length} connections`;
this.countEl.classList.remove('is-error');
return;
}
const shown = this.cy.nodes('.hit').not('.hidden').length;
this.countEl.textContent = r.message ? r.message : r.path ? `path of ${r.path.length} elements` : `${shown} of ${total} elements`;
this.countEl.classList.toggle('is-error', Boolean(r.message));
}
private fit(): void {
const hits = this.cy.nodes('.hit').not('.hidden');
const target = hits.length ? hits.union(hits.connectedEdges().not('.dim')) : this.cy.elements().not('.hidden');
this.cy.animate({ fit: { eles: target, padding: 40 }, duration: 250 });
}
// ---------------------------------------------------------------- side
private showEmptySide(): void {
this.side.textContent = '';
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
const h3 = el('h3', '', 'This model');
this.side.appendChild(h3);
const summary = el('p', '', `${this.graph.nodes.length} elements, ${this.graph.edges.length} connections, ${this.graph.diagrams.length} diagrams, ${this.graph.decisions.length} decisions.`);
this.side.appendChild(summary);
const unused = this.graph.nodes.filter((n) => n.shared && !n.diagrams.length).length;
if (unused) {
const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`);
hint.type = 'button';
hint.addEventListener('click', () => this.setQuery('unused'));
this.side.appendChild(hint);
}
}
private showSide(target: NodeSingular | EdgeSingular): void {
if (target.isNode()) this.showNode(this.nodesById.get(target.id()));
else this.showEdge(this.edgesById.get(target.id()));
}
private link(text: string, title: string, onClick?: () => void, className = ''): HTMLButtonElement {
const button = el('button', `ag-link ${className}${onClick ? '' : ' is-static'}`);
button.type = 'button';
button.title = title;
button.appendChild(document.createTextNode(text));
if (onClick) button.addEventListener('click', onClick);
return button;
}
private decisionLinks(ids: string[]): HTMLElement {
const holder = el('div');
for (const id of ids) {
const d = this.graph.decisions.find((x) => x.id === id);
const button = this.link('', d ? `${d.title ?? d.id} (${d.status})` : id, d ? () => this.host.post({ type: 'openDecision', file: d.file }) : undefined, `is-${d?.status ?? 'unknown'}`);
button.prepend(el('span', 'ag-dot'));
button.appendChild(document.createTextNode([id, d?.title, d?.status].filter(Boolean).join(' · ')));
holder.appendChild(button);
}
return holder;
}
private diagramLinks(paths: string[]): HTMLElement {
const holder = el('div');
for (const path of paths) {
const d = this.graph.diagrams.find((x) => x.path === path);
holder.appendChild(this.link(path.replace(/\.archify$/, ''), d?.title ? `Open ${d.title} (${path})` : `Open ${path}`, () => this.host.post({ type: 'openDiagram', path })));
}
return holder;
}
private showNode(node: GraphNode | undefined): void {
if (!node) return;
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(node.type));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', [node.id, node.type, node.sublabel, node.brand ? `brand ${node.brand}` : undefined, node.shared ? 'registry' : 'inline'].filter(Boolean).join(' · ')));
this.side.appendChild(node.description ? el('p', '', node.description) : el('p', 'ag-empty', node.shared ? 'No description in the registry.' : 'Inline element: defined in the diagram, not in a registry.'));
const actions = el('div', 'ag-side-actions');
actions.appendChild(this.link('Neighbours', `Show ${node.id} and everything connected to it`, () => this.setQuery(`uses:${node.id}`)));
if (node.file) actions.appendChild(this.link('Registry', `Open ${node.file} at line ${(node.line ?? 0) + 1}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: node.line ?? 0 })));
if (node.link) actions.appendChild(this.link('Explained by', `Open ${node.link}`, () => this.host.post({ type: 'openDiagram', path: node.link })));
this.side.appendChild(actions);
this.side.appendChild(el('h3', '', `Used in ${node.diagrams.length} diagram${node.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(node.diagrams.length ? this.diagramLinks(node.diagrams) : el('p', 'ag-empty', 'Not drawn by any diagram yet.'));
const connected = this.graph.edges.filter((e) => e.from === node.id || e.to === node.id);
this.side.appendChild(el('h3', '', `${connected.length} connection${connected.length === 1 ? '' : 's'}`));
const list = el('div');
for (const e of connected) {
const other = e.from === node.id ? e.to : e.from;
const otherLabel = this.nodesById.get(other)?.label ?? other;
const text = e.from === node.id ? `→ ${otherLabel}` : `← ${otherLabel}`;
list.appendChild(this.link(e.labels.length ? `${text} · ${e.labels.join(' / ')}` : text, `${e.from} → ${e.to}`, () => {
const edge = this.cy.$id(e.id);
this.cy.elements().unselect();
edge.select();
this.showEdge(e);
}));
}
this.side.appendChild(list);
if (node.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(node.decisions));
}
}
private showEdge(edge: GraphEdge | undefined): void {
if (!edge) return;
this.side.textContent = '';
const from = this.nodesById.get(edge.from);
const to = this.nodesById.get(edge.to);
const title = el('h2');
title.textContent = `${from?.label ?? edge.from} → ${to?.label ?? edge.to}`;
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', `${edge.from} → ${edge.to}${edge.labels.length ? ` · ${edge.labels.join(' / ')}` : ''}`));
const ends = el('div', 'ag-side-actions');
for (const end of [from, to]) {
if (!end) continue;
const button = this.link(end.label, `Show ${end.id}`, () => {
const n = this.cy.$id(end.id);
this.cy.elements().unselect();
n.select();
this.showNode(end);
});
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(end.type));
button.prepend(dot);
ends.appendChild(button);
}
this.side.appendChild(ends);
this.side.appendChild(el('h3', '', `Drawn in ${edge.diagrams.length} diagram${edge.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(this.diagramLinks(edge.diagrams));
if (edge.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(edge.decisions));
}
}
// -------------------------------------------------------------- export
private exportPng(): void {
const blob = this.cy.png({ output: 'blob', full: true, scale: 2, bg: palette().bg });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${this.graph.meta.id || 'model'}-graph.png`;
anchor.hidden = true;
document.body.appendChild(anchor);
anchor.click(); // the bridge turns the download into a VS Code save dialog
setTimeout(() => {
anchor.remove();
URL.revokeObjectURL(url);
}, 1000);
}
// -------------------------------------------------------------- events
private listen(): void {
this.queryInput.addEventListener('input', () => {
window.clearTimeout(this.queryTimer);
this.queryTimer = window.setTimeout(() => this.applyQuery(), 120);
});
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
window.clearTimeout(this.queryTimer);
this.applyQuery();
this.fit();
} else if (event.key === 'Escape') {
if (this.queryInput.value) this.setQuery('');
else this.queryInput.blur();
}
});
window.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement | null;
const typing = target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/') {
event.preventDefault();
this.queryInput.focus();
this.queryInput.select();
} else if (event.key === '0') this.fit();
else if (event.key === 'Escape') {
this.cy.elements().unselect();
this.showEmptySide();
this.help.hidden = true;
}
});
window.addEventListener('resize', () => this.cy.resize());
new MutationObserver(() => {
this.cy.style(this.stylesheet());
this.syncLabels();
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('message', (event) => {
const message = event.data || {};
if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph);
if (message.type === 'query' && typeof message.query === 'string') this.setQuery(message.query);
});
}
private save(): void {
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [] } });
}
}
function start(): void {
const host = window.__archifyHost;
if (!host?.graph) return;
// Exposed for the integration harness; nothing in the page depends on it.
(window as unknown as { __archifyGraphPage?: GraphPage }).__archifyGraphPage = new GraphPage(host, host.graph);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
+63
View File
@@ -0,0 +1,63 @@
/** Page styles of the graph view. VS Code's theme variables, Archify's type colours. */
export const GRAPH_PAGE_STYLE = `
:root { --ag-bg: var(--vscode-editor-background, #0b1020); --ag-fg: var(--vscode-foreground, #e5e7eb); --ag-muted: var(--vscode-descriptionForeground, #9ca3af);
--ag-panel: var(--vscode-editorWidget-background, #111a2e); --ag-border: var(--vscode-editorWidget-border, rgba(128,128,128,.35)); --ag-accent: var(--vscode-textLink-foreground, #22d3ee);
--ag-input: var(--vscode-input-background, #1f2937); --ag-input-fg: var(--vscode-input-foreground, #e5e7eb); --ag-input-border: var(--vscode-input-border, var(--ag-border));
--ag-error: var(--vscode-errorForeground, #f87171); --ag-font: var(--vscode-font-family, system-ui, sans-serif); --ag-mono: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace);
--ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
.ag { display: grid; grid-template-rows: auto auto minmax(0, 1fr); height: 100%; }
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); }
.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); }
.ag-name { font-weight: 700; font-size: 1.125rem; letter-spacing: -0.01em; }
.ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.ag-switch { display: inline-flex; padding: 0; border: 1px solid var(--ag-border); border-radius: 999px; background: var(--ag-panel); color: var(--ag-muted); font: inherit; cursor: pointer; overflow: hidden; }
.ag-switch:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; }
.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.8rem; border-radius: 999px; font-weight: 600; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; }
.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); }
.ag-switch:hover .ag-seg:not(.on) { color: var(--ag-fg); }
.ag-button { font: inherit; font-size: 0.8125rem; font-weight: 600; height: 1.9rem; padding: 0 0.85rem; border-radius: 999px; border: 1px solid var(--ag-border); background: var(--ag-panel); color: var(--ag-fg); cursor: pointer; white-space: nowrap; }
.ag-button:hover { border-color: var(--ag-accent); }
.ag-button[aria-pressed="true"] { background: var(--ag-fg); color: var(--ag-bg); border-color: var(--ag-fg); }
.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; }
.ag-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--ag-border); flex-wrap: wrap; }
.ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.6rem; padding: 0 0.6rem; }
.ag-query-wrap:focus-within { border-color: var(--ag-accent); }
.ag-query { flex: 1; min-width: 0; height: 2rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; }
.ag-query::placeholder { color: var(--ag-muted); opacity: 0.8; }
.ag-clear { border: 0; background: transparent; color: var(--ag-muted); font-size: 1rem; cursor: pointer; padding: 0 0.1rem; }
.ag-clear:hover { color: var(--ag-fg); }
.ag-count { font-family: var(--ag-mono); font-size: 0.8125rem; color: var(--ag-muted); white-space: nowrap; }
.ag-count.is-error { color: var(--ag-error); }
.ag-main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 20rem; min-height: 0; }
.ag-main.is-side-hidden { grid-template-columns: minmax(0, 1fr); }
.ag-canvas { position: relative; min-width: 0; min-height: 0; }
.ag-cy { position: absolute; inset: 0; }
.ag-legend { position: absolute; left: 1rem; bottom: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2rem); }
.ag-chip { display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px; border: 1px solid var(--ag-border); background: color-mix(in srgb, var(--ag-panel) 85%, transparent); color: var(--ag-fg); cursor: pointer; backdrop-filter: blur(6px); }
.ag-chip::before { content: ""; width: 0.55rem; height: 0.55rem; border-radius: 2px; background: var(--c); }
.ag-chip[aria-pressed="false"] { opacity: 0.45; text-decoration: line-through; }
.ag-chip small { color: var(--ag-muted); font-family: var(--ag-mono); }
.ag-side { border-left: 1px solid var(--ag-border); background: var(--ag-panel); overflow: auto; padding: 1rem 1.1rem 1.5rem; min-width: 0; }
.ag-side h2 { margin: 0; font-size: 1.0625rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; }
.ag-dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--c, var(--ag-external)); flex: none; }
.ag-id { font-family: var(--ag-mono); color: var(--ag-muted); font-size: 0.75rem; margin: 0.25rem 0 0.8rem; word-break: break-all; }
.ag-side p { margin: 0 0 0.8rem; line-height: 1.5; white-space: pre-line; }
.ag-side h3 { margin: 0.9rem 0 0.4rem; font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ag-muted); font-weight: 700; }
.ag-link { display: inline-flex; align-items: baseline; gap: 0.35rem; line-height: 1.4; font: inherit; font-family: var(--ag-mono); font-size: 0.75rem; padding: 0.15rem 0.55rem; margin: 0 0.3rem 0.35rem 0; border: 1px solid var(--ag-border); border-radius: 999px; background: transparent; color: var(--ag-fg); cursor: pointer; text-align: left; max-width: 100%; }
.ag-link:hover { border-color: var(--ag-accent); }
.ag-link.is-static { cursor: default; }
.ag-link.is-static:hover { border-color: var(--ag-border); }
.ag-link.is-accepted .ag-dot { background: var(--ag-backend); } .ag-link.is-proposed .ag-dot { background: var(--ag-cloud); } .ag-link.is-rejected .ag-dot { background: var(--ag-security); } .ag-link.is-superseded .ag-dot { background: var(--ag-external); }
.ag-empty { color: var(--ag-muted); }
.ag-side-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
.ag-help { position: absolute; top: 0.5rem; right: 20.75rem; z-index: 5; width: min(26rem, calc(100% - 2rem)); max-height: calc(100% - 1rem); overflow: auto; padding: 0.9rem 1rem; border: 1px solid var(--ag-border); border-radius: 0.75rem; background: var(--ag-panel); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
.ag-main.is-side-hidden .ag-help { right: 0.75rem; }
.ag-help h3 { margin: 0 0 0.5rem; font-size: 0.8125rem; }
.ag-help table { border-collapse: collapse; width: 100%; font-size: 0.8125rem; }
.ag-help td { padding: 0.25rem 0.4rem 0.25rem 0; vertical-align: top; border-top: 1px solid var(--ag-border); }
.ag-help td:first-child { font-family: var(--ag-mono); white-space: nowrap; color: var(--ag-accent); cursor: pointer; }
.ag-help td:first-child:hover { text-decoration: underline; }
.ag-help p { margin: 0.6rem 0 0; color: var(--ag-muted); font-size: 0.75rem; }
@media (max-width: 760px) { .ag-main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; } .ag-side { border-left: 0; border-top: 1px solid var(--ag-border); max-height: 40%; } .ag-help { right: 0.75rem; } }
`;
+49 -2
View File
@@ -309,6 +309,23 @@ export function groupOf(relativePath: string): string {
return parts.join('/'); return parts.join('/');
} }
/** A node as a diagram declares it: a `ref` to the registry, or an inline element. */
export interface DiagramNode {
id: string;
ref?: string;
type?: string;
label?: string;
sublabel?: string;
brand?: string;
}
/** A connection, edge, flow, transition or message between two nodes of a diagram. */
export interface DiagramEdge {
from: string;
to: string;
label?: string;
}
export interface DiagramSummary { export interface DiagramSummary {
relativePath: string; relativePath: string;
group: string; group: string;
@@ -317,6 +334,10 @@ export interface DiagramSummary {
used: string[]; used: string[];
/** Decisions the diagram points at. */ /** Decisions the diagram points at. */
decisions: DecisionRef[]; decisions: DecisionRef[];
/** Every node the diagram draws, shared or inline, in file order. */
nodes: DiagramNode[];
/** Every edge between two of those nodes. */
edges: DiagramEdge[];
parseError?: string; parseError?: string;
} }
@@ -326,14 +347,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
try { try {
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false }); doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch (error) { } catch (error) {
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) }; return { relativePath, group: groupOf(relativePath), used: [], decisions: [], nodes: [], edges: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
} }
const obj = isObj(doc) ? doc : {}; const obj = isObj(doc) ? doc : {};
const meta = isObj(obj.meta) ? obj.meta : {}; const meta = isObj(obj.meta) ? obj.meta : {};
const components = Array.isArray(obj.components) ? obj.components : []; const components = Array.isArray(obj.components) ? obj.components : [];
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))]; const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []); const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions }; const nodes: DiagramNode[] = [];
const seen = new Set<string>();
for (const key of NODE_COLLECTIONS) {
const items = obj[key];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!isObj(item)) continue;
const ref = str(item.ref);
const id = str(item.id) ?? ref;
if (!id || seen.has(id)) continue;
seen.add(id);
nodes.push({ id, ref, type: str(item.type), label: str(item.label), sublabel: str(item.sublabel), brand: str(item.brand) });
}
}
const edges: DiagramEdge[] = [];
for (const key of EDGE_COLLECTIONS) {
const items = obj[key];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!isObj(item)) continue;
const from = str(item.from);
const to = str(item.to);
if (!from || !to || !seen.has(from) || !seen.has(to)) continue;
edges.push({ from, to, label: str(item.label) });
}
}
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions, nodes, edges };
} }
/** Groups in display order: the root group first, then folders in path order. */ /** Groups in display order: the root group first, then folders in path order. */
+147
View File
@@ -0,0 +1,147 @@
import { Decision, DecisionSet, effectiveStatus } from './decisions';
import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './format';
/**
* The model as one graph: every element any diagram draws (and every registry
* element, drawn or not), every connection any diagram has between two of
* them, each once. Pure: built from the model index, no VS Code imports, so
* the webview bundle and the unit tests share it.
*/
export interface GraphNode {
id: string;
label: string;
sublabel?: string;
type?: string;
brand?: string;
/** Defined in a registry (true) or inline in one diagram (false). */
shared: boolean;
description?: string;
/** Registry file and 0-based line, for shared elements. */
file?: string;
line?: number;
/** Diagram that explains the element, relative to the model root. */
link?: string;
/** Diagrams that draw it, relative paths in path order. */
diagrams: string[];
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
decisions: string[];
}
export interface GraphEdge {
/** `from>to`, unique in the graph. */
id: string;
from: string;
to: string;
/** Distinct labels the diagrams give this connection. */
labels: string[];
diagrams: string[];
decisions: string[];
}
export interface GraphDecision {
id: string;
title?: string;
status: string;
file: string;
}
export interface GraphDiagram {
path: string;
title?: string;
kind?: string;
}
export interface ModelGraph {
meta: ModelMeta;
nodes: GraphNode[];
edges: GraphEdge[];
decisions: GraphDecision[];
diagrams: GraphDiagram[];
}
export interface GraphInput {
meta: ModelMeta;
registry: Registry;
diagrams: DiagramSummary[];
decisions: DecisionSet;
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
}
export const edgeId = (from: string, to: string): string => `${from}>${to}`;
function pushUnique(list: string[], value: string): void {
if (!list.includes(value)) list.push(value);
}
export function buildGraph(input: GraphInput): ModelGraph {
const nodes = new Map<string, GraphNode>();
const edges = new Map<string, GraphEdge>();
// Registry elements first: shared, and present even when no diagram draws them yet.
for (const e of input.registry.elements.values()) {
nodes.set(e.id, {
id: e.id,
label: e.label ?? e.id,
sublabel: e.sublabel,
type: e.type,
brand: e.brand,
shared: true,
description: e.description?.trim() || undefined,
file: e.file,
line: e.line,
link: e.diagram,
diagrams: [],
decisions: [],
});
}
const diagrams = [...input.diagrams].filter((d) => !d.parseError).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
for (const d of diagrams) {
for (const n of d.nodes) {
let node = nodes.get(n.id);
if (!node) {
const element = n.ref ? input.registry.elements.get(n.ref) : undefined;
node = element
? { ...nodes.get(element.id)!, id: n.id, label: n.label ?? element.label ?? n.id, sublabel: n.sublabel ?? element.sublabel, diagrams: [], decisions: [] }
: { id: n.id, label: n.label ?? n.ref ?? n.id, sublabel: n.sublabel, type: n.type, brand: n.brand, shared: false, diagrams: [], decisions: [] };
nodes.set(n.id, node);
}
pushUnique(node.diagrams, d.relativePath);
}
for (const e of d.edges) {
const id = edgeId(e.from, e.to);
let edge = edges.get(id);
if (!edge) {
edge = { id, from: e.from, to: e.to, labels: [], diagrams: [], decisions: [] };
edges.set(id, edge);
}
if (e.label) pushUnique(edge.labels, e.label);
pushUnique(edge.diagrams, d.relativePath);
}
}
const all = input.decisions.decisions;
for (const d of all.values()) for (const id of d.elements) nodes.get(id)?.decisions && pushUnique(nodes.get(id)!.decisions, d.id);
for (const [decisionId, uses] of input.decisionUsage) {
if (!all.has(decisionId)) continue;
for (const use of uses) {
if (use.attachedTo.kind === 'node') {
const node = nodes.get(use.attachedTo.id);
if (node) pushUnique(node.decisions, decisionId);
} else if (use.attachedTo.kind === 'edge') {
const edge = edges.get(edgeId(use.attachedTo.from, use.attachedTo.to));
if (edge) pushUnique(edge.decisions, decisionId);
}
}
}
const decisionRow = (d: Decision): GraphDecision => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file });
return {
meta: input.meta,
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
decisions: [...all.values()].map(decisionRow).sort((a, b) => a.id.localeCompare(b.id)),
diagrams: diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind })),
};
}
+234
View File
@@ -0,0 +1,234 @@
import { GraphEdge, GraphNode, ModelGraph } from './graph';
/**
* The graph page's query box. A few words, no language to learn: terms are
* ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk
* the graph. Pure, shared by the webview bundle and the unit tests.
*
* database label, id, sublabel, brand or type contain it
* type:backend elements of that type
* diagram:0200-* drawn by a diagram whose path matches (glob)
* decision:<id> what the decision is about or attached to
* uses:api api and its neighbours
* near:api,2 api and everything within two steps
* from:web to:db the shortest path between two elements
* unused registry elements no diagram draws
* shared / -shared registry elements / inline elements
*/
export interface QueryTerm {
key: string;
value: string;
negate: boolean;
}
export interface QueryResult {
/** Ids of the elements that match. Every element when the query is empty. */
nodes: Set<string>;
/** Ids of the connections shown: both ends match, or the path found. */
edges: Set<string>;
/** The path found by `from: to:`, in order. */
path?: string[];
/** Why the result is empty or partial, when it is. */
message?: string;
terms: QueryTerm[];
}
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
export function parseQuery(text: string): QueryTerm[] {
const tokens = text.trim().split(/\s+/).filter(Boolean);
const terms: QueryTerm[] = [];
for (let i = 0; i < tokens.length; i += 1) {
let token = tokens[i];
const negate = token.startsWith('-') && token.length > 1;
if (negate) token = token.slice(1);
const colon = token.indexOf(':');
let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase();
let value = colon > 0 ? token.slice(colon + 1) : '';
if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) {
key = 'text';
value = token;
} else if (colon < 0 && key !== 'shared' && key !== 'unused') {
key = 'text';
value = token;
}
// `near:api 2` is also accepted: a bare number after near is the hop count.
if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) {
value = `${value},${tokens[i + 1]}`;
i += 1;
}
if (!value && key === 'text') continue;
terms.push({ key, value, negate });
}
return terms;
}
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
/** An element by id, then by id or label ignoring case. */
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
const exact = graph.nodes.find((n) => n.id === ref);
if (exact) return exact;
const l = ref.toLowerCase();
return graph.nodes.find((n) => n.id.toLowerCase() === l) ?? graph.nodes.find((n) => n.label.toLowerCase() === l);
}
function globToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${escaped}$`, 'i');
}
function diagramMatches(path: string, pattern: string): boolean {
const bare = path.replace(/\.archify$/, '');
const wanted = pattern.replace(/\.archify$/, '');
if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path);
const l = wanted.toLowerCase();
return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`);
}
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const n of graph.nodes) map.set(n.id, new Set());
for (const e of graph.edges) {
map.get(e.from)?.add(e.to);
map.get(e.to)?.add(e.from);
}
return map;
}
function within(graph: ModelGraph, start: string, hops: number): Set<string> {
const adjacency = neighbours(graph);
let frontier = new Set([start]);
const seen = new Set([start]);
for (let i = 0; i < hops; i += 1) {
const next = new Set<string>();
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
frontier = next;
if (!frontier.size) break;
}
return seen;
}
/** Shortest path by breadth-first search; directed first, then ignoring direction. */
export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined {
const attempt = (directed: boolean) => {
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
for (const n of graph.nodes) out.set(n.id, []);
for (const e of graph.edges) {
out.get(e.from)?.push({ to: e.to, edge: e });
if (!directed) out.get(e.to)?.push({ to: e.from, edge: e });
}
const previous = new Map<string, { from: string; edge: GraphEdge }>();
const queue = [from];
const seen = new Set([from]);
while (queue.length) {
const id = queue.shift()!;
if (id === to) break;
for (const step of out.get(id) ?? []) {
if (seen.has(step.to)) continue;
seen.add(step.to);
previous.set(step.to, { from: id, edge: step.edge });
queue.push(step.to);
}
}
if (!seen.has(to)) return undefined;
const nodes = [to];
const edges: string[] = [];
let at = to;
while (at !== from) {
const step = previous.get(at)!;
edges.unshift(step.edge.id);
nodes.unshift(step.from);
at = step.from;
}
return { nodes, edges };
};
if (from === to) return { nodes: [from], edges: [] };
return attempt(true) ?? attempt(false);
}
export function runQuery(graph: ModelGraph, text: string): QueryResult {
const terms = parseQuery(text);
const everything = new Set(graph.nodes.map((n) => n.id));
let result = new Set(everything);
const notes: string[] = [];
let from: string | undefined;
let to: string | undefined;
let pathEdges: Set<string> | undefined;
let path: string[] | undefined;
const matching = (term: QueryTerm): Set<string> | undefined => {
const v = term.value;
switch (term.key) {
case 'text': {
const l = v.toLowerCase();
return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id));
}
case 'type':
return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id));
case 'diagram':
return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id));
case 'decision': {
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
return ids;
}
case 'shared':
return new Set(graph.nodes.filter((n) => n.shared).map((n) => n.id));
case 'unused':
return new Set(graph.nodes.filter((n) => n.shared && n.diagrams.length === 0).map((n) => n.id));
case 'uses':
case 'near': {
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
const node = findNode(graph, ref);
if (!node) { notes.push(`No element "${ref}".`); return new Set(); }
const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50));
return within(graph, node.id, hops);
}
case 'from':
from = v;
return undefined;
case 'to':
to = v;
return undefined;
}
return undefined;
};
for (const term of terms) {
const ids = matching(term);
if (!ids) continue;
if (term.negate) for (const id of ids) result.delete(id);
else result = new Set([...result].filter((id) => ids.has(id)));
}
if (from || to) {
if (!from || !to) notes.push('A path needs both from: and to:.');
else {
const a = findNode(graph, from);
const b = findNode(graph, to);
if (!a || !b) notes.push(`No element "${!a ? from : to}".`);
else {
const found = shortestPath(graph, a.id, b.id);
if (!found) notes.push(`No path from ${a.id} to ${b.id}.`);
else {
path = found.nodes;
pathEdges = new Set(found.edges);
result = new Set(found.nodes.filter((id) => result.has(id)));
}
}
if (!path) result = new Set();
}
}
const edges = new Set<string>();
for (const e of graph.edges) {
if (!result.has(e.from) || !result.has(e.to)) continue;
if (pathEdges && !pathEdges.has(e.id)) continue;
edges.add(e.id);
}
return { nodes: result, edges, path, message: notes[0], terms };
}
+2 -12
View File
@@ -2,6 +2,7 @@ import { layerMarkup, rasterize } from './raster';
import { CameraState, Layers, Stage } from './stage'; import { CameraState, Layers, Stage } from './stage';
import { DiagramModel, readModel } from './svgModel'; import { DiagramModel, readModel } from './svgModel';
import { STYLE } from './style'; import { STYLE } from './style';
import type { ArchifyHost } from '../webview/host';
/** /**
* 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's * 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's
@@ -12,12 +13,7 @@ import { STYLE } from './style';
* follows Archify's animations as they play. * follows Archify's animations as they play.
*/ */
interface Host { type Host = ArchifyHost;
defaultView: '2d' | '3d';
post(message: unknown): void;
getState(): Record<string, unknown>;
setState(patch: Record<string, unknown>): void;
}
interface SavedView { interface SavedView {
active?: boolean; active?: boolean;
@@ -25,12 +21,6 @@ interface SavedView {
camera?: CameraState; camera?: CameraState;
} }
declare global {
interface Window {
__archifyHost?: Host;
}
}
const REDRAW_MS = 220; const REDRAW_MS = 220;
const MARGIN = 80; const MARGIN = 80;
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */ /** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
+108 -10
View File
@@ -2,7 +2,8 @@ import path from 'node:path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { renderDiagram } from '../archify/cli'; import { renderDiagram } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime'; import { evidenceMode, runtimeFor } from '../archify/runtime';
import { isMainFile, isRegistryFile } from '../model/format'; import { MAIN_FILE, isMainFile, isRegistryFile } from '../model/format';
import { buildGraph } from '../model/graph';
import { ModelPanel, panelFromIndex } from '../model/panel'; import { ModelPanel, panelFromIndex } from '../model/panel';
import { prepareDiagram } from '../model/prepare'; import { prepareDiagram } from '../model/prepare';
import { ModelService } from '../model/service'; import { ModelService } from '../model/service';
@@ -11,8 +12,8 @@ import { readText } from '../util/files';
import { log } from '../util/log'; import { log } from '../util/log';
import { viewerTheme } from '../util/settings'; import { viewerTheme } from '../util/settings';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page'; import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d'; import { graphScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
@@ -21,8 +22,18 @@ export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
interface PreviewState { interface PreviewState {
uri: string; uri: string;
hash?: string; hash?: string;
/** main.archify only: the Graph page is shown instead of the model page. */
graph?: boolean;
} }
/** What a preview asks of its manager. */
interface PreviewHost {
/** Opens the Graph page of the model at `root`, with a query to start from. */
openGraph(root: vscode.Uri, query?: string): Promise<void>;
}
const GRAPH_MODE_KEY = 'archify.graphMode';
class DiagramPreview implements vscode.Disposable { class DiagramPreview implements vscode.Disposable {
private seq = 0; private seq = 0;
private hash: string | undefined; private hash: string | undefined;
@@ -34,10 +45,16 @@ class DiagramPreview implements vscode.Disposable {
readonly onDidDispose = this.onDisposeEmitter.event; readonly onDidDispose = this.onDisposeEmitter.event;
/** Root of the model this file belongs to, once known. */ /** Root of the model this file belongs to, once known. */
root: vscode.Uri | undefined; root: vscode.Uri | undefined;
/** main.archify: show the Graph page instead of the model page. Remembered per file across reloads. */
private graph = false;
private graphQuery: string | undefined;
/** True while the webview shows the Graph page, which takes model updates as messages. */
private graphShown = false;
constructor( constructor(
private readonly context: vscode.ExtensionContext, private readonly context: vscode.ExtensionContext,
private readonly models: ModelService, private readonly models: ModelService,
private readonly host: PreviewHost,
readonly panel: vscode.WebviewPanel, readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri, readonly uri: vscode.Uri,
state?: PreviewState, state?: PreviewState,
@@ -45,6 +62,7 @@ class DiagramPreview implements vscode.Disposable {
readonly isEditor = false, readonly isEditor = false,
) { ) {
this.hash = state?.hash; this.hash = state?.hash;
this.graph = state?.graph ?? this.graphModes()[uri.toString()] === true;
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
if (!isEditor) { if (!isEditor) {
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
@@ -70,6 +88,15 @@ class DiagramPreview implements vscode.Disposable {
void this.openLinked(message.file, DECISION_VIEW_TYPE); void this.openLinked(message.file, DECISION_VIEW_TYPE);
return; return;
} }
if (message.type === 'showGraph') {
if (isMainFile(this.uri.path)) this.setGraph(true, message.query);
else if (this.root) void this.host.openGraph(this.root, message.query);
return;
}
if (message.type === 'showModel') {
this.setGraph(false);
return;
}
void handleWebviewMessage(message, this.uri, this.panel); void handleWebviewMessage(message, this.uri, this.panel);
}), }),
); );
@@ -89,8 +116,51 @@ class DiagramPreview implements vscode.Disposable {
void this.update(); void this.update();
} }
/** Posts to the webview; a panel closed mid-update is not an error. */
private post(message: unknown): void {
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
}
private state(): PreviewState { private state(): PreviewState {
return { uri: this.uri.toString(), hash: this.hash }; return { uri: this.uri.toString(), hash: this.hash, graph: this.graph };
}
private graphModes(): Record<string, boolean> {
return this.context.workspaceState.get<Record<string, boolean>>(GRAPH_MODE_KEY, {});
}
/** Switches a main.archify editor between its model page and the Graph page. */
setGraph(on: boolean, query?: string): void {
this.graph = on;
this.graphQuery = query;
const modes = this.graphModes();
if (on) modes[this.uri.toString()] = true;
else delete modes[this.uri.toString()];
void this.context.workspaceState.update(GRAPH_MODE_KEY, modes);
this.lastHtml = undefined;
this.rendered = false;
if (!on) this.graphShown = false;
else if (this.graphShown && query !== undefined) this.post({ type: 'query', query });
void this.update(true);
}
/** The Graph page of the model, or an update of it when it is already showing. */
private showGraphPage(index: Awaited<ReturnType<ModelService['index']>>): void {
const graph = buildGraph(index);
const fingerprint = `graph\u0000${JSON.stringify(graph)}`;
if (fingerprint === this.lastHtml && this.graphShown) return;
this.lastHtml = fingerprint;
this.rendered = false;
if (this.graphShown) {
this.post({ type: 'graph', graph });
return;
}
this.graphShown = true;
this.panel.webview.html = graphPage(
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
{ graph, query: this.graphQuery, scriptUri: graphScriptUri(this.panel.webview, this.context.extensionUri) },
);
this.graphQuery = undefined;
} }
private showMessage(title: string, body: string, detail?: string): void { private showMessage(title: string, body: string, detail?: string): void {
@@ -121,6 +191,7 @@ class DiagramPreview implements vscode.Disposable {
if (fingerprint === this.lastHtml) return; if (fingerprint === this.lastHtml) return;
this.lastHtml = fingerprint; this.lastHtml = fingerprint;
this.rendered = false; this.rendered = false;
this.graphShown = false;
this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model); this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model);
} }
@@ -146,7 +217,13 @@ class DiagramPreview implements vscode.Disposable {
if (main) { if (main) {
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath)); this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
try { try {
model = panelFromIndex(await this.models.index(this.root)); const index = await this.models.index(this.root);
if (seq !== this.seq) return;
if (this.graph) {
this.showGraphPage(index);
return;
}
model = panelFromIndex(index);
} catch (error) { } catch (error) {
log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`); log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -159,6 +236,7 @@ class DiagramPreview implements vscode.Disposable {
} }
if (seq !== this.seq) return; if (seq !== this.seq) return;
if (!main) this.root = prepared?.root; if (!main) this.root = prepared?.root;
this.graphShown = false;
if (!prepared) { if (!prepared) {
if (model) { if (model) {
this.showModelPage(model); this.showModelPage(model);
@@ -184,7 +262,7 @@ class DiagramPreview implements vscode.Disposable {
if (!result.ok || !result.html) { if (!result.ok || !result.html) {
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`); log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
if (this.rendered && this.lastHtml) { if (this.rendered && this.lastHtml) {
void this.panel.webview.postMessage({ this.post({
type: 'notice', type: 'notice',
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` }, notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
}); });
@@ -206,11 +284,12 @@ class DiagramPreview implements vscode.Disposable {
descriptions: Object.fromEntries(prepared.descriptions), descriptions: Object.fromEntries(prepared.descriptions),
decisions: prepared.decisions, decisions: prepared.decisions,
model, model,
graphSwitch: main ? { left: 'Model' } : prepared.root ? { left: 'Diagram', query: `diagram:${path.relative(prepared.root.fsPath, this.uri.fsPath).split(path.sep).join('/')}` } : undefined,
}); });
// Nonces differ per render, so compare the renderer output instead. // Nonces differ per render, so compare the renderer output instead.
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`; const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`;
if (!force && this.rendered && fingerprint === this.lastHtml) { if (!force && this.rendered && fingerprint === this.lastHtml) {
void this.panel.webview.postMessage({ type: 'notice', notice: undefined }); this.post({ type: 'notice', notice: undefined });
return; return;
} }
this.lastHtml = fingerprint; this.lastHtml = fingerprint;
@@ -286,10 +365,29 @@ export class DiagramViewManager
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key); return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
} }
private readonly host: PreviewHost = { openGraph: (root, query) => this.showGraph(root, query) };
/** Opening a .archify file lands here: the diagram is the editor. */ /** Opening a .archify file lands here: the diagram is the editor. */
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> { async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, document.uri, undefined, true));
}
/** Opens the model's main.archify as its Graph page, with a query to start from. */
async showGraph(root: vscode.Uri, query?: string): Promise<void> {
const main = vscode.Uri.joinPath(root, MAIN_FILE);
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
const key = main.toString();
const deadline = Date.now() + 5000;
for (;;) {
const editor = [...this.editors].find((e) => e.uri.toString() === key && e.panel.active) ?? [...this.editors].find((e) => e.uri.toString() === key);
if (editor) {
editor.setGraph(true, query);
return;
}
if (Date.now() > deadline) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
} }
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void { show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
@@ -304,7 +402,7 @@ export class DiagramViewManager
{ viewColumn, preserveFocus }, { viewColumn, preserveFocus },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) }, { enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
); );
this.track(new DiagramPreview(this.context, this.models, panel, uri)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri));
} }
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
@@ -312,7 +410,7 @@ export class DiagramViewManager
panel.dispose(); panel.dispose();
return; return;
} }
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, vscode.Uri.parse(state.uri), state));
} }
private track(preview: DiagramPreview): void { private track(preview: DiagramPreview): void {
+20
View File
@@ -0,0 +1,20 @@
import type { ModelGraph } from '../model/graph';
/**
* What the bridge script (page.ts) exposes to the bundled page scripts as
* `window.__archifyHost`: they cannot call acquireVsCodeApi a second time.
*/
export interface ArchifyHost {
defaultView: '2d' | '3d';
post(message: unknown): void;
getState(): Record<string, unknown>;
setState(patch: Record<string, unknown>): void;
/** The Graph page's data, present on that page only. */
graph?: { graph: ModelGraph; query?: string };
}
declare global {
interface Window {
__archifyHost?: ArchifyHost;
}
}
+4
View File
@@ -12,6 +12,8 @@ export type WebviewMessage =
| { type: 'openDiagram'; path: string } | { type: 'openDiagram'; path: string }
| { type: 'openRegistry'; file: string; line?: number } | { type: 'openRegistry'; file: string; line?: number }
| { type: 'openDecision'; file: string } | { type: 'openDecision'; file: string }
| { type: 'showGraph'; query?: string }
| { type: 'showModel' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
/** Column for opening files next to a webview without covering it. */ /** Column for opening files next to a webview without covering it. */
@@ -96,6 +98,8 @@ export async function handleWebviewMessage(
break; break;
case 'hash': case 'hash':
case 'openDiagram': case 'openDiagram':
case 'showGraph':
case 'showModel':
// Handled by the view, which knows the model root. // Handled by the view, which knows the model root.
break; break;
} }
+72 -3
View File
@@ -26,8 +26,13 @@ export interface PageOptions {
decision?: DecisionPanel; decision?: DecisionPanel;
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */ /** Diagram page: decisions of this diagram, for the Decisions rail panel. */
decisions?: DiagramDecision[]; decisions?: DiagramDecision[];
/** Pages inside a model get a switch to the model's Graph page; `left` names the page they are on. */
graphSwitch?: { left: 'Model' | 'Diagram'; query?: string };
/** The Graph page: the model as one graph, the bundled script that draws it, and the query to start with. */
graph?: { graph: ModelGraph; query?: string; scriptUri: string };
} }
import type { ModelGraph } from '../model/graph';
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel'; import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
import type { DiagramDecision } from '../model/prepare'; import type { DiagramDecision } from '../model/prepare';
@@ -87,6 +92,8 @@ function bridgeScript(options: PageOptions): string {
decision: options.decision, decision: options.decision,
decisions: options.decisions || [], decisions: options.decisions || [],
descriptions: options.descriptions || {}, descriptions: options.descriptions || {},
graphSwitch: options.graphSwitch,
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
}; };
return `(function () { return `(function () {
var config = ${scriptJson(config)}; var config = ${scriptJson(config)};
@@ -110,7 +117,8 @@ function bridgeScript(options: PageOptions): string {
defaultView: config.defaultView || '2d', defaultView: config.defaultView || '2d',
post: post, post: post,
getState: function () { return vscode.getState() || {}; }, getState: function () { return vscode.getState() || {}; },
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); } setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); },
graph: config.graph
}; };
function saveBlobUrl(url, filename) { function saveBlobUrl(url, filename) {
@@ -668,6 +676,32 @@ function bridgeScript(options: PageOptions): string {
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body); if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
} }
// Model | Graph (or Diagram | Graph): Export's twin in Archify's toolbar,
// or at the top of the model page. Graph opens the model's Graph page.
function renderModeSwitch() {
var sw = config.graphSwitch;
if (!sw || document.getElementById('archify-mode-switch')) return;
var button = el('button', 'archify-mode-switch');
button.id = 'archify-mode-switch';
button.type = 'button';
button.title = 'Open the model as one graph, with a query box';
button.setAttribute('aria-label', sw.left + ' view; switch to the model graph');
button.appendChild(el('span', 'archify-mode-seg on', sw.left));
button.appendChild(el('span', 'archify-mode-seg', 'Graph'));
button.addEventListener('click', function (event) {
event.stopPropagation();
post({ type: 'showGraph', query: sw.query });
});
var exportWrap = document.querySelector('.toolbar .export-wrap');
var head = document.querySelector('.archify-model-head');
if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(button, exportWrap);
else if (head) {
var row = el('div', 'archify-model-switch-row');
row.appendChild(button);
head.insertBefore(row, head.firstChild);
} else document.body.appendChild(button);
}
window.addEventListener('message', function (event) { window.addEventListener('message', function (event) {
var message = event.data || {}; var message = event.data || {};
if (message.type === 'notice') showNotice(message.notice); if (message.type === 'notice') showNotice(message.notice);
@@ -682,6 +716,7 @@ function bridgeScript(options: PageOptions): string {
onReady(renderConnectors); onReady(renderConnectors);
onReady(renderDecisionsRail); onReady(renderDecisionsRail);
onReady(describeElements); onReady(describeElements);
onReady(renderModeSwitch);
if (config.notice) { if (config.notice) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); }); document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
@@ -778,6 +813,19 @@ const MODEL_STYLE = `
.archify-model-used-link:hover { border-color: var(--m-accent); } .archify-model-used-link:hover { border-color: var(--m-accent); }
`; `;
/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */
const SWITCH_STYLE = `
.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; border: 0; background: transparent; color: var(--text-muted, var(--vscode-descriptionForeground)); font: inherit; cursor: pointer; position: relative; }
.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; }
.archify-mode-seg.on { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.archify-mode-switch:hover .archify-mode-seg:not(.on) { color: var(--text, var(--vscode-foreground)); }
.archify-mode-switch:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 2px; border-radius: 0.75rem; }
.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; }
.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); padding: 0.1rem 0.25rem; }
`;
/* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */ /* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */
const CONNECTOR_STYLE = ` const CONNECTOR_STYLE = `
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); } .diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
@@ -820,7 +868,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
const head = const head =
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` + `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` + `<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`; `<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${SWITCH_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
let page = html let page = html
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements. // Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
@@ -848,7 +896,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
/** The model page for a main.archify without a diagram of its own. */ /** The model page for a main.archify without a diagram of its own. */
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string { export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {} }); const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' } });
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -859,6 +907,27 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
<style> <style>
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); } body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
${MODEL_STYLE} ${MODEL_STYLE}
${SWITCH_STYLE}
</style>
</head>
<body></body>
</html>`;
}
/** The Graph page of a model: the bundled script builds everything from the host's graph data. */
export function graphPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, graph: NonNullable<PageOptions['graph']>): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph });
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
<script nonce="${options.nonce}">${bridge}</script>
<script nonce="${options.nonce}" defer src="${escapeHtml(graph.scriptUri)}"></script>
<style>
html, body { margin: 0; height: 100%; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
${NOTICE_STYLE}
</style> </style>
</head> </head>
<body></body> <body></body>
+5
View File
@@ -13,3 +13,8 @@ export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri
defaultView, defaultView,
}; };
} }
/** The Graph page script (a webview URI). */
export function graphScriptUri(webview: vscode.Webview, extensionUri: vscode.Uri): string {
return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'graph.js')).toString();
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { mergeDecisions, parseDecision } from '../src/model/decisions';
import { DecisionAttachment, mergeRegistries, parseRegistry, summarizeDiagram } from '../src/model/format';
import { ModelGraph, buildGraph } from '../src/model/graph';
import { parseQuery, runQuery, shortestPath } from '../src/model/graphQuery';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
const REGISTRY_FILES = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
const DIAGRAM_FILES = ['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify'];
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
function graphOfModel(): ModelGraph {
const registry = mergeRegistries(REGISTRY_FILES.map((file) => ({ file, ...parseRegistry(read(file), file) })));
const diagrams = DIAGRAM_FILES.map((file) => summarizeDiagram(read(file), file));
const decisions = mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
const decisionUsage = new Map<string, { path: string; attachedTo: DecisionAttachment }[]>();
for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]);
return buildGraph({ meta: { id: 'online-shop', name: 'Online Shop' }, registry, diagrams, decisions, decisionUsage });
}
test('diagram summaries carry nodes and edges, shared and inline', () => {
const api = summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify');
assert.deepEqual(api.nodes.map((n) => n.id), ['web', 'auth', 'orders', 'catalogue', 'db', 'queue']);
assert.equal(api.nodes[0].ref, 'web');
assert.equal(api.nodes[1].ref, undefined);
assert.equal(api.nodes[1].type, 'security');
assert.equal(api.edges.length, 6);
assert.deepEqual(api.edges[0], { from: 'web', to: 'auth', label: 'sign in' });
const broken = summarizeDiagram('diagram_type: [', 'x.archify');
assert.deepEqual(broken.nodes, []);
assert.deepEqual(broken.edges, []);
});
test('the model graph unites every diagram: elements once, connections once, labels merged', () => {
const graph = graphOfModel();
// 11 registry elements + 3 inline components of the API diagram.
assert.equal(graph.nodes.length, 14);
const db = graph.nodes.find((n) => n.id === 'db')!;
assert.equal(db.shared, true);
assert.equal(db.type, 'database');
assert.deepEqual(db.diagrams, ['0200-containers/online-shop.archify', '0300-components/api.archify']);
assert.equal(db.file, 'registry/containers.archify-registry');
assert.ok(db.description);
const orders = graph.nodes.find((n) => n.id === 'orders')!;
assert.equal(orders.shared, false);
assert.deepEqual(orders.diagrams, ['0300-components/api.archify']);
// api → db is drawn once in the containers diagram; orders → db and catalogue → db are separate connections.
assert.equal(graph.edges.length, 4 + 7 + 6);
const apiDb = graph.edges.find((e) => e.id === 'api>db')!;
assert.deepEqual(apiDb.labels, ['SQL']);
assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']);
assert.deepEqual(graph.diagrams.map((d) => d.path), DIAGRAM_FILES);
});
test('decisions reach the elements they name and the nodes and edges they are attached to', () => {
const graph = graphOfModel();
const queue = graph.nodes.find((n) => n.id === 'queue')!;
assert.deepEqual(queue.decisions, ['queue-between-api-and-worker']);
const api = graph.nodes.find((n) => n.id === 'api')!;
assert.deepEqual([...api.decisions].sort(), ['opensearch-for-product-search', 'postgres-as-source-of-truth', 'queue-between-api-and-worker']);
assert.deepEqual(graph.edges.find((e) => e.id === 'api>queue')!.decisions, ['queue-between-api-and-worker']);
assert.equal(graph.decisions.length, 3);
assert.equal(graph.decisions.find((d) => d.id === 'postgres-as-source-of-truth')!.status, 'accepted');
});
test('an unused registry element is in the graph with no diagrams', () => {
const registry = mergeRegistries([{ file: 'r', ...parseRegistry('elements:\n api: { type: backend, label: API }\n ghost: { type: database, label: Ghost }\n', 'r') }]);
const diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')];
const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() });
assert.deepEqual(graph.nodes.map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]);
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost']);
});
test('queries parse into terms; unknown keys are plain text', () => {
assert.deepEqual(parseQuery('database type:backend -shared near:api 2 foo:bar unused'), [
{ key: 'text', value: 'database', negate: false },
{ key: 'type', value: 'backend', negate: false },
{ key: 'shared', value: '', negate: true },
{ key: 'near', value: 'api,2', negate: false },
{ key: 'text', value: 'foo:bar', negate: false },
{ key: 'unused', value: '', negate: false },
]);
assert.deepEqual(parseQuery(' '), []);
});
test('queries narrow the graph: text, type, diagram, decision, shared', () => {
const graph = graphOfModel();
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
assert.equal(runQuery(graph, '').nodes.size, 14);
assert.deepEqual(ids('type:database'), ['cache', 'db']);
assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']);
assert.deepEqual(ids('worker'), ['worker']);
assert.deepEqual(ids('diagram:0100-*'), ['customer', 'email', 'psp', 'shop', 'support']);
assert.deepEqual(ids('diagram:0300-components/api'), ['auth', 'catalogue', 'db', 'orders', 'queue', 'web']);
assert.deepEqual(ids('-shared'), ['auth', 'catalogue', 'orders']);
assert.deepEqual(ids('shared type:backend'), ['api', 'worker']);
assert.deepEqual(ids('decision:queue-between-api-and-worker'), ['api', 'queue', 'worker']);
assert.deepEqual(ids('type:backend -worker'), ['api', 'catalogue', 'orders']);
const unknown = runQuery(graph, 'decision:nope');
assert.equal(unknown.nodes.size, 0);
assert.match(unknown.message ?? '', /No decision "nope"/);
});
test('queries walk the graph: uses, near, from/to', () => {
const graph = graphOfModel();
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
assert.deepEqual(ids('uses:api'), ['api', 'cache', 'db', 'queue', 'web']);
assert.deepEqual(ids('uses:API'), ['api', 'cache', 'db', 'queue', 'web']);
assert.deepEqual(ids('near:worker,2'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']);
assert.deepEqual(ids('near:worker 2 type:database'), ['db']);
const result = runQuery(graph, 'from:customer to:db');
assert.deepEqual(result.path, ['customer', 'web', 'api', 'db']);
assert.deepEqual([...result.edges].sort(), ['api>db', 'customer>web', 'web>api']);
// Direction is tried first; against the arrows the search still finds a way.
assert.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']);
assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5);
const none = runQuery(graph, 'from:customer to:nowhere');
assert.equal(none.nodes.size, 0);
assert.match(none.message ?? '', /No element "nowhere"/);
const missing = runQuery(graph, 'from:customer');
assert.match(missing.message ?? '', /both from: and to:/);
});
test('connections are shown only when both ends match', () => {
const graph = graphOfModel();
const result = runQuery(graph, 'type:backend');
assert.ok(result.nodes.has('api'));
assert.ok(!result.edges.has('api>db'));
assert.ok(result.edges.has('orders>queue') === false);
assert.ok([...result.edges].every((id) => id.split('>').every((end) => result.nodes.has(end))));
});
+16
View File
@@ -317,6 +317,22 @@ export async function run(): Promise<void> {
assert.ok(tab); assert.ok(tab);
}); });
await check('Archify | Open Model Graph switches the main.archify editor to the Graph page and back', async () => {
const main = workspaceFile('model/main.archify');
const diagram = workspaceFile('model/0100-context/online-shop.archify');
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
assert.equal(commands.find((item) => item.command === 'archify.openModelGraph')?.title, 'Archify | Open Model Graph');
await vscode.commands.executeCommand('archify.openModelGraph', diagram);
const tab = await waitFor('model editor active', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
);
assert.ok(tab);
// The choice is remembered per file in the workspace state until the page is switched back.
const ext = vscode.extensions.getExtension(EXTENSION_ID);
assert.ok(ext?.isActive);
await new Promise((resolve) => setTimeout(resolve, 500));
});
await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => { await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => {
const registry = workspaceFile('model/registry/people.archify-registry'); const registry = workspaceFile('model/registry/people.archify-registry');
await vscode.commands.executeCommand('vscode.open', registry); await vscode.commands.executeCommand('vscode.open', registry);