diff --git a/CHANGELOG.md b/CHANGELOG.md index 3918627..1a65647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`, `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:`. 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. diff --git a/README.md b/README.md index dcd11de..7f9bf68 100644 --- a/README.md +++ b/README.md @@ -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 0) 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 (/ 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:` (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). diff --git a/docs/model.md b/docs/model.md index af117d9..7fc2dd3 100644 --- a/docs/model.md +++ b/docs/model.md @@ -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. + diff --git a/esbuild.mjs b/esbuild.mjs index ba1b66e..fc5d2ab 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -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())); diff --git a/package-lock.json b/package-lock.json index 22af545..3b594c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 701ca03..b92146b 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/commands/modelCommands.ts b/src/commands/modelCommands.ts index 87bbbf0..5450468 100644 --- a/src/commands/modelCommands.ts +++ b/src/commands/modelCommands.ts @@ -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); diff --git a/src/graph/index.ts b/src/graph/index.ts new file mode 100644 index 0000000..8b4346a --- /dev/null +++ b/src/graph/index.ts @@ -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 = { 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: · 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:', '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(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(); + private readonly edgesById = new Map(); + private saved: SavedView; + private queryTimer: number | undefined; + private result: QueryResult | undefined; + private readonly hiddenTypes: Set; + + 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(); + 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('') ? example.replace('', 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(); + 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(); diff --git a/src/graph/style.ts b/src/graph/style.ts new file mode 100644 index 0000000..63359e1 --- /dev/null +++ b/src/graph/style.ts @@ -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; } } +`; diff --git a/src/model/format.ts b/src/model/format.ts index fae57fe..0941f0e 100644 --- a/src/model/format.ts +++ b/src/model/format.ts @@ -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(); + 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. */ diff --git a/src/model/graph.ts b/src/model/graph.ts new file mode 100644 index 0000000..03a4c4d --- /dev/null +++ b/src/model/graph.ts @@ -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; +} + +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(); + const edges = new Map(); + + // 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 })), + }; +} diff --git a/src/model/graphQuery.ts b/src/model/graphQuery.ts new file mode 100644 index 0000000..3c4bbfc --- /dev/null +++ b/src/model/graphQuery.ts @@ -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: 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; + /** Ids of the connections shown: both ends match, or the path found. */ + edges: Set; + /** 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> { + const map = new Map>(); + 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 { + 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(); + 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(); + 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(); + 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 | undefined; + let path: string[] | undefined; + + const matching = (term: QueryTerm): Set | 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(); + 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 }; +} diff --git a/src/viewer3d/index.ts b/src/viewer3d/index.ts index e1eb782..acaa640 100644 --- a/src/viewer3d/index.ts +++ b/src/viewer3d/index.ts @@ -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; - setState(patch: Record): 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. */ diff --git a/src/views/diagramView.ts b/src/views/diagramView.ts index 154d449..f6c23a8 100644 --- a/src/views/diagramView.ts +++ b/src/views/diagramView.ts @@ -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; +} + +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 { + return this.context.workspaceState.get>(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>): 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 { 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 { + 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 { @@ -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 { diff --git a/src/webview/host.ts b/src/webview/host.ts new file mode 100644 index 0000000..be961ce --- /dev/null +++ b/src/webview/host.ts @@ -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; + setState(patch: Record): void; + /** The Graph page's data, present on that page only. */ + graph?: { graph: ModelGraph; query?: string }; +} + +declare global { + interface Window { + __archifyHost?: ArchifyHost; + } +} diff --git a/src/webview/messages.ts b/src/webview/messages.ts index 5e4bc7f..e2a091d 100644 --- a/src/webview/messages.ts +++ b/src/webview/messages.ts @@ -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; } diff --git a/src/webview/page.ts b/src/webview/page.ts index b026337..7909374 100644 --- a/src/webview/page.ts +++ b/src/webview/page.ts @@ -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 = `\n` + `\n` + - `\n`; + `\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, 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 ` @@ -859,6 +907,27 @@ export function modelPage(options: Pick body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); } ${MODEL_STYLE} + ${SWITCH_STYLE} + + + +`; +} + +/** The Graph page of a model: the bundled script builds everything from the host's graph data. */ +export function graphPage(options: Pick, graph: NonNullable): string { + const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph }); + return ` + + + + + + + + diff --git a/src/webview/viewer3d.ts b/src/webview/viewer3d.ts index 201de7b..885d0cc 100644 --- a/src/webview/viewer3d.ts +++ b/src/webview/viewer3d.ts @@ -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(); +} diff --git a/test/graph.test.ts b/test/graph.test.ts new file mode 100644 index 0000000..20f0c71 --- /dev/null +++ b/test/graph.test.ts @@ -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(); + 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)))); +}); diff --git a/test/integration/suite.ts b/test/integration/suite.ts index d6565fb..59bc390 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -317,6 +317,22 @@ export async function run(): Promise { 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);