0.9.0: model graph with queries #27
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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 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.
|
||||
- **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.
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
- **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`.
|
||||
- **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).
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
|
||||
@@ -49,6 +49,15 @@ if (tests) {
|
||||
entryPoints: ['src/viewer3d/index.ts'],
|
||||
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) {
|
||||
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||
|
||||
Generated
+57
@@ -9,10 +9,14 @@
|
||||
"version": "0.8.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cytoscape": "^3.34.4",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"three": "^0.186.1",
|
||||
"yaml": "^2.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/cytoscape-fcose": "^2.2.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/three": "^0.186.0",
|
||||
"@types/vscode": "~1.100.0",
|
||||
@@ -1015,6 +1019,23 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "22.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||
@@ -1755,6 +1776,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -1785,6 +1815,27 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2935,6 +2986,12 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
||||
+24
-1
@@ -2,7 +2,7 @@
|
||||
"name": "archify-vscode-ext",
|
||||
"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.",
|
||||
"version": "0.8.1",
|
||||
"version": "0.9.0",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
@@ -108,6 +108,11 @@
|
||||
"command": "archify.openModel",
|
||||
"title": "Archify | Open Model",
|
||||
"icon": "$(symbol-structure)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"title": "Archify | Open Model Graph",
|
||||
"icon": "$(type-hierarchy)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -143,6 +148,10 @@
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"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": [
|
||||
@@ -180,6 +189,11 @@
|
||||
"command": "archify.openModel",
|
||||
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
|
||||
"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": [
|
||||
@@ -222,6 +236,11 @@
|
||||
"command": "archify.openModel",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@8"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
@@ -453,6 +472,8 @@
|
||||
"test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/cytoscape-fcose": "^2.2.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/three": "^0.186.0",
|
||||
"@types/vscode": "~1.100.0",
|
||||
@@ -468,6 +489,8 @@
|
||||
},
|
||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||
"dependencies": {
|
||||
"cytoscape": "^3.34.4",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"three": "^0.186.1",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
// 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.
|
||||
vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => {
|
||||
const folder = await parentFolder(arg);
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -309,6 +309,23 @@ export function groupOf(relativePath: string): string {
|
||||
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 {
|
||||
relativePath: string;
|
||||
group: string;
|
||||
@@ -317,6 +334,10 @@ export interface DiagramSummary {
|
||||
used: string[];
|
||||
/** Decisions the diagram points at. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -326,14 +347,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
|
||||
try {
|
||||
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||
} 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 meta = isObj(obj.meta) ? obj.meta : {};
|
||||
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 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. */
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -2,6 +2,7 @@ import { layerMarkup, rasterize } from './raster';
|
||||
import { CameraState, Layers, Stage } from './stage';
|
||||
import { DiagramModel, readModel } from './svgModel';
|
||||
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
|
||||
@@ -12,12 +13,7 @@ import { STYLE } from './style';
|
||||
* follows Archify's animations as they play.
|
||||
*/
|
||||
|
||||
interface Host {
|
||||
defaultView: '2d' | '3d';
|
||||
post(message: unknown): void;
|
||||
getState(): Record<string, unknown>;
|
||||
setState(patch: Record<string, unknown>): void;
|
||||
}
|
||||
type Host = ArchifyHost;
|
||||
|
||||
interface SavedView {
|
||||
active?: boolean;
|
||||
@@ -25,12 +21,6 @@ interface SavedView {
|
||||
camera?: CameraState;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__archifyHost?: Host;
|
||||
}
|
||||
}
|
||||
|
||||
const REDRAW_MS = 220;
|
||||
const MARGIN = 80;
|
||||
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
|
||||
|
||||
+108
-10
@@ -2,7 +2,8 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { renderDiagram } from '../archify/cli';
|
||||
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 { prepareDiagram } from '../model/prepare';
|
||||
import { ModelService } from '../model/service';
|
||||
@@ -11,8 +12,8 @@ import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { graphScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
|
||||
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 {
|
||||
uri: 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 {
|
||||
private seq = 0;
|
||||
private hash: string | undefined;
|
||||
@@ -34,10 +45,16 @@ class DiagramPreview implements vscode.Disposable {
|
||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||
/** Root of the model this file belongs to, once known. */
|
||||
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(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
private readonly host: PreviewHost,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
state?: PreviewState,
|
||||
@@ -45,6 +62,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
readonly isEditor = false,
|
||||
) {
|
||||
this.hash = state?.hash;
|
||||
this.graph = state?.graph ?? this.graphModes()[uri.toString()] === true;
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||
if (!isEditor) {
|
||||
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);
|
||||
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);
|
||||
}),
|
||||
);
|
||||
@@ -89,8 +116,51 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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 {
|
||||
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 {
|
||||
@@ -121,6 +191,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (fingerprint === this.lastHtml) return;
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = false;
|
||||
this.graphShown = false;
|
||||
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) {
|
||||
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
|
||||
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) {
|
||||
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 (!main) this.root = prepared?.root;
|
||||
this.graphShown = false;
|
||||
if (!prepared) {
|
||||
if (model) {
|
||||
this.showModelPage(model);
|
||||
@@ -184,7 +262,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (!result.ok || !result.html) {
|
||||
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
|
||||
if (this.rendered && this.lastHtml) {
|
||||
void this.panel.webview.postMessage({
|
||||
this.post({
|
||||
type: 'notice',
|
||||
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),
|
||||
decisions: prepared.decisions,
|
||||
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.
|
||||
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) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
this.post({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
}
|
||||
this.lastHtml = fingerprint;
|
||||
@@ -286,10 +365,29 @@ export class DiagramViewManager
|
||||
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. */
|
||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
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 {
|
||||
@@ -304,7 +402,7 @@ export class DiagramViewManager
|
||||
{ viewColumn, preserveFocus },
|
||||
{ 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> {
|
||||
@@ -312,7 +410,7 @@ export class DiagramViewManager
|
||||
panel.dispose();
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export type WebviewMessage =
|
||||
| { type: 'openDiagram'; path: string }
|
||||
| { type: 'openRegistry'; file: string; line?: number }
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -96,6 +98,8 @@ export async function handleWebviewMessage(
|
||||
break;
|
||||
case 'hash':
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
|
||||
+72
-3
@@ -26,8 +26,13 @@ export interface PageOptions {
|
||||
decision?: DecisionPanel;
|
||||
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
|
||||
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 { DiagramDecision } from '../model/prepare';
|
||||
|
||||
@@ -87,6 +92,8 @@ function bridgeScript(options: PageOptions): string {
|
||||
decision: options.decision,
|
||||
decisions: options.decisions || [],
|
||||
descriptions: options.descriptions || {},
|
||||
graphSwitch: options.graphSwitch,
|
||||
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
|
||||
};
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
@@ -110,7 +117,8 @@ function bridgeScript(options: PageOptions): string {
|
||||
defaultView: config.defaultView || '2d',
|
||||
post: post,
|
||||
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) {
|
||||
@@ -668,6 +676,32 @@ function bridgeScript(options: PageOptions): string {
|
||||
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) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
@@ -682,6 +716,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
onReady(renderConnectors);
|
||||
onReady(renderDecisionsRail);
|
||||
onReady(describeElements);
|
||||
onReady(renderModeSwitch);
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
@@ -778,6 +813,19 @@ const MODEL_STYLE = `
|
||||
.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. */
|
||||
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); }
|
||||
@@ -820,7 +868,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
const head =
|
||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\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
|
||||
// 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. */
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -859,6 +907,27 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${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>
|
||||
</head>
|
||||
<body></body>
|
||||
|
||||
@@ -13,3 +13,8 @@ export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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))));
|
||||
});
|
||||
@@ -317,6 +317,22 @@ export async function run(): Promise<void> {
|
||||
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 () => {
|
||||
const registry = workspaceFile('model/registry/people.archify-registry');
|
||||
await vscode.commands.executeCommand('vscode.open', registry);
|
||||
|
||||
Reference in New Issue
Block a user