Compare commits
19
Commits
feat/decisions
...
v0.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5a9f92e75 | ||
|
|
268c75cfae | ||
|
|
27d4794ff8 | ||
|
|
5bcd34b839 | ||
|
|
69b87833b7 | ||
|
|
058c0d17a8 | ||
|
|
8398c44ca8 | ||
|
|
60ad7479fd | ||
|
|
bc9f55e2fa | ||
|
|
27d1b4381c | ||
|
|
82dd10253b | ||
|
|
91fcde9fa5 | ||
|
|
17e07adb33 | ||
|
|
3ed2d7eed8 | ||
|
|
99ea076571 | ||
|
|
c8645b228b | ||
|
|
265f8c57f6 | ||
|
|
c66e819abe | ||
|
|
2ba66c1ee2 |
No files matched your search
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.2 — 2026-10-27
|
||||
|
||||
- **Source view.** Every Archify page can show its file's YAML in place: **Diagram | Graph | Source** on diagrams (the old **Diagram | Graph** switch, now on every diagram; Graph only inside a model), **Model | Graph | Source** on `main.archify`, **View | Source** on registry and decision pages, and Source on the Graph page too. The YAML opens in an editor inside the page, under its toolbar and heading, in the page's own colours: line numbers, folding, brackets, multiple cursors, undo and redo, ⌘S/Ctrl+S saves. Problems are underlined as you type, listed under the editor (click the count to go to the next one) and counted on the **Source** segment, also while the picture shows. Completion and hover come from the schema of the file, with nothing else installed. Edits go to the VS Code document, so the tab's dirty dot, save, Git and the text editor stay in step. **Diagram** (or **Model**, **View**) brings the picture back as it was left; anything rendered meanwhile shows then.
|
||||
- **2D | 3D** now sits left of the view switch (it says how the picture is drawn, the switch what is shown) and hides while Source shows; the 3D stage stops drawing meanwhile.
|
||||
- **Schema checks without the YAML extension.** `.archify` files are checked against the extension's schema on the YAML as written, at once and with readable messages (`Unknown key "lable" in connections[4].`) on the key or value concerned; `archify validate` runs once the schema is satisfied. `.archify-registry` files are checked against the registry schema for the first time (types, unknown keys, ids). The decision schema is no longer handed to the YAML extension: decision files use the `decision` key twice by design, which it reported as an error in every file.
|
||||
- **Commands.** *Archify | Show Source* switches the focused page to its Source view. The old behaviour, the YAML in VS Code's text editor beside the page, is *Archify | Open in Text Editor*, which is now the editor-title button.
|
||||
- Registry and decision pages are text editors of their files (as diagram pages already were), so edits made there make the tab dirty and save with it.
|
||||
- Fixed: a YAML error in a registry or decision file was reported on its first line instead of where it is.
|
||||
- Fixed: an unhandled "Webview is disposed" error when a diagram closed while it was rendering. On `main.archify` pages with a diagram, the model heading no longer runs under the toolbar in narrow editors.
|
||||
|
||||
## 0.9.1 — 2026-10-23
|
||||
|
||||
- No functional change. Republished so that Open VSX carries the release: the 0.9.0 upload there was held under review while the `root-at-skic` namespace was still unverified, and a held upload is not released by the later ownership grant.
|
||||
|
||||
## 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. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Layered layout by type (clients left, data right, registries above, decisions below, orthogonal connections) or a force layout; hover lights up an element and its connections; the dotted decision and registry links are shown by default; **Links** switches them to the selected element only. Drag, zoom, **Fit**, **Labels**, a dark/light theme toggle of its own (remembered, and shared with the diagram pages' theme key), legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
|
||||
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; 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. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**.
|
||||
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
|
||||
- Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from.
|
||||
|
||||
## 0.8.1 — 2026-10-20
|
||||
|
||||
- **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it.
|
||||
- The Archify entries in the Explorer menu and *File → New File…* come in the order New Model, New Registry, New Diagram, New Decision.
|
||||
|
||||
## 0.8.0 — 2026-10-19
|
||||
|
||||
- **Architecture decisions.** A model can hold decisions: one `*.archify-decision` file each, anywhere under the model root, with an id (unique in the model), title, status, date, the registry `elements` and `diagrams` it is about, `supersedes`, and prose for context, decision and consequences.
|
||||
|
||||
@@ -12,7 +12,7 @@ Free and open source. If it saves you time, you can [buy me a coffee](https://bu
|
||||
|
||||
| File | Format | What the extension does |
|
||||
|---|---|---|
|
||||
| **`name.archify`** | **YAML** (the main format) | Opens as the interactive diagram. Show Source, live updates, validation, completion, templates. |
|
||||
| **`name.archify`** | **YAML** (the main format) | Opens as the interactive diagram, with a Source view for its YAML. Live updates, validation, completion, templates. |
|
||||
| `name.architecture.json`, `name.workflow.json`, `name.sequence.json`, `name.dataflow.json`, `name.lifecycle.json` | JSON (Archify's own CLI format) | Opens as text, with a preview beside it and validation. For files produced by the Archify CLI or AI agents. |
|
||||
| Rendered Archify `.html` | HTML | Read-only viewer instead of a browser. |
|
||||
|
||||
@@ -44,7 +44,7 @@ To write one by hand:
|
||||
```
|
||||
|
||||
2. Click it in the Explorer: the diagram opens.
|
||||
3. To edit, use **Show Source** in the editor title. The YAML opens beside the diagram, which re-renders as you type.
|
||||
3. To edit, click **Source** in the diagram's toolbar (**Diagram | Graph | Source**). The YAML takes the picture's place, checked as you type; **Diagram** brings the picture back. To see the text and the picture side by side, use **Open in Text Editor** in the editor title instead: the diagram re-renders as you type.
|
||||
|
||||
The same file is in [examples/hello.archify](examples/hello.archify).
|
||||
|
||||
@@ -53,7 +53,7 @@ The same file is in [examples/hello.archify](examples/hello.archify).
|
||||
A `.archify` file is an Archify diagram written in **YAML**. Its fields are those of Archify's diagram schema; the extension converts the YAML for the renderer, so you never handle JSON yourself. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`.
|
||||
|
||||
- **Opening:** the **Archify Diagram** editor is the default for `.archify`. Use *Reopen Editor With… → Text Editor* to edit the YAML full-screen.
|
||||
- **Completion:** with the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`) installed, `.archify` files get completion and hover from Archify's schemas. The schema follows `diagram_type`.
|
||||
- **Completion:** in the Source view, `.archify` and `.archify-registry` files get completion and hover from Archify's schemas, with nothing else installed; the schema follows `diagram_type`. In VS Code's text editor the same comes from the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`), if installed.
|
||||
|
||||
`.archify` files from extension version 0.2.0 were written in JSON. They still open, because JSON is valid YAML, but new files should use YAML.
|
||||
|
||||
@@ -65,19 +65,20 @@ 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).
|
||||
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
|
||||
- **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. **Source** shows the YAML in its place, in an editor inside the page: problems underlined as you type and counted on the switch, completion and hover from the schema, undo, and ⌘S/Ctrl+S to save. **Open in Text Editor** opens the YAML beside the diagram instead, 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.
|
||||
- **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the `.archify` file.
|
||||
- **Problems**: YAML syntax errors, schema problems (unknown keys, wrong types, missing fields, checked with the extension's own schema check on the YAML as written) and `archify validate`'s layout and evidence problems appear at the matching place in the `.archify` file. Registries are checked against their schema too.
|
||||
- **Brand logos**: after `brand:` the editor suggests Archify's built-in brand IDs (PostgreSQL, Redis, Kafka, Docker, …). Typing an alias or product name finds them too, so `kafka` offers `apache-kafka`. An unknown brand is reported on its own line.
|
||||
- **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files.
|
||||
- **Schema help**: completion and hover from Archify's own schemas: in the Source view built in, in the text editor through the YAML extension.
|
||||
- **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 Diagram…*, *Archify | New Model…*, *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* (switches the page to its Source view), *Archify | Open in Text Editor*.
|
||||
|
||||
## 3D view
|
||||
|
||||
Every diagram can also be seen in 3D. The **2D | 3D** switch next to **Export** in the diagram's toolbar shows which view you are in; click it to flip.
|
||||
Every diagram can also be seen in 3D. The **2D | 3D** switch in the diagram's toolbar, left of **Diagram | Graph | Source**, shows which view you are in; click it to flip. It hides while Source shows, and **Diagram** returns to the 2D or 3D you left.
|
||||
|
||||

|
||||
|
||||
@@ -123,10 +124,13 @@ online-shop/
|
||||
- **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
|
||||
- **Descriptions.** Under a model diagram, hovering a row of the **Elements** panel shows the registry description of that element.
|
||||
- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: [<id>]` on the diagram, a component or a connection. They show as a **Decisions** panel on the `main.archify` page, a **Decisions** rail panel under each diagram (hover lights up what the decision is attached to), on element cards and in the element dialog, and a decision opens as its own page. *Archify | New Decision…* creates one; links are checked in Problems.
|
||||
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
|
||||
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **View | Source** at the top right switches to the YAML, checked against the registry schema as you type.
|
||||
- **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 | Source** 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. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. The layout is **Layered** by default, like Archify's stack: clients on the left, data on the right, registries in a row above, decisions in a row below, connections drawn orthogonally; click the button for a **Force** layout instead. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Labels** toggles connection labels, the dotted **Links** from decisions and registries to their elements are on by default and can be switched off, in which case only the selected element's show (hovering an element lights up its connections), the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph | Source** and Graph opens the graph already narrowed to that diagram; **Source** on the graph page goes back to `main.archify` in its Source view. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
|
||||
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. 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. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
|
||||
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
|
||||
|
||||
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
||||
|
||||
|
||||
@@ -127,3 +127,20 @@ 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.
|
||||
- **Decision nodes** (`decision:<id>`, type `decision`) carry the effective status, the context and decision text, and the diagrams the decision names; `decides` edges point at the elements it is about, `supersedes` edges at the decisions it replaces.
|
||||
- **Registry nodes** (`registry:<file>`, type `registry`) stand for each `.archify-registry` file; `defines` edges point at its elements.
|
||||
|
||||
Walks (`uses:`, `near:`, `from: to:`) follow `connection` edges only. Decision and registry nodes join a result when an element they link to matches, and `-type:decision` or `-type:registry` removes them.
|
||||
|
||||
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.
|
||||
|
||||
+30
@@ -18,6 +18,17 @@ const common = {
|
||||
mainFields: ['module', 'main'],
|
||||
};
|
||||
|
||||
// codemirror-json-schema formats schema descriptions with markdown-it and
|
||||
// shiki (about 400 KB); a small stub does what our descriptions need.
|
||||
const stubMarkdown = {
|
||||
name: 'stub-markdown',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /utils\/markdown(\.js)?$/ }, (args) =>
|
||||
args.importer.includes('codemirror-json-schema') ? { path: path.resolve('src/webview/source/markdownStub.ts') } : undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
if (tests) {
|
||||
// Unit tests cover the modules that do not import 'vscode'; the
|
||||
// integration suite runs inside VS Code, which provides it.
|
||||
@@ -49,6 +60,25 @@ 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',
|
||||
}),
|
||||
// The Source view; CodeMirror is bundled in. Loaded the first time Source is chosen.
|
||||
esbuild.context({
|
||||
...common,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
entryPoints: ['src/webview/source/index.ts'],
|
||||
outfile: 'dist/source.js',
|
||||
plugins: [stubMarkdown],
|
||||
}),
|
||||
]);
|
||||
if (watch) {
|
||||
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||
|
||||
Generated
+1034
-22
File diff suppressed because it is too large.
Load diff
+80
-28
@@ -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.0",
|
||||
"version": "0.9.2",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
@@ -49,16 +49,21 @@
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "Archify | New Diagram…",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"title": "Archify | New Model…",
|
||||
"icon": "$(new-folder)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newRegistry",
|
||||
"title": "Archify | New Registry…",
|
||||
"icon": "$(symbol-field)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "Archify | New Diagram…",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"title": "Archify | New Decision…",
|
||||
@@ -97,12 +102,22 @@
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"title": "Archify | Show Source",
|
||||
"icon": "$(code)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInTextEditor",
|
||||
"title": "Archify | Open in Text Editor",
|
||||
"icon": "$(go-to-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"title": "Archify | Open Model",
|
||||
"icon": "$(symbol-structure)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"title": "Archify | Open Model Graph",
|
||||
"icon": "$(type-hierarchy)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -135,9 +150,17 @@
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInTextEditor",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
@@ -157,7 +180,7 @@
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"command": "archify.openInTextEditor",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'",
|
||||
"group": "navigation"
|
||||
},
|
||||
@@ -175,43 +198,58 @@
|
||||
"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": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"command": "archify.newModel",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"command": "archify.newRegistry",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
"group": "zzz_archify@4"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@4"
|
||||
"group": "zzz_archify@5"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html",
|
||||
"group": "zzz_archify@3"
|
||||
"group": "zzz_archify@6"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@7"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@5"
|
||||
"group": "zzz_archify@8"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
@@ -223,16 +261,20 @@
|
||||
],
|
||||
"file/newFile": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file"
|
||||
"command": "archify.newModel",
|
||||
"group": "file@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"group": "file"
|
||||
"command": "archify.newRegistry",
|
||||
"group": "file@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"group": "file"
|
||||
"group": "file@4"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -421,10 +463,6 @@
|
||||
{
|
||||
"fileMatch": "*.archify-registry",
|
||||
"url": "./schemas/archify-registry.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.archify-decision",
|
||||
"url": "./schemas/archify-decision.schema.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -439,12 +477,13 @@
|
||||
"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",
|
||||
"@vscode/test-electron": "^3.1.0",
|
||||
"@vscode/vsce": "^3.0.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"typescript": "^5.8.0"
|
||||
@@ -454,6 +493,19 @@
|
||||
},
|
||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.4",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"ajv": "^8.20.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-json-schema": "^0.8.1",
|
||||
"cytoscape": "^3.34.4",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"three": "^0.186.1",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as vscode from 'vscode';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
|
||||
import { diagramName, invalidNameReason } from '../language/templates';
|
||||
import { DECISION_EXT, slugify } from '../model/decisions';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import { MAIN_FILE, REGISTRY_EXT } from '../model/format';
|
||||
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
|
||||
import { DECISION_VIEW_TYPE } from '../views/decisionView';
|
||||
import { readText } from '../util/files';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
@@ -32,6 +33,36 @@ 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);
|
||||
if (!folder) return;
|
||||
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Registry',
|
||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}${root ? '' : ' (not inside a model: add a main.archify above it so diagrams can refer to its elements)'}`,
|
||||
value: 'elements',
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(folder, `${diagramName(value).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`))) ? `${diagramName(value)}${REGISTRY_EXT} already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
const target = vscode.Uri.joinPath(folder, `${diagramName(name).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`);
|
||||
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'registry.archify-registry'));
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(template, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, REGISTRY_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
// Creates a decision file in the clicked folder of a model and opens it.
|
||||
vscode.commands.registerCommand('archify.newDecision', async (arg?: unknown) => {
|
||||
const folder = await parentFolder(arg);
|
||||
|
||||
@@ -39,7 +39,17 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
|
||||
vscode.commands.registerCommand('archify.refreshPreview', () => ctx.views.activePreview?.update(true)),
|
||||
|
||||
// On a page that has a Source view, show it; elsewhere open the text.
|
||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||
const session = ctx.views.activePreview?.source ?? ctx.registry.activeView?.source ?? ctx.decision.activeView?.source;
|
||||
if (session) {
|
||||
session.reveal();
|
||||
return;
|
||||
}
|
||||
await vscode.commands.executeCommand('archify.openInTextEditor');
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openInTextEditor', async () => {
|
||||
const preview = ctx.views.activePreview;
|
||||
if (preview?.isEditor) {
|
||||
// The diagram is the editor; open the YAML text beside it.
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
extension: context,
|
||||
views: new DiagramViewManager(context, models),
|
||||
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||
registry: new RegistryViewProvider(models),
|
||||
decision: new DecisionViewProvider(models),
|
||||
registry: new RegistryViewProvider(context, models),
|
||||
decision: new DecisionViewProvider(context, models),
|
||||
models,
|
||||
};
|
||||
const refs = new RefLanguage(models);
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
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 { layeredLayout } from './layout';
|
||||
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[];
|
||||
/** A theme chosen on the page; absent means follow VS Code. */
|
||||
theme?: 'dark' | 'light';
|
||||
/** Layered by type (default) or force-directed. */
|
||||
layout?: 'layered' | 'force';
|
||||
/** Show every decision and registry link, not only the selected element's. */
|
||||
links?: boolean;
|
||||
}
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
/** The theme in effect: the page's own choice, else VS Code's. */
|
||||
function currentTheme(): Theme {
|
||||
const chosen = document.documentElement.dataset.theme;
|
||||
if (chosen === 'dark' || chosen === 'light') return chosen;
|
||||
return document.body.classList.contains('vscode-light') ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8', decision: '#e879f9', registry: '#cbd5e1' };
|
||||
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry'];
|
||||
const STATUS_COLORS: Record<string, string> = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' };
|
||||
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8';
|
||||
/** An opaque mix of two `#rrggbb` colours (`amount` of the first), so nodes hide the connections behind them. */
|
||||
function mix(front: string, back: string, amount: number): string {
|
||||
const f = parseInt(front.slice(1), 16);
|
||||
const b = parseInt(back.replace(/^#/, '').padEnd(6, '0').slice(0, 6), 16);
|
||||
const channel = (shift: number) => Math.round(((f >> shift) & 255) * amount + ((b >> shift) & 255) * (1 - amount));
|
||||
return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
|
||||
}
|
||||
/** `#rrggbb` form of a CSS colour, read through the canvas so `rgb()` and names work too. */
|
||||
function toHex(color: string): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
|
||||
const canvas = document.createElement('canvas').getContext('2d');
|
||||
if (!canvas) return '#000000';
|
||||
canvas.fillStyle = color;
|
||||
return /^#[0-9a-f]{6}$/i.test(canvas.fillStyle) ? canvas.fillStyle : '#000000';
|
||||
}
|
||||
const nodeColor = (n: GraphNode) => (n.kind === 'decision' ? STATUS_COLORS[n.status ?? ''] ?? TYPE_COLORS.decision : colorOf(n.type));
|
||||
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
|
||||
const LABELS_UP_TO = 40;
|
||||
const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision:<id> · unused · -shared';
|
||||
|
||||
const HELP: [string, string][] = [
|
||||
['database', 'elements whose id, label, sublabel, brand or type contain the word'],
|
||||
['type:backend', 'elements of that type'],
|
||||
['diagram:0200-*', 'what the diagrams matching the path draw (glob)'],
|
||||
['decision:<id>', 'what a decision is about or is attached to'],
|
||||
['uses:api', 'an element and its neighbours'],
|
||||
['near:api,2', 'an element and everything within two steps'],
|
||||
['from:web to:db', 'the shortest path between two elements'],
|
||||
['unused', 'registry elements no diagram draws'],
|
||||
['shared', 'registry elements only; -shared: inline elements only'],
|
||||
['-worker', 'a leading minus excludes; terms combine with AND'],
|
||||
];
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
/** Theme colours Cytoscape needs as literals: the page's tokens, read again whenever the theme changes. */
|
||||
function palette() {
|
||||
const dark = currentTheme() === 'dark';
|
||||
return {
|
||||
bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'),
|
||||
fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'),
|
||||
muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'),
|
||||
panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'),
|
||||
accent: cssVar('--ag-accent', '#22d3ee'),
|
||||
edge: dark ? '#64748b' : '#94a3b8',
|
||||
font: cssVar('--ag-font', 'system-ui, sans-serif'),
|
||||
};
|
||||
}
|
||||
|
||||
class GraphPage {
|
||||
cy!: Core;
|
||||
private graph: ModelGraph;
|
||||
private readonly nodesById = new Map<string, GraphNode>();
|
||||
private readonly edgesById = new Map<string, GraphEdge>();
|
||||
private saved: SavedView;
|
||||
private queryTimer: number | undefined;
|
||||
private result: QueryResult | undefined;
|
||||
private readonly hiddenTypes: Set<string>;
|
||||
|
||||
private readonly root = el('div', 'ag');
|
||||
private readonly nameEl = el('div', 'ag-name');
|
||||
private readonly kickerEl = el('div', 'ag-kicker');
|
||||
private readonly queryInput = el('input', 'ag-query');
|
||||
private readonly countEl = el('span', 'ag-count');
|
||||
private readonly labelsButton = el('button', 'ag-button', 'Labels');
|
||||
private readonly linksButton = el('button', 'ag-button', 'Links');
|
||||
private readonly layoutButton = el('button', 'ag-button', 'Layered');
|
||||
private readonly themeButton = el('button', 'ag-button ag-theme');
|
||||
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;
|
||||
if (this.saved.links === undefined) this.saved.links = true;
|
||||
this.hiddenTypes = new Set(this.saved.hidden ?? []);
|
||||
this.applyTheme(this.saved.theme ?? archifyThemePreference());
|
||||
this.index();
|
||||
this.buildChrome();
|
||||
this.buildGraph(true);
|
||||
this.syncLabels();
|
||||
this.renderLegend();
|
||||
this.syncLayoutButton();
|
||||
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 shorten = (text: string, max: number) => {
|
||||
if (text.length <= max) return text;
|
||||
const cut = text.slice(0, max - 1);
|
||||
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), max - 16))}\u2026`;
|
||||
};
|
||||
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.kind === 'decision' ? shorten(n.label, 60) : n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } }));
|
||||
// In the layered layout a label sits at the end of the connection where fewer connections meet, so a
|
||||
// fan-out labels at its targets and a fan-in at its sources; what still shares an end is staggered.
|
||||
const outCount = new Map<string, number>();
|
||||
const inCount = new Map<string, number>();
|
||||
for (const e of this.graph.edges) {
|
||||
if (e.kind !== 'connection') continue;
|
||||
outCount.set(e.from, (outCount.get(e.from) ?? 0) + 1);
|
||||
inCount.set(e.to, (inCount.get(e.to) ?? 0) + 1);
|
||||
}
|
||||
const used = new Map<string, number>();
|
||||
const edges = this.graph.edges.map((e) => {
|
||||
const at = e.kind === 'connection' && (outCount.get(e.from) ?? 0) > (inCount.get(e.to) ?? 0) ? 'target' : 'source';
|
||||
const key = `${at}:${at === 'source' ? e.from : e.to}`;
|
||||
const index = used.get(key) ?? 0;
|
||||
used.set(key, index + 1);
|
||||
return { group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / '), at, offset: 40 + (index % 3) * 34 } };
|
||||
});
|
||||
return [...nodes, ...edges];
|
||||
}
|
||||
|
||||
/** A new graph from the extension: keep the positions of the elements that are still there. */
|
||||
replace(graph: ModelGraph): void {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
this.cy.nodes().forEach((n) => {
|
||||
positions.set(n.id(), { ...n.position() });
|
||||
});
|
||||
const selected = this.cy.$(':selected').map((e) => e.id());
|
||||
this.graph = graph;
|
||||
this.index();
|
||||
this.cy.elements().remove();
|
||||
this.cy.add(this.elements());
|
||||
let fresh = 0;
|
||||
this.cy.nodes().forEach((n) => {
|
||||
const at = positions.get(n.id());
|
||||
if (at) n.position(at);
|
||||
else fresh += 1;
|
||||
});
|
||||
if (fresh || this.saved.layout !== 'force') this.layout(false);
|
||||
this.syncLinks();
|
||||
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');
|
||||
// Model | Graph | Source, as on the model page. Source goes back to the
|
||||
// model page and opens it in Source (the page reads sourceView on load).
|
||||
const sw = el('div', 'ag-switch');
|
||||
sw.setAttribute('role', 'group');
|
||||
sw.setAttribute('aria-label', 'View');
|
||||
const segment = (label: string, title: string, on: boolean, action?: () => void) => {
|
||||
const button = el('button', `ag-seg${on ? ' on' : ''}`, label);
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-pressed', String(on));
|
||||
if (action) button.addEventListener('click', action);
|
||||
return button;
|
||||
};
|
||||
sw.append(
|
||||
segment('Model', 'Back to the model page', false, () => this.host.post({ type: 'showModel' })),
|
||||
segment('Graph', 'The model as one graph', true),
|
||||
segment('Source', 'Edit the YAML of main.archify, checked as you type', false, () => {
|
||||
this.host.setState({ sourceView: true });
|
||||
this.host.post({ type: 'showModel' });
|
||||
}),
|
||||
);
|
||||
const exportButton = el('button', 'ag-button ag-primary', 'Export PNG');
|
||||
exportButton.type = 'button';
|
||||
exportButton.title = 'Save the graph as it is on screen, as a PNG';
|
||||
exportButton.addEventListener('click', () => this.exportPng());
|
||||
this.themeButton.type = 'button';
|
||||
this.themeButton.setAttribute('role', 'switch');
|
||||
this.themeButton.append(el('span', 'ag-theme-track'), el('span', 'ag-theme-icon'), el('span', 'ag-sr', 'Dark theme'));
|
||||
this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark'));
|
||||
this.syncThemeButton();
|
||||
actions.append(sw, this.themeButton, 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());
|
||||
this.layoutButton.type = 'button';
|
||||
this.layoutButton.addEventListener('click', () => {
|
||||
this.saved.layout = this.saved.layout === 'force' ? 'layered' : 'force';
|
||||
this.save();
|
||||
this.layout(true);
|
||||
this.syncLayoutButton();
|
||||
});
|
||||
this.linksButton.type = 'button';
|
||||
this.linksButton.title = 'Links from decisions and registries to their elements: on for all, off for the selected element only';
|
||||
this.linksButton.addEventListener('click', () => {
|
||||
this.saved.links = !this.saved.links;
|
||||
this.save();
|
||||
this.syncLinks();
|
||||
});
|
||||
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, this.layoutButton, this.labelsButton, this.linksButton, helpButton);
|
||||
|
||||
const cyHolder = el('div', 'ag-cy');
|
||||
this.canvas.append(cyHolder, this.legend);
|
||||
this.help.hidden = true;
|
||||
this.help.appendChild(el('h3', '', 'Queries'));
|
||||
const table = el('table');
|
||||
for (const [example, meaning] of HELP) {
|
||||
const row = el('tr');
|
||||
const code = el('td', '', example);
|
||||
code.title = 'Try it';
|
||||
code.addEventListener('click', () => {
|
||||
this.setQuery(example.includes('<id>') ? example.replace('<id>', this.graph.decisions[0]?.id ?? '') : example);
|
||||
});
|
||||
row.append(code, el('td', '', meaning));
|
||||
table.appendChild(row);
|
||||
}
|
||||
this.help.appendChild(table);
|
||||
this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query. Layered puts clients on the left and data on the right, with registries before and decisions after; Links switches the dotted decision and registry links between all of them and the selected element\u2019s only.'));
|
||||
this.main.append(this.canvas, this.side, this.help);
|
||||
const head = el('div', 'ag-head');
|
||||
head.append(top, bar);
|
||||
this.root.append(head, this.main);
|
||||
document.body.appendChild(this.root);
|
||||
this.syncLabels();
|
||||
}
|
||||
|
||||
private renderLegend(): void {
|
||||
this.legend.textContent = '';
|
||||
const counts = new Map<string, number>();
|
||||
for (const n of this.graph.nodes) counts.set(n.type ?? 'other', (counts.get(n.type ?? 'other') ?? 0) + 1);
|
||||
const types = [...counts.keys()].sort((a, b) => (TYPE_ORDER.indexOf(a) + 1 || 99) - (TYPE_ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b));
|
||||
for (const type of types) {
|
||||
const chip = el('button', 'ag-chip');
|
||||
chip.type = 'button';
|
||||
chip.style.setProperty('--c', colorOf(type));
|
||||
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
|
||||
chip.title = `Show or hide ${type} elements`;
|
||||
chip.append(document.createTextNode(type), el('small', '', String(counts.get(type))));
|
||||
chip.addEventListener('click', () => {
|
||||
if (this.hiddenTypes.has(type)) this.hiddenTypes.delete(type);
|
||||
else this.hiddenTypes.add(type);
|
||||
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
|
||||
this.saved.hidden = [...this.hiddenTypes];
|
||||
this.save();
|
||||
this.applyHidden();
|
||||
this.updateCount();
|
||||
});
|
||||
this.legend.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- graph
|
||||
|
||||
private buildGraph(first: boolean): void {
|
||||
const holder = this.canvas.firstElementChild as HTMLElement;
|
||||
this.cy = cytoscape({
|
||||
container: holder,
|
||||
elements: this.elements(),
|
||||
style: this.stylesheet(),
|
||||
wheelSensitivity: 0.2,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 4,
|
||||
});
|
||||
this.applyHidden();
|
||||
this.syncLinks();
|
||||
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();
|
||||
});
|
||||
// Hover lights up an element and its connections; selection also reveals its decision and registry links.
|
||||
this.cy.on('mouseover', 'node', (event) => {
|
||||
const node = event.target as NodeSingular;
|
||||
node.addClass('hover');
|
||||
node.connectedEdges('[kind = "connection"]').addClass('edge-hover');
|
||||
});
|
||||
this.cy.on('mouseout', 'node', (event) => {
|
||||
const node = event.target as NodeSingular;
|
||||
node.removeClass('hover');
|
||||
node.connectedEdges().removeClass('edge-hover');
|
||||
});
|
||||
this.cy.on('select unselect', 'node', () => this.syncLinks());
|
||||
}
|
||||
|
||||
/** Decision and registry links: all of them when Links is on, else those of the selected nodes. */
|
||||
private syncLinks(): void {
|
||||
const all = Boolean(this.saved.links);
|
||||
this.linksButton.setAttribute('aria-pressed', String(all));
|
||||
const meta = this.cy.edges('[kind != "connection"]');
|
||||
meta.removeClass('shown');
|
||||
if (all) meta.addClass('shown');
|
||||
else this.cy.nodes(':selected').connectedEdges('[kind != "connection"]').addClass('shown');
|
||||
}
|
||||
|
||||
private syncLayoutButton(): void {
|
||||
const force = this.saved.layout === 'force';
|
||||
this.layoutButton.textContent = force ? 'Force' : 'Layered';
|
||||
this.layoutButton.title = force ? 'Force-directed layout; click for the layered one (clients left, data right)' : 'Layered by type; click for a force-directed layout';
|
||||
}
|
||||
|
||||
private stylesheet(): cytoscape.StylesheetJson {
|
||||
const p = palette();
|
||||
const dark = currentTheme() === 'dark';
|
||||
const bg = toHex(p.bg);
|
||||
const fill = (amount: number) => (n: NodeSingular) => mix(String(n.data('color')), bg, amount);
|
||||
return [
|
||||
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(240, Math.max(110, String(n.data('label')).length * 7.6 + 40)), height: 46, 'background-color': fill(dark ? 0.16 : 0.1), 'border-width': 1.5, 'border-color': 'data(color)', 'border-opacity': 0.9, 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': '210px', 'overlay-opacity': 0, 'underlay-color': 'data(color)', 'underlay-padding': 6, 'underlay-opacity': 0, 'underlay-shape': 'round-rectangle', 'transition-property': 'underlay-opacity, border-width', 'transition-duration': 120 } },
|
||||
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
|
||||
{ selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: (n: NodeSingular) => (String(n.data('label')).length > 30 ? 44 : 34), 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-wrap': 'wrap', 'text-max-width': '196px', 'line-height': 1.2, width: (n: NodeSingular) => Math.min(244, Math.max(120, String(n.data('label')).length * 6.4 + 48)) } },
|
||||
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 32, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1, 'border-color': p.muted, 'background-color': mix(dark ? '#94a3b8' : '#64748b', bg, 0.08), width: (n: NodeSingular) => Math.min(220, Math.max(110, String(n.data('label')).length * 6.8 + 36)) } },
|
||||
{ selector: 'node.hover', style: { 'underlay-opacity': dark ? 0.22 : 0.16, 'border-width': 2, 'background-color': fill(dark ? 0.28 : 0.18) } },
|
||||
{ selector: 'edge', style: { width: 1.4, 'line-color': p.edge, 'line-opacity': 0.85, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, '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': '3px', 'text-background-shape': 'round-rectangle', 'text-rotation': 'autorotate', 'transition-property': 'width, line-color', 'transition-duration': 120 } },
|
||||
{ selector: 'edge.ortho', style: { 'curve-style': 'taxi', 'taxi-direction': 'auto', 'taxi-turn': '45%', 'taxi-turn-min-distance': 24, 'text-rotation': 'none', 'source-text-offset': 'data(offset)', 'source-text-rotation': 'none', 'source-text-background-color': p.bg, 'source-text-background-opacity': 1, 'source-text-background-padding': '3px', 'source-text-background-shape': 'round-rectangle', 'target-text-offset': 'data(offset)', 'target-text-rotation': 'none', 'target-text-background-color': p.bg, 'target-text-background-opacity': 1, 'target-text-background-padding': '3px', 'target-text-background-shape': 'round-rectangle' } },
|
||||
{ selector: 'edge.edge-hover', style: { width: 2.4, 'line-color': p.fg, 'target-arrow-color': p.fg, 'line-opacity': 1, color: p.fg } },
|
||||
{ selector: 'edge[kind != "connection"]', style: { display: 'none', width: 1, 'curve-style': 'bezier', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.55 } },
|
||||
{ selector: 'edge[kind = "supersedes"]', style: { 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-shape': 'triangle', 'target-arrow-color': TYPE_COLORS.decision } },
|
||||
{ selector: 'edge[kind != "connection"].shown', style: { display: 'element' } },
|
||||
{ selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
|
||||
{ selector: 'edge.labelled.ortho[kind = "connection"][at = "source"]', style: { label: '', 'source-label': 'data(label)' } },
|
||||
{ selector: 'edge.labelled.ortho[kind = "connection"][at = "target"]', style: { label: '', 'target-label': 'data(label)' } },
|
||||
{ selector: '.dim', style: { opacity: 0.12, events: 'no' } },
|
||||
{ selector: 'node.hit', style: { 'border-width': 2.5 } },
|
||||
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg, 'line-opacity': 1 } },
|
||||
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 2.5, 'underlay-color': p.accent, 'underlay-opacity': dark ? 0.28 : 0.18, 'background-color': fill(dark ? 0.3 : 0.2) } },
|
||||
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'line-opacity': 1 } },
|
||||
{ selector: '.hidden', style: { display: 'none' } },
|
||||
] as unknown as cytoscape.StylesheetJson;
|
||||
}
|
||||
|
||||
private layout(fresh: boolean): void {
|
||||
const force = this.saved.layout === 'force';
|
||||
this.cy.edges().toggleClass('ortho', !force);
|
||||
if (force) {
|
||||
this.cy
|
||||
.elements()
|
||||
.not('.hidden')
|
||||
.layout({ name: 'fcose', animate: false, randomize: fresh, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 150 : 110), edgeElasticity: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 0.2 : 0.08), gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
const positions = layeredLayout(this.graph.nodes, this.graph.edges);
|
||||
this.cy.batch(() => {
|
||||
this.cy.nodes().forEach((n) => {
|
||||
const at = positions.get(n.id());
|
||||
if (at) n.position(at);
|
||||
});
|
||||
});
|
||||
this.cy.fit(this.cy.elements().not('.hidden'), 40);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- theme
|
||||
|
||||
/** Sets the page's own theme (or follows VS Code when undefined) without touching the saved choice. */
|
||||
private applyTheme(theme: Theme | undefined): void {
|
||||
if (theme) document.documentElement.dataset.theme = theme;
|
||||
else delete document.documentElement.dataset.theme;
|
||||
}
|
||||
|
||||
private setTheme(theme: Theme): void {
|
||||
this.applyTheme(theme);
|
||||
this.saved.theme = theme;
|
||||
this.save();
|
||||
try {
|
||||
localStorage.setItem('archify-theme', theme); // the diagram pages read the same key
|
||||
} catch {
|
||||
// storage may be unavailable; the page state keeps the choice
|
||||
}
|
||||
this.restyle();
|
||||
}
|
||||
|
||||
private syncThemeButton(): void {
|
||||
const dark = currentTheme() === 'dark';
|
||||
this.themeButton.dataset.on = dark ? 'dark' : 'light';
|
||||
this.themeButton.setAttribute('aria-checked', String(dark));
|
||||
this.themeButton.title = dark ? 'Dark theme on; switch to light' : 'Light theme on; switch to dark';
|
||||
this.themeButton.setAttribute('aria-label', this.themeButton.title);
|
||||
}
|
||||
|
||||
private restyle(): void {
|
||||
this.cy?.style(this.stylesheet());
|
||||
this.syncLabels();
|
||||
this.syncThemeButton();
|
||||
}
|
||||
|
||||
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('[kind = "element"]').not('.hidden').length;
|
||||
const r = this.result;
|
||||
if (!r || !this.queryInput.value.trim()) {
|
||||
this.countEl.textContent = `${total} elements · ${this.cy.edges('[kind = "connection"]').not('.hidden').length} connections`;
|
||||
this.countEl.classList.remove('is-error');
|
||||
return;
|
||||
}
|
||||
const shown = this.cy.nodes('.hit[kind = "element"]').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 elements = this.graph.nodes.filter((n) => n.kind === 'element');
|
||||
const registries = this.graph.nodes.filter((n) => n.kind === 'registry');
|
||||
const summary = el('p', '', `${elements.length} elements, ${this.graph.edges.filter((e) => e.kind === 'connection').length} connections, ${this.graph.diagrams.length} diagrams, ${registries.length} ${registries.length === 1 ? 'registry' : 'registries'}, ${this.graph.decisions.length} decisions.`);
|
||||
this.side.appendChild(summary);
|
||||
const unused = elements.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.showEdge(this.edgesById.get(target.id()));
|
||||
return;
|
||||
}
|
||||
const node = this.nodesById.get(target.id());
|
||||
if (node?.kind === 'decision') this.showDecision(node);
|
||||
else if (node?.kind === 'registry') this.showRegistry(node);
|
||||
else this.showNode(node);
|
||||
}
|
||||
|
||||
private selectNode(id: string): void {
|
||||
const n = this.cy.$id(id);
|
||||
this.cy.elements().unselect();
|
||||
n.select();
|
||||
this.showSide(n as NodeSingular);
|
||||
}
|
||||
|
||||
private elementLinks(ids: string[]): HTMLElement {
|
||||
const holder = el('div');
|
||||
for (const id of ids) {
|
||||
const n = this.nodesById.get(id);
|
||||
if (!n) continue;
|
||||
const button = this.link(n.label, `Show ${n.id}`, () => this.selectNode(n.id));
|
||||
const dot = el('span', 'ag-dot');
|
||||
dot.style.setProperty('--c', nodeColor(n));
|
||||
button.prepend(dot);
|
||||
holder.appendChild(button);
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
|
||||
/** Side panel of a decision node: status, text, what it is about, and the file. */
|
||||
private showDecision(node: GraphNode): void {
|
||||
this.side.textContent = '';
|
||||
const title = el('h2');
|
||||
const dot = el('span', 'ag-dot');
|
||||
dot.style.setProperty('--c', nodeColor(node));
|
||||
title.append(dot, document.createTextNode(node.label));
|
||||
this.side.appendChild(title);
|
||||
this.side.appendChild(el('div', 'ag-id', ['decision', node.sublabel, node.status].filter(Boolean).join(' · ')));
|
||||
if (node.description) this.side.appendChild(el('p', '', node.description));
|
||||
const actions = el('div', 'ag-side-actions');
|
||||
if (node.file) actions.appendChild(this.link('Open decision', `Open ${node.file}`, () => this.host.post({ type: 'openDecision', file: node.file })));
|
||||
if (node.sublabel) actions.appendChild(this.link('What it touches', `Show everything decision ${node.sublabel} is about`, () => this.setQuery(`decision:${node.sublabel}`)));
|
||||
this.side.appendChild(actions);
|
||||
const about = this.graph.edges.filter((e) => e.kind === 'decides' && e.from === node.id).map((e) => e.to);
|
||||
this.side.appendChild(el('h3', '', `About ${about.length} element${about.length === 1 ? '' : 's'}`));
|
||||
this.side.appendChild(about.length ? this.elementLinks(about) : el('p', 'ag-empty', 'Names no element of this model.'));
|
||||
const onEdges = this.graph.edges.filter((e) => e.kind === 'connection' && node.sublabel && e.decisions.includes(node.sublabel));
|
||||
if (onEdges.length) {
|
||||
this.side.appendChild(el('h3', '', 'On connections'));
|
||||
const holder = el('div');
|
||||
for (const e of onEdges) holder.appendChild(this.link(`${this.nodesById.get(e.from)?.label ?? e.from} → ${this.nodesById.get(e.to)?.label ?? e.to}`, e.id, () => { this.cy.elements().unselect(); this.cy.$id(e.id).select(); this.showEdge(e); }));
|
||||
this.side.appendChild(holder);
|
||||
}
|
||||
if (node.diagrams.length) {
|
||||
this.side.appendChild(el('h3', '', 'Diagrams it names'));
|
||||
this.side.appendChild(this.diagramLinks(node.diagrams));
|
||||
}
|
||||
const related = this.graph.edges.filter((e) => e.kind === 'supersedes' && (e.from === node.id || e.to === node.id));
|
||||
if (related.length) {
|
||||
this.side.appendChild(el('h3', '', 'Related decisions'));
|
||||
const holder = el('div');
|
||||
for (const e of related) {
|
||||
const otherId = e.from === node.id ? e.to : e.from;
|
||||
const other = this.nodesById.get(otherId);
|
||||
if (other) holder.appendChild(this.link(`${e.from === node.id ? 'supersedes' : 'superseded by'} ${other.label}`, other.sublabel ?? otherId, () => this.selectNode(otherId)));
|
||||
}
|
||||
this.side.appendChild(holder);
|
||||
}
|
||||
}
|
||||
|
||||
/** Side panel of a registry node: the file and the elements it defines. */
|
||||
private showRegistry(node: GraphNode): void {
|
||||
this.side.textContent = '';
|
||||
const title = el('h2');
|
||||
const dot = el('span', 'ag-dot');
|
||||
dot.style.setProperty('--c', nodeColor(node));
|
||||
title.append(dot, document.createTextNode(node.label));
|
||||
this.side.appendChild(title);
|
||||
this.side.appendChild(el('div', 'ag-id', `registry · ${node.file ?? ''}`));
|
||||
const actions = el('div', 'ag-side-actions');
|
||||
if (node.file) actions.appendChild(this.link('Open registry', `Open ${node.file}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: 0 })));
|
||||
this.side.appendChild(actions);
|
||||
const ids = node.elements ?? [];
|
||||
this.side.appendChild(el('h3', '', `Defines ${ids.length} element${ids.length === 1 ? '' : 's'}`));
|
||||
this.side.appendChild(ids.length ? this.elementLinks(ids) : el('p', 'ag-empty', 'No elements yet.'));
|
||||
const unused = ids.filter((id) => !(this.nodesById.get(id)?.diagrams.length));
|
||||
if (unused.length) {
|
||||
this.side.appendChild(el('h3', '', `${unused.length} not drawn by any diagram`));
|
||||
this.side.appendChild(this.elementLinks(unused));
|
||||
}
|
||||
}
|
||||
|
||||
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.kind === 'connection' && (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;
|
||||
}
|
||||
});
|
||||
let resizeTimer: number | undefined;
|
||||
window.addEventListener('resize', () => {
|
||||
this.cy.resize();
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => this.cy.fit(this.cy.elements().not('.hidden'), 40), 150);
|
||||
});
|
||||
new MutationObserver(() => this.restyle()).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 ?? [], theme: this.saved.theme, layout: this.saved.layout, links: this.saved.links } });
|
||||
}
|
||||
}
|
||||
|
||||
/** The theme the Archify diagram pages last used, when they stored one. */
|
||||
function archifyThemePreference(): Theme | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem('archify-theme');
|
||||
return value === 'dark' || value === 'light' ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
const host = window.__archifyHost;
|
||||
if (!host?.graph) return;
|
||||
// Exposed for the integration harness; nothing in the page depends on it.
|
||||
(window as unknown as { __archifyGraphPage?: GraphPage }).__archifyGraphPage = new GraphPage(host, host.graph);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||
else start();
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GraphEdge, GraphNode } from '../model/graph';
|
||||
|
||||
/**
|
||||
* Layered layout: a column per element type in Archify's stack order
|
||||
* (clients on the left, data on the right), empty columns left out, rows
|
||||
* ordered to shorten the connections; registries in a row above the
|
||||
* columns, decisions in a row below. Deterministic, so the picture is stable.
|
||||
*/
|
||||
|
||||
const COLUMN_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'other', 'messagebus', 'database'];
|
||||
const STATUS_ORDER = ['accepted', 'proposed', 'superseded', 'rejected'];
|
||||
|
||||
export interface LayoutOptions {
|
||||
columnGap: number;
|
||||
rowGap: number;
|
||||
}
|
||||
|
||||
export type Positions = Map<string, { x: number; y: number }>;
|
||||
|
||||
function columnOf(node: GraphNode): number {
|
||||
if (node.kind === 'registry') return -1;
|
||||
if (node.kind === 'decision') return COLUMN_ORDER.length;
|
||||
const i = COLUMN_ORDER.indexOf(node.type ?? 'other');
|
||||
return i < 0 ? COLUMN_ORDER.indexOf('other') : i;
|
||||
}
|
||||
|
||||
export function layeredLayout(nodes: GraphNode[], edges: GraphEdge[], options: LayoutOptions = { columnGap: 230, rowGap: 84 }): Positions {
|
||||
const columns = new Map<number, GraphNode[]>();
|
||||
const registries = nodes.filter((n) => n.kind === 'registry').sort((a, b) => a.label.localeCompare(b.label));
|
||||
const decisions = nodes.filter((n) => n.kind === 'decision').sort((a, b) => STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label));
|
||||
for (const n of nodes) if (n.kind === 'element') columns.set(columnOf(n), [...(columns.get(columnOf(n)) ?? []), n]);
|
||||
const order = new Map<string, number>();
|
||||
const neighbours = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.kind === 'supersedes') continue;
|
||||
neighbours.set(e.from, [...(neighbours.get(e.from) ?? []), e.to]);
|
||||
neighbours.set(e.to, [...(neighbours.get(e.to) ?? []), e.from]);
|
||||
}
|
||||
// Start from a stable order: elements by label, decisions by status, registries by file.
|
||||
for (const [col, list] of columns) {
|
||||
list.sort((a, b) => {
|
||||
if (a.kind === 'decision' && b.kind === 'decision') return STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label);
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
list.forEach((n, i) => order.set(n.id, i));
|
||||
columns.set(col, list);
|
||||
}
|
||||
// Barycenter sweeps: each node moves towards the average row of what it is connected to.
|
||||
const keys = [...columns.keys()].sort((a, b) => a - b);
|
||||
for (let sweep = 0; sweep < 4; sweep += 1) {
|
||||
const sequence = sweep % 2 === 0 ? keys : [...keys].reverse();
|
||||
for (const col of sequence) {
|
||||
const list = columns.get(col)!;
|
||||
const weight = new Map<string, number>();
|
||||
for (const n of list) {
|
||||
const rows = (neighbours.get(n.id) ?? []).map((id) => order.get(id)).filter((r): r is number => r !== undefined);
|
||||
weight.set(n.id, rows.length ? rows.reduce((a, b) => a + b, 0) / rows.length : order.get(n.id)!);
|
||||
}
|
||||
list.sort((a, b) => weight.get(a.id)! - weight.get(b.id)! || order.get(a.id)! - order.get(b.id)!);
|
||||
list.forEach((n, i) => order.set(n.id, i));
|
||||
}
|
||||
}
|
||||
const positions: Positions = new Map();
|
||||
const tallest = Math.max(1, ...[...columns.values()].map((list) => list.length));
|
||||
// Empty columns are left out, so the picture is as wide as the types the model uses.
|
||||
const used = keys.filter((col) => columns.get(col)!.length);
|
||||
used.forEach((col, index) => {
|
||||
const list = columns.get(col)!;
|
||||
const offset = ((tallest - list.length) * options.rowGap) / 2;
|
||||
list.forEach((n, i) => positions.set(n.id, { x: index * options.columnGap, y: offset + i * options.rowGap }));
|
||||
});
|
||||
const width = Math.max(0, used.length - 1) * options.columnGap;
|
||||
// Registries and decisions sit above or below the elements they link to: at the mean x of those
|
||||
// elements, pushed apart only as far as needed so they do not overlap.
|
||||
const spread = (list: GraphNode[], y: number, gap: number) => {
|
||||
const wanted = list.map((n) => {
|
||||
const xs = (neighbours.get(n.id) ?? []).map((id) => positions.get(id)?.x).filter((x): x is number => x !== undefined);
|
||||
return { n, x: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : width / 2 };
|
||||
});
|
||||
wanted.sort((a, b) => a.x - b.x || a.n.label.localeCompare(b.n.label));
|
||||
for (let i = 1; i < wanted.length; i += 1) wanted[i].x = Math.max(wanted[i].x, wanted[i - 1].x + gap);
|
||||
for (let i = wanted.length - 2; i >= 0; i -= 1) wanted[i].x = Math.min(wanted[i].x, wanted[i + 1].x - gap);
|
||||
for (const { n, x } of wanted) positions.set(n.id, { x, y });
|
||||
};
|
||||
spread(registries, -options.rowGap * 1.6, 200);
|
||||
spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, 250);
|
||||
return positions;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** 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; --ag-decision: #e879f9; --ag-registry: #cbd5e1; }
|
||||
/* A theme chosen on the page overrides VS Code's; without one the VS Code variables above apply. */
|
||||
:root[data-theme="dark"] { --ag-bg: #0b1020; --ag-fg: #e5e7eb; --ag-muted: #9ca3af; --ag-panel: #111a2e; --ag-border: rgba(148, 163, 184, 0.28); --ag-accent: #22d3ee; --ag-input: #0f172a; --ag-input-fg: #e5e7eb; --ag-input-border: rgba(148, 163, 184, 0.28); --ag-error: #f87171; color-scheme: dark; }
|
||||
:root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
|
||||
|
||||
:root { --ag-gutter: 1.5rem; }
|
||||
.ag { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; }
|
||||
/* One head block: title row and query row share a background and a single rule below. */
|
||||
.ag-head { background: color-mix(in srgb, var(--ag-panel) 55%, var(--ag-bg)); border-bottom: 1px solid var(--ag-border); }
|
||||
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 1.1rem var(--ag-gutter) 0.6rem; }
|
||||
.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); margin-bottom: 0.2rem; }
|
||||
.ag-name { font-weight: 700; font-size: 1.25rem; line-height: 1.2; letter-spacing: -0.015em; }
|
||||
.ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ag-bar .ag-button { min-height: 2rem; }
|
||||
/* Toolbar controls follow Archify's: quiet pills (0.5rem) inside 0.5625rem buttons, one filled primary action (0.6875rem), the 2D | 3D switch's twin. */
|
||||
.ag-switch { position: relative; isolation: isolate; display: inline-flex; padding: 0 0.25rem; border: 0; border-radius: 0.5625rem; background: transparent; color: var(--ag-muted); font: inherit; }
|
||||
.ag-switch::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||
.ag-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||
.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; margin: 0.25rem 0; padding: 0 0.7rem; border: 0; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; font-weight: 550; font-size: 0.8125rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
.ag-seg.on { cursor: default; }
|
||||
.ag-seg:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 1px; }
|
||||
.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
.ag-seg:hover:not(.on) { color: var(--ag-fg); }
|
||||
.ag-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.8125rem; font-weight: 500; min-height: 2.25rem; padding: 0.25rem 0.58rem; border-radius: 0.5625rem; border: 0; background: transparent; color: var(--ag-muted); cursor: pointer; white-space: nowrap; transition: color 0.15s; }
|
||||
.ag-button::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.5rem; background: transparent; transition: background 0.15s, box-shadow 0.15s; }
|
||||
.ag-button:hover { color: var(--ag-fg); }
|
||||
.ag-button:hover::before { background: color-mix(in srgb, var(--ag-fg) 6%, transparent); }
|
||||
.ag-button[aria-pressed="true"] { color: var(--ag-fg); }
|
||||
.ag-button[aria-pressed="true"]::before { background: color-mix(in srgb, var(--ag-fg) 8%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 14%, transparent); }
|
||||
.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: -4px; }
|
||||
.ag-primary { color: var(--ag-bg); font-weight: 600; padding: 0.25rem 0.9rem; }
|
||||
.ag-primary::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
.ag-primary:hover { color: var(--ag-bg); }
|
||||
.ag-primary:hover::before { background: color-mix(in srgb, var(--ag-fg) 86%, var(--ag-bg)); }
|
||||
/* Theme toggle: Archify's own, a pill track with a knob that slides and carries the sun or moon. */
|
||||
.ag-theme { width: 4rem; padding: 0; justify-content: center; }
|
||||
/* The same outer pill as the Model | Graph switch and Export, so the three line up; Archify's track and knob inside it. */
|
||||
.ag-theme::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||
.ag-theme:hover::before { background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||
.ag-theme-track { position: absolute; z-index: 0; inset: 0.6875rem 0.5625rem; border-radius: 999px; background: color-mix(in srgb, var(--ag-fg) 9%, transparent); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); transition: background 0.15s; }
|
||||
.ag-theme:hover .ag-theme-track { background: color-mix(in srgb, var(--ag-fg) 13%, transparent); }
|
||||
.ag-theme::after { content: ""; position: absolute; z-index: 1; top: 50%; left: 0.6875rem; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--ag-panel); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.12); transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
.ag-theme[data-on="dark"] .ag-theme-track { background: rgba(148, 163, 184, 0.16); }
|
||||
.ag-theme[data-on="dark"]::after { left: calc(100% - 0.6875rem - 1.125rem); background: #3b4a63; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); }
|
||||
.ag-theme-icon { position: absolute; z-index: 2; top: 50%; left: calc(0.6875rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; }
|
||||
.ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.6875rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||
.ag-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.ag-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem var(--ag-gutter) 0.9rem; 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.5625rem; padding: 0 0.6rem; }
|
||||
.ag-query-wrap:focus-within { border-color: var(--ag-accent); }
|
||||
.ag-query { flex: 1; min-width: 0; height: 2.125rem; 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; margin: 0 0.35rem 0 0.25rem; }
|
||||
.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: var(--ag-gutter); bottom: 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2 * var(--ag-gutter)); }
|
||||
.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: 1.1rem var(--ag-gutter) 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.6875rem; 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; } }
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import { isArchifyFile } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import path from 'node:path';
|
||||
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||
import { DIAGRAM_SCHEMA, REGISTRY_SCHEMA, SchemaChecker, SchemaProblem, readSchemas } from './schemaCheck';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
@@ -33,6 +34,7 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly sequence = new Map<string, number>();
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
private checker: SchemaChecker | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
@@ -54,6 +56,22 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
this.revalidateAll();
|
||||
}
|
||||
|
||||
/** Our schemas, compiled on first use. */
|
||||
private schemas(): SchemaChecker {
|
||||
this.checker ??= new SchemaChecker(readSchemas(vscode.Uri.joinPath(this.context.extensionUri, 'schemas').fsPath));
|
||||
return this.checker;
|
||||
}
|
||||
|
||||
private schemaDiagnostic(doc: vscode.TextDocument, problem: SchemaProblem): vscode.Diagnostic {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(doc.positionAt(problem.from), doc.positionAt(problem.to)),
|
||||
problem.message,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
private enabled(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('validation.enabled', true);
|
||||
}
|
||||
@@ -136,6 +154,17 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
if (isArchifyFile(doc.fileName)) {
|
||||
// The schema check sees the source as written and answers at once; a
|
||||
// file that fails it would fail archify validate the same way, so that
|
||||
// runs only on files the schema accepts.
|
||||
const schemaProblems = this.schemas().check(text, DIAGRAM_SCHEMA);
|
||||
if (schemaProblems.length) {
|
||||
this.collection.set(doc.uri, [...modelDiagnostics, ...schemaProblems.map((problem) => this.schemaDiagnostic(doc, problem))]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, doc.uri);
|
||||
const result = await validate(runtime, source.type, source.json);
|
||||
@@ -210,6 +239,10 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
const root = await this.models.rootOf(doc.uri);
|
||||
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath) : path.basename(doc.uri.fsPath);
|
||||
const problems: ModelProblem[] = [...parseRegistry(text, rel).problems];
|
||||
// The schema adds what the parser does not check (types, unknown keys,
|
||||
// ids); a file the parser already rejects as a whole gets no second opinion.
|
||||
const shapeless = problems.some((p) => /not valid YAML|must be a mapping|needs an "elements" mapping/.test(p.message));
|
||||
const schemaProblems = shapeless ? [] : this.schemas().check(text, REGISTRY_SCHEMA);
|
||||
if (root) {
|
||||
try {
|
||||
const index = await this.models.index(root);
|
||||
@@ -223,7 +256,10 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
problems.push({ message: `This registry is not inside a model: no main.archify above ${path.basename(doc.uri.fsPath)}.`, severity: 'warning', line: 0 });
|
||||
}
|
||||
if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return;
|
||||
this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)));
|
||||
this.collection.set(doc.uri, [
|
||||
...problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)),
|
||||
...schemaProblems.map((problem) => this.schemaDiagnostic(doc, problem)),
|
||||
]);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DIAGRAM_SCHEMA, REGISTRY_SCHEMA } from './schemaCheck';
|
||||
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
|
||||
/**
|
||||
* Schemas for completion and hover in the Source view. The editor's schema
|
||||
* library reads one draft-07 document, while ours are 2020-12 files that
|
||||
* refer to each other; so the referenced files are folded into `$defs` and
|
||||
* 2020-12 tuples become draft-07 ones. Problems never come from these
|
||||
* copies (the host checks with the originals), only suggestions.
|
||||
*/
|
||||
export class EditorSchemas {
|
||||
private readonly cache = new Map<string, object>();
|
||||
|
||||
constructor(private readonly schemas: Record<string, object>) {}
|
||||
|
||||
/** The schema for a file kind; for diagrams the one of its diagram_type. */
|
||||
forSource(kind: 'diagram' | 'registry' | 'decision', text: string): { key: string; schema: object } | undefined {
|
||||
if (kind === 'decision') return undefined;
|
||||
if (kind === 'registry') return this.get(REGISTRY_SCHEMA);
|
||||
const type = /^diagram_type:\s*["']?([a-z]+)/m.exec(text)?.[1];
|
||||
const name = type && this.schemas[`${type}.schema.json`] ? `${type}.schema.json` : DIAGRAM_SCHEMA;
|
||||
return this.get(name);
|
||||
}
|
||||
|
||||
private get(name: string): { key: string; schema: object } | undefined {
|
||||
if (!this.schemas[name]) return undefined;
|
||||
if (!this.cache.has(name)) this.cache.set(name, bundleSchema(this.schemas, name));
|
||||
return { key: name, schema: this.cache.get(name)! };
|
||||
}
|
||||
}
|
||||
|
||||
const defKey = (file: string) => `file:${file}`;
|
||||
|
||||
/** One self-contained draft-07 shaped schema for `root` and every file it refers to. */
|
||||
export function bundleSchema(schemas: Record<string, object>, root: string): object {
|
||||
const files = new Set<string>([root]);
|
||||
const queue = [root];
|
||||
while (queue.length) {
|
||||
const file = queue.shift()!;
|
||||
for (const ref of refsOf(schemas[file] as Json)) {
|
||||
const target = ref.split('#')[0];
|
||||
if (target && !files.has(target) && schemas[target]) {
|
||||
files.add(target);
|
||||
queue.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
const base = (file: string) => (file === root ? '#' : `#/$defs/${defKey(file)}`);
|
||||
const rewrite = (node: Json, file: string): Json => {
|
||||
if (Array.isArray(node)) return node.map((item) => rewrite(item, file));
|
||||
if (!node || typeof node !== 'object') return node;
|
||||
const out: { [key: string]: Json } = {};
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === '$ref' && typeof value === 'string') {
|
||||
const [target, pointer = ''] = value.split('#');
|
||||
out.$ref = `${base(target || file)}${pointer}`;
|
||||
} else if (key !== '$schema' && key !== '$id') {
|
||||
out[key] = rewrite(value, file);
|
||||
}
|
||||
}
|
||||
if (out.prefixItems) {
|
||||
if (out.items === false) out.additionalItems = false;
|
||||
else if (out.items !== undefined) out.additionalItems = out.items;
|
||||
out.items = out.prefixItems;
|
||||
delete out.prefixItems;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const bundled = rewrite(schemas[root] as Json, root) as { [key: string]: Json };
|
||||
const defs = { ...((bundled.$defs as { [key: string]: Json } | undefined) ?? {}) };
|
||||
for (const file of files) if (file !== root) defs[defKey(file)] = rewrite(schemas[file] as Json, file);
|
||||
return { ...bundled, $defs: defs };
|
||||
}
|
||||
|
||||
function refsOf(node: Json, out: string[] = []): string[] {
|
||||
if (Array.isArray(node)) node.forEach((item) => refsOf(item, out));
|
||||
else if (node && typeof node === 'object') {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === '$ref' && typeof value === 'string') out.push(value);
|
||||
else refsOf(value, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Ajv2020, { ErrorObject, ValidateFunction } from 'ajv/dist/2020';
|
||||
import { LineCounter, Node, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||
|
||||
/** A schema problem placed on the YAML node it is about; offsets into the text. */
|
||||
export interface SchemaProblem {
|
||||
from: number;
|
||||
to: number;
|
||||
message: string;
|
||||
/** 'yaml' for a syntax error (only the first is reported), 'schema' otherwise. */
|
||||
kind: 'yaml' | 'schema';
|
||||
}
|
||||
|
||||
/** Schema file names in schemas/ that a file kind is checked against. */
|
||||
export const DIAGRAM_SCHEMA = 'archify.schema.json';
|
||||
export const REGISTRY_SCHEMA = 'archify-registry.schema.json';
|
||||
|
||||
/** Reads every *.schema.json of a folder: file name → schema. */
|
||||
export function readSchemas(folder: string): Record<string, object> {
|
||||
return Object.fromEntries(
|
||||
fs
|
||||
.readdirSync(folder)
|
||||
.filter((name) => name.endsWith('.schema.json'))
|
||||
.map((name) => [name, JSON.parse(fs.readFileSync(path.join(folder, name), 'utf8')) as object]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks YAML text against our JSON Schemas (draft 2020-12, with ajv), so the
|
||||
* Source view and the Problems panel see the same problems whether or not a
|
||||
* YAML extension is installed. Schemas refer to each other by file name.
|
||||
*/
|
||||
export class SchemaChecker {
|
||||
private readonly ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
private readonly compiled = new Map<string, ValidateFunction | undefined>();
|
||||
|
||||
constructor(schemas: Record<string, object>) {
|
||||
for (const [name, schema] of Object.entries(schemas)) this.ajv.addSchema(schema, name);
|
||||
}
|
||||
|
||||
private validator(name: string): ValidateFunction | undefined {
|
||||
if (!this.compiled.has(name)) this.compiled.set(name, this.ajv.getSchema(name));
|
||||
return this.compiled.get(name);
|
||||
}
|
||||
|
||||
check(text: string, schema: string): SchemaProblem[] {
|
||||
const doc = parseDocument(text, { lineCounter: new LineCounter(), prettyErrors: false, uniqueKeys: false });
|
||||
if (doc.errors.length) {
|
||||
// Later syntax errors are usually echoes of the first.
|
||||
const error = doc.errors[0];
|
||||
return [{ from: error.pos[0], to: Math.max(error.pos[1], error.pos[0] + 1), message: `YAML: ${error.message.split('\n')[0]}`, kind: 'yaml' }];
|
||||
}
|
||||
const validate = this.validator(schema);
|
||||
if (!validate || validate(doc.toJS())) return [];
|
||||
return collapse(validate.errors ?? []).map((error) => {
|
||||
const [from, to] = rangeFor(doc.contents, error, text.length);
|
||||
return { from, to, message: describe(error), kind: 'schema' as const };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** oneOf/anyOf/if produce a cascade; keep the errors a person can act on. */
|
||||
function collapse(errors: ErrorObject[]): ErrorObject[] {
|
||||
const seen = new Set<string>();
|
||||
return errors
|
||||
.filter((e) => !['if', 'oneOf', 'anyOf', 'allOf'].includes(e.keyword))
|
||||
.filter((e) => {
|
||||
const key = `${e.instancePath}|${e.keyword}|${JSON.stringify(e.params)}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** "/connections/4/label" → "connections[4].label". */
|
||||
export function readablePointer(pointer: string): string {
|
||||
if (!pointer) return 'the file';
|
||||
return pointer
|
||||
.split('/')
|
||||
.slice(1)
|
||||
.map((part) => (/^\d+$/.test(part) ? `[${part}]` : `.${part.replace(/~1/g, '/').replace(/~0/g, '~')}`))
|
||||
.join('')
|
||||
.replace(/^\./, '');
|
||||
}
|
||||
|
||||
function describe(e: ErrorObject): string {
|
||||
const where = readablePointer(e.instancePath);
|
||||
const params = e.params as Record<string, unknown>;
|
||||
switch (e.keyword) {
|
||||
case 'additionalProperties':
|
||||
return `Unknown key "${params.additionalProperty}" in ${where}.`;
|
||||
case 'required':
|
||||
return `"${params.missingProperty}" is required in ${where}.`;
|
||||
case 'enum':
|
||||
return `${where} must be one of: ${(params.allowedValues as unknown[]).join(', ')}.`;
|
||||
case 'const':
|
||||
return `${where} must be ${JSON.stringify(params.allowedValue)}.`;
|
||||
case 'type': {
|
||||
const type = String(params.type);
|
||||
return `${where} must be ${type === 'array' ? 'a list' : type === 'object' ? 'a mapping' : `a ${type}`}.`;
|
||||
}
|
||||
case 'minItems':
|
||||
case 'maxItems': {
|
||||
const limit = Number(params.limit);
|
||||
return `${where} needs ${e.keyword === 'minItems' ? 'at least' : 'at most'} ${limit} item${limit === 1 ? '' : 's'}.`;
|
||||
}
|
||||
case 'propertyNames':
|
||||
return `"${params.propertyName}" in ${where} is not a valid id: start with a letter; letters, digits, - and _ after.`;
|
||||
default:
|
||||
return `${where} ${e.message ?? 'is not valid'}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function keyText(key: unknown): string {
|
||||
return isScalar(key) ? String(key.value) : String(key);
|
||||
}
|
||||
|
||||
/** The node an error is about: the offending key for unknown keys, else the value. */
|
||||
function rangeFor(root: Node | null, error: ErrorObject, length: number): [number, number] {
|
||||
const parts = error.instancePath.split('/').slice(1).map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
|
||||
let node: unknown = root;
|
||||
let keyNode: Node | null = null;
|
||||
for (const part of parts) {
|
||||
if (isMap(node)) {
|
||||
const pair = node.items.find((p) => keyText(p.key) === part);
|
||||
if (!pair) break;
|
||||
keyNode = pair.key as Node;
|
||||
node = pair.value;
|
||||
} else if (isSeq(node)) {
|
||||
keyNode = null;
|
||||
node = node.items[Number(part)];
|
||||
} else break;
|
||||
}
|
||||
const params = error.params as Record<string, unknown>;
|
||||
const extra = params.additionalProperty ?? params.propertyName;
|
||||
if (extra !== undefined && isMap(node)) {
|
||||
const pair = node.items.find((p) => keyText(p.key) === String(extra));
|
||||
const range = (pair?.key as Node | undefined)?.range;
|
||||
if (range) return [range[0], range[1]];
|
||||
}
|
||||
if (error.keyword === 'required' && keyNode?.range) return [keyNode.range[0], keyNode.range[1]];
|
||||
const target = (node as Node | undefined)?.range ? (node as Node) : keyNode;
|
||||
if (!target?.range) return [0, Math.min(1, length)];
|
||||
// A block mapping or list spans many lines; mark its first character only.
|
||||
if ((isMap(target) || isSeq(target)) && !target.flow) return [target.range[0], target.range[0] + 1];
|
||||
return [target.range[0], Math.max(target.range[1], target.range[0] + 1)];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||
import { LineCounter, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||
import { ModelProblem, Registry } from './format';
|
||||
|
||||
/**
|
||||
@@ -46,9 +46,11 @@ export function slugify(title: string): string {
|
||||
/** Parses one decision file. Problems carry 0-based lines. */
|
||||
export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } {
|
||||
const problems: ModelProblem[] = [];
|
||||
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
|
||||
// prettyErrors would put line numbers into the messages; the line counter gives them without.
|
||||
const lines = new LineCounter();
|
||||
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false, lineCounter: lines });
|
||||
for (const error of doc.errors) {
|
||||
problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
|
||||
problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: lines.linePos(error.pos[0]).line - 1 });
|
||||
}
|
||||
if (doc.errors.length) return { problems };
|
||||
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
|
||||
|
||||
+54
-5
@@ -1,4 +1,4 @@
|
||||
import { isMap, isScalar, parseDocument, parse as parseYaml } from 'yaml';
|
||||
import { LineCounter, isMap, isScalar, parse as parseYaml, parseDocument } from 'yaml';
|
||||
|
||||
/**
|
||||
* The model file formats (see docs/model.md): `main.archify` with a `model`
|
||||
@@ -83,9 +83,11 @@ export function hasDiagramBody(doc: unknown): boolean {
|
||||
export function parseRegistry(text: string, file: string): { elements: RegistryElement[]; problems: ModelProblem[] } {
|
||||
const problems: ModelProblem[] = [];
|
||||
const elements: RegistryElement[] = [];
|
||||
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
|
||||
// prettyErrors would put line numbers into the messages; the line counter gives them without.
|
||||
const lines = new LineCounter();
|
||||
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false, lineCounter: lines });
|
||||
for (const error of doc.errors) {
|
||||
problems.push({ message: `Registry is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
|
||||
problems.push({ message: `Registry is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: lines.linePos(error.pos[0]).line - 1 });
|
||||
}
|
||||
if (doc.errors.length) return { elements, problems };
|
||||
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
|
||||
@@ -309,6 +311,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 +336,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 +349,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
|
||||
try {
|
||||
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||
} catch (error) {
|
||||
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], nodes: [], edges: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
}
|
||||
const obj = isObj(doc) ? doc : {};
|
||||
const meta = isObj(obj.meta) ? obj.meta : {};
|
||||
const components = Array.isArray(obj.components) ? obj.components : [];
|
||||
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions };
|
||||
const nodes: DiagramNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of NODE_COLLECTIONS) {
|
||||
const items = obj[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
if (!isObj(item)) continue;
|
||||
const ref = str(item.ref);
|
||||
const id = str(item.id) ?? ref;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
nodes.push({ id, ref, type: str(item.type), label: str(item.label), sublabel: str(item.sublabel), brand: str(item.brand) });
|
||||
}
|
||||
}
|
||||
const edges: DiagramEdge[] = [];
|
||||
for (const key of EDGE_COLLECTIONS) {
|
||||
const items = obj[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
if (!isObj(item)) continue;
|
||||
const from = str(item.from);
|
||||
const to = str(item.to);
|
||||
if (!from || !to || !seen.has(from) || !seen.has(to)) continue;
|
||||
edges.push({ from, to, label: str(item.label) });
|
||||
}
|
||||
}
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions, nodes, edges };
|
||||
}
|
||||
|
||||
/** Groups in display order: the root group first, then folders in path order. */
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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 type GraphNodeKind = 'element' | 'decision' | 'registry';
|
||||
export type GraphEdgeKind = 'connection' | 'decides' | 'defines' | 'supersedes';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
/** An element of the model, a decision (`decision:<id>`) or a registry file (`registry:<file>`). */
|
||||
kind: GraphNodeKind;
|
||||
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[];
|
||||
/** Decision nodes: the effective status. */
|
||||
status?: string;
|
||||
/** Registry nodes: ids of the elements the file defines. */
|
||||
elements?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
/** `from>to`, unique in the graph. */
|
||||
id: string;
|
||||
/** A connection drawn by a diagram, or a link to a decision or registry node. */
|
||||
kind: GraphEdgeKind;
|
||||
from: string;
|
||||
to: string;
|
||||
/** Distinct labels the diagrams give this connection. */
|
||||
labels: string[];
|
||||
diagrams: string[];
|
||||
decisions: string[];
|
||||
}
|
||||
|
||||
export interface GraphDecision {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface GraphDiagram {
|
||||
path: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface ModelGraph {
|
||||
meta: ModelMeta;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
decisions: GraphDecision[];
|
||||
diagrams: GraphDiagram[];
|
||||
}
|
||||
|
||||
export interface GraphInput {
|
||||
meta: ModelMeta;
|
||||
registry: Registry;
|
||||
diagrams: DiagramSummary[];
|
||||
decisions: DecisionSet;
|
||||
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
|
||||
}
|
||||
|
||||
export const edgeId = (from: string, to: string): string => `${from}>${to}`;
|
||||
export const decisionNodeId = (id: string): string => `decision:${id}`;
|
||||
export const registryNodeId = (file: string): string => `registry:${file}`;
|
||||
|
||||
function pushUnique(list: string[], value: string): void {
|
||||
if (!list.includes(value)) list.push(value);
|
||||
}
|
||||
|
||||
export function buildGraph(input: GraphInput): ModelGraph {
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
const edges = new Map<string, GraphEdge>();
|
||||
|
||||
// Registry elements first: shared, and present even when no diagram draws them yet.
|
||||
for (const e of input.registry.elements.values()) {
|
||||
nodes.set(e.id, {
|
||||
id: e.id,
|
||||
kind: 'element',
|
||||
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, kind: 'element', 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, kind: 'connection', 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 });
|
||||
|
||||
// Registry files as nodes, each linked to the elements it defines.
|
||||
const byFile = new Map<string, string[]>();
|
||||
for (const e of input.registry.elements.values()) byFile.set(e.file, [...(byFile.get(e.file) ?? []), e.id]);
|
||||
for (const file of input.registry.files) if (!byFile.has(file)) byFile.set(file, []);
|
||||
for (const [file, ids] of byFile) {
|
||||
const id = registryNodeId(file);
|
||||
nodes.set(id, { id, kind: 'registry', label: file.replace(/\.archify-registry$/, ''), type: 'registry', shared: true, file, diagrams: [], decisions: [], elements: ids });
|
||||
for (const element of ids) {
|
||||
const edge = edgeId(id, element);
|
||||
edges.set(edge, { id: edge, kind: 'defines', from: id, to: element, labels: [], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Decisions as nodes, linked to the elements and connections they are about, and to what they supersede.
|
||||
for (const d of all.values()) {
|
||||
const id = decisionNodeId(d.id);
|
||||
const row = decisionRow(d);
|
||||
nodes.set(id, { id, kind: 'decision', label: d.title ?? d.id, sublabel: d.id, type: 'decision', shared: true, description: [d.context, d.decision].filter(Boolean).join('\n\n') || undefined, file: d.file, line: 0, diagrams: [...d.diagrams], decisions: [], status: row.status });
|
||||
}
|
||||
for (const node of [...nodes.values()]) {
|
||||
if (node.kind !== 'element') continue;
|
||||
for (const decision of node.decisions) {
|
||||
if (!all.has(decision)) continue;
|
||||
const edge = edgeId(decisionNodeId(decision), node.id);
|
||||
edges.set(edge, { id: edge, kind: 'decides', from: decisionNodeId(decision), to: node.id, labels: [], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
for (const d of all.values()) {
|
||||
for (const other of d.supersedes) {
|
||||
if (!all.has(other)) continue;
|
||||
const edge = edgeId(decisionNodeId(d.id), decisionNodeId(other));
|
||||
edges.set(edge, { id: edge, kind: 'supersedes', from: decisionNodeId(d.id), to: decisionNodeId(other), labels: ['supersedes'], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
return {
|
||||
meta: input.meta,
|
||||
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
decisions: [...all.values()].map(decisionRow).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
diagrams: diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { GraphEdge, GraphNode, ModelGraph } from './graph';
|
||||
|
||||
/**
|
||||
* The graph page's query box. A few words, no language to learn: terms are
|
||||
* ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk
|
||||
* the graph. Pure, shared by the webview bundle and the unit tests.
|
||||
*
|
||||
* database label, id, sublabel, brand or type contain it
|
||||
* type:backend elements of that type
|
||||
* diagram:0200-* drawn by a diagram whose path matches (glob)
|
||||
* decision:<id> what the decision is about or attached to
|
||||
* uses:api api and its neighbours
|
||||
* near:api,2 api and everything within two steps
|
||||
* from:web to:db the shortest path between two elements
|
||||
* unused registry elements no diagram draws
|
||||
* shared / -shared registry elements / inline elements
|
||||
*/
|
||||
|
||||
export interface QueryTerm {
|
||||
key: string;
|
||||
value: string;
|
||||
negate: boolean;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
/** Ids of the elements that match. Every element when the query is empty. */
|
||||
nodes: Set<string>;
|
||||
/** Ids of the connections shown: both ends match, or the path found. */
|
||||
edges: Set<string>;
|
||||
/** The path found by `from: to:`, in order. */
|
||||
path?: string[];
|
||||
/** Why the result is empty or partial, when it is. */
|
||||
message?: string;
|
||||
terms: QueryTerm[];
|
||||
}
|
||||
|
||||
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
|
||||
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
|
||||
|
||||
export function parseQuery(text: string): QueryTerm[] {
|
||||
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
||||
const terms: QueryTerm[] = [];
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
let token = tokens[i];
|
||||
const negate = token.startsWith('-') && token.length > 1;
|
||||
if (negate) token = token.slice(1);
|
||||
const colon = token.indexOf(':');
|
||||
let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase();
|
||||
let value = colon > 0 ? token.slice(colon + 1) : '';
|
||||
if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) {
|
||||
key = 'text';
|
||||
value = token;
|
||||
} else if (colon < 0 && key !== 'shared' && key !== 'unused') {
|
||||
key = 'text';
|
||||
value = token;
|
||||
}
|
||||
// `near:api 2` is also accepted: a bare number after near is the hop count.
|
||||
if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) {
|
||||
value = `${value},${tokens[i + 1]}`;
|
||||
i += 1;
|
||||
}
|
||||
if (!value && key === 'text') continue;
|
||||
terms.push({ key, value, negate });
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
|
||||
|
||||
const isElement = (n: GraphNode) => n.kind === 'element';
|
||||
const isConnection = (e: GraphEdge) => e.kind === 'connection';
|
||||
|
||||
/** An element by id, then by id or label ignoring case. Decision and registry nodes are not walked from. */
|
||||
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
|
||||
const elements = graph.nodes.filter(isElement);
|
||||
const exact = elements.find((n) => n.id === ref);
|
||||
if (exact) return exact;
|
||||
const l = ref.toLowerCase();
|
||||
return elements.find((n) => n.id.toLowerCase() === l) ?? elements.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}`);
|
||||
}
|
||||
|
||||
/** Element-to-element adjacency over the diagrams' connections; decision and registry links do not count as steps. */
|
||||
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const n of graph.nodes) map.set(n.id, new Set());
|
||||
for (const e of graph.edges.filter(isConnection)) {
|
||||
map.get(e.from)?.add(e.to);
|
||||
map.get(e.to)?.add(e.from);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function within(graph: ModelGraph, start: string, hops: number): Set<string> {
|
||||
const adjacency = neighbours(graph);
|
||||
let frontier = new Set([start]);
|
||||
const seen = new Set([start]);
|
||||
for (let i = 0; i < hops; i += 1) {
|
||||
const next = new Set<string>();
|
||||
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
|
||||
frontier = next;
|
||||
if (!frontier.size) break;
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Shortest path by breadth-first search; directed first, then ignoring direction. */
|
||||
export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined {
|
||||
const attempt = (directed: boolean) => {
|
||||
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
||||
for (const n of graph.nodes) out.set(n.id, []);
|
||||
for (const e of graph.edges.filter(isConnection)) {
|
||||
out.get(e.from)?.push({ to: e.to, edge: e });
|
||||
if (!directed) out.get(e.to)?.push({ to: e.from, edge: e });
|
||||
}
|
||||
const previous = new Map<string, { from: string; edge: GraphEdge }>();
|
||||
const queue = [from];
|
||||
const seen = new Set([from]);
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (id === to) break;
|
||||
for (const step of out.get(id) ?? []) {
|
||||
if (seen.has(step.to)) continue;
|
||||
seen.add(step.to);
|
||||
previous.set(step.to, { from: id, edge: step.edge });
|
||||
queue.push(step.to);
|
||||
}
|
||||
}
|
||||
if (!seen.has(to)) return undefined;
|
||||
const nodes = [to];
|
||||
const edges: string[] = [];
|
||||
let at = to;
|
||||
while (at !== from) {
|
||||
const step = previous.get(at)!;
|
||||
edges.unshift(step.edge.id);
|
||||
nodes.unshift(step.from);
|
||||
at = step.from;
|
||||
}
|
||||
return { nodes, edges };
|
||||
};
|
||||
if (from === to) return { nodes: [from], edges: [] };
|
||||
return attempt(true) ?? attempt(false);
|
||||
}
|
||||
|
||||
export function runQuery(graph: ModelGraph, text: string): QueryResult {
|
||||
const terms = parseQuery(text);
|
||||
const everything = new Set(graph.nodes.map((n) => n.id));
|
||||
let result = new Set(everything);
|
||||
const notes: string[] = [];
|
||||
let from: string | undefined;
|
||||
let to: string | undefined;
|
||||
let pathEdges: Set<string> | undefined;
|
||||
let path: string[] | undefined;
|
||||
|
||||
const matching = (term: QueryTerm): Set<string> | undefined => {
|
||||
const v = term.value;
|
||||
switch (term.key) {
|
||||
case 'text': {
|
||||
const l = v.toLowerCase();
|
||||
return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id));
|
||||
}
|
||||
case 'type':
|
||||
return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id));
|
||||
case 'diagram':
|
||||
return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id));
|
||||
case 'decision': {
|
||||
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
|
||||
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
|
||||
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
|
||||
else ids.add(`decision:${v}`);
|
||||
return ids;
|
||||
}
|
||||
case 'shared':
|
||||
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
|
||||
case 'unused':
|
||||
return new Set(graph.nodes.filter((n) => isElement(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;
|
||||
};
|
||||
|
||||
const negated: Set<string>[] = [];
|
||||
for (const term of terms) {
|
||||
const ids = matching(term);
|
||||
if (!ids) continue;
|
||||
if (term.negate) {
|
||||
negated.push(ids);
|
||||
for (const id of ids) result.delete(id);
|
||||
} else result = new Set([...result].filter((id) => ids.has(id)));
|
||||
}
|
||||
// Decisions and registries follow their elements: a matching element brings the nodes attached to it.
|
||||
if (terms.length) {
|
||||
for (const e of graph.edges) {
|
||||
if (isConnection(e)) continue;
|
||||
if (result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from);
|
||||
}
|
||||
for (const ids of negated) for (const id of ids) result.delete(id);
|
||||
}
|
||||
|
||||
if (from || to) {
|
||||
if (!from || !to) notes.push('A path needs both from: and to:.');
|
||||
else {
|
||||
const a = findNode(graph, from);
|
||||
const b = findNode(graph, to);
|
||||
if (!a || !b) notes.push(`No element "${!a ? from : to}".`);
|
||||
else {
|
||||
const found = shortestPath(graph, a.id, b.id);
|
||||
if (!found) notes.push(`No path from ${a.id} to ${b.id}.`);
|
||||
else {
|
||||
path = found.nodes;
|
||||
pathEdges = new Set(found.edges);
|
||||
result = new Set(found.nodes.filter((id) => result.has(id)));
|
||||
}
|
||||
}
|
||||
if (!path) result = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const edges = new Set<string>();
|
||||
for (const e of graph.edges) {
|
||||
if (!result.has(e.from) || !result.has(e.to)) continue;
|
||||
if (pathEdges && !pathEdges.has(e.id)) continue;
|
||||
edges.add(e.id);
|
||||
}
|
||||
return { nodes: result, edges, path, message: notes[0], terms };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Text helpers shared by the host and the Source view in the page. The page's
|
||||
// editor always works in "\n" text; documents may use "\r\n".
|
||||
|
||||
export interface TextChange {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
/** The text with "\r\n" turned into "\n". */
|
||||
export function normalizeEol(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest single change that turns `before` into `after`: the common
|
||||
* start and end are kept, so a cursor outside the change stays put.
|
||||
*/
|
||||
export function minimalChange(before: string, after: string): TextChange | undefined {
|
||||
if (before === after) return undefined;
|
||||
let start = 0;
|
||||
const max = Math.min(before.length, after.length);
|
||||
while (start < max && before.charCodeAt(start) === after.charCodeAt(start)) start++;
|
||||
let endBefore = before.length;
|
||||
let endAfter = after.length;
|
||||
while (endBefore > start && endAfter > start && before.charCodeAt(endBefore - 1) === after.charCodeAt(endAfter - 1)) {
|
||||
endBefore--;
|
||||
endAfter--;
|
||||
}
|
||||
return { from: start, to: endBefore, insert: after.slice(start, endAfter) };
|
||||
}
|
||||
|
||||
/** Line and character of an offset into "\n" text. */
|
||||
export function positionOf(text: string, offset: number): { line: number; character: number } {
|
||||
const clamped = Math.max(0, Math.min(offset, text.length));
|
||||
let line = 0;
|
||||
let lineStart = 0;
|
||||
for (let i = text.indexOf('\n'); i !== -1 && i < clamped; i = text.indexOf('\n', i + 1)) {
|
||||
line++;
|
||||
lineStart = i + 1;
|
||||
}
|
||||
return { line, character: clamped - lineStart };
|
||||
}
|
||||
|
||||
/** Offset into "\n" text of a line and character (clamped to the line). */
|
||||
export function offsetOf(text: string, line: number, character: number): number {
|
||||
let start = 0;
|
||||
for (let l = 0; l < line; l++) {
|
||||
const next = text.indexOf('\n', start);
|
||||
if (next === -1) return text.length;
|
||||
start = next + 1;
|
||||
}
|
||||
const end = text.indexOf('\n', start);
|
||||
return Math.min(start + Math.max(0, character), end === -1 ? text.length : end);
|
||||
}
|
||||
|
||||
/** Applies changes given in the coordinates of `text` (as one editor transaction reports them). */
|
||||
export function applyChanges(text: string, changes: TextChange[]): string {
|
||||
let out = text;
|
||||
for (const change of [...changes].sort((a, b) => b.from - a.from)) {
|
||||
out = out.slice(0, change.from) + change.insert + out.slice(change.to);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+13
-16
@@ -2,6 +2,7 @@ import { layerMarkup, rasterize } from './raster';
|
||||
import { CameraState, Layers, Stage } from './stage';
|
||||
import { DiagramModel, readModel } from './svgModel';
|
||||
import { STYLE } from './style';
|
||||
import type { ArchifyHost } from '../webview/host';
|
||||
|
||||
/**
|
||||
* 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's
|
||||
@@ -12,12 +13,7 @@ import { STYLE } from './style';
|
||||
* follows Archify's animations as they play.
|
||||
*/
|
||||
|
||||
interface Host {
|
||||
defaultView: '2d' | '3d';
|
||||
post(message: unknown): void;
|
||||
getState(): Record<string, unknown>;
|
||||
setState(patch: Record<string, unknown>): void;
|
||||
}
|
||||
type Host = ArchifyHost;
|
||||
|
||||
interface SavedView {
|
||||
active?: boolean;
|
||||
@@ -25,12 +21,6 @@ interface SavedView {
|
||||
camera?: CameraState;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__archifyHost?: Host;
|
||||
}
|
||||
}
|
||||
|
||||
const REDRAW_MS = 220;
|
||||
const MARGIN = 80;
|
||||
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
|
||||
@@ -68,13 +58,20 @@ class Viewer3D {
|
||||
this.interceptViewerControls();
|
||||
if (this.saved.active ?? host.defaultView === '3d') this.setActive(true);
|
||||
else this.syncButtons();
|
||||
// The Source view hides the picture; the stage stops drawing meanwhile.
|
||||
window.addEventListener('archify-source', (event) => {
|
||||
if (!this.active || !this.stage) return;
|
||||
if ((event as CustomEvent<{ on: boolean }>).detail.on) this.stage.stop();
|
||||
else this.stage.start();
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- chrome
|
||||
|
||||
/**
|
||||
* The 2D | 3D switch: Export's twin in the toolbar. Both states are always
|
||||
* visible, the filled half is the current one, a click flips it.
|
||||
* The 2D | 3D switch in the toolbar, left of Diagram | Graph | Source (it
|
||||
* says how the picture is drawn, that one what is shown). Both states are
|
||||
* always visible, the filled half is the current one, a click flips it.
|
||||
*/
|
||||
private buildSwitch(): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
@@ -89,8 +86,8 @@ class Viewer3D {
|
||||
event.stopPropagation();
|
||||
this.setActive(!this.active);
|
||||
});
|
||||
const exportWrap = document.querySelector('.toolbar .export-wrap');
|
||||
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(button, exportWrap);
|
||||
const anchor = document.querySelector('.toolbar #archify-mode-switch') ?? document.querySelector('.toolbar .export-wrap');
|
||||
if (anchor?.parentElement) anchor.parentElement.insertBefore(button, anchor);
|
||||
else document.querySelector('.toolbar')?.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
+34
-13
@@ -6,31 +6,41 @@ import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } from '
|
||||
import { ModelService } from '../model/service';
|
||||
import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { sourceScriptUri } from '../webview/viewer3d';
|
||||
import { SourceSession } from './sourceSession';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { decisionPage, makeNonce } from '../webview/page';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { DECISION_VIEW_TYPE };
|
||||
|
||||
class DecisionDocument implements vscode.CustomDocument {
|
||||
constructor(readonly uri: vscode.Uri) {}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class DecisionView implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private last: string | undefined;
|
||||
root: vscode.Uri | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** The Source view; while it shows, a new page waits in `heldHtml`. */
|
||||
readonly source: SourceSession;
|
||||
private heldHtml: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
) {
|
||||
this.source = new SourceSession(context, panel, uri, 'decision', (shown) => {
|
||||
if (!shown && this.heldHtml !== undefined) {
|
||||
const html = this.heldHtml;
|
||||
this.heldHtml = undefined;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
});
|
||||
this.disposables.push(
|
||||
this.source,
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (this.source.handle(message)) return;
|
||||
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
|
||||
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
|
||||
@@ -86,7 +96,12 @@ class DecisionView implements vscode.Disposable {
|
||||
const fingerprint = JSON.stringify(decision);
|
||||
if (fingerprint === this.last) return;
|
||||
this.last = fingerprint;
|
||||
this.panel.webview.html = decisionPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, decision);
|
||||
const html = decisionPage(
|
||||
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), source: { scriptUri: sourceScriptUri(this.panel.webview, this.context.extensionUri), left: 'View' } },
|
||||
decision,
|
||||
);
|
||||
if (this.source.shown) this.heldHtml = html;
|
||||
else this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -95,13 +110,16 @@ class DecisionView implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */
|
||||
export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider<DecisionDocument>, vscode.Disposable {
|
||||
/** Custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */
|
||||
export class DecisionViewProvider implements vscode.CustomTextEditorProvider, vscode.Disposable {
|
||||
private readonly views = new Set<DecisionView>();
|
||||
private active: DecisionView | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly models: ModelService) {
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
@@ -122,13 +140,16 @@ export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
openCustomDocument(uri: vscode.Uri): DecisionDocument {
|
||||
return new DecisionDocument(uri);
|
||||
/** The view of the focused page, if any. */
|
||||
get activeView(): { source: SourceSession } | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
async resolveCustomEditor(document: DecisionDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
// A text editor provider: the page edits the file's own TextDocument (in
|
||||
// its Source view), so dirty state, save and undo are VS Code's.
|
||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true };
|
||||
const view = new DecisionView(this.models, panel, document.uri);
|
||||
const view = new DecisionView(this.context, this.models, panel, document.uri);
|
||||
this.views.add(view);
|
||||
const onViewState = () => {
|
||||
if (panel.active) this.active = view;
|
||||
|
||||
+153
-15
@@ -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,9 @@ 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, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
import { SourceSession } from './sourceSession';
|
||||
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
@@ -21,8 +23,18 @@ export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
|
||||
interface PreviewState {
|
||||
uri: string;
|
||||
hash?: string;
|
||||
/** main.archify only: the Graph page is shown instead of the model page. */
|
||||
graph?: boolean;
|
||||
}
|
||||
|
||||
/** What a preview asks of its manager. */
|
||||
interface PreviewHost {
|
||||
/** Opens the Graph page of the model at `root`, with a query to start from. */
|
||||
openGraph(root: vscode.Uri, query?: string): Promise<void>;
|
||||
}
|
||||
|
||||
const GRAPH_MODE_KEY = 'archify.graphMode';
|
||||
|
||||
class DiagramPreview implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private hash: string | undefined;
|
||||
@@ -34,10 +46,20 @@ 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;
|
||||
/** The Source view of a .archify file; while it shows, new HTML waits in `heldHtml`. */
|
||||
readonly source: SourceSession | undefined;
|
||||
private heldHtml: string | undefined;
|
||||
private disposed = 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,15 +67,29 @@ 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');
|
||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||
}
|
||||
|
||||
if (isArchifyFile(uri.path)) {
|
||||
this.source = new SourceSession(context, panel, uri, 'diagram', (shown) => {
|
||||
// Back from Source: show what was rendered meanwhile.
|
||||
if (!shown && this.heldHtml !== undefined) {
|
||||
const html = this.heldHtml;
|
||||
this.heldHtml = undefined;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
});
|
||||
this.disposables.push(this.source);
|
||||
}
|
||||
|
||||
this.disposables.push(
|
||||
panel.onDidDispose(() => this.dispose()),
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (this.source?.handle(message)) return;
|
||||
if (message.type === 'hash') {
|
||||
this.hash = message.hash;
|
||||
return;
|
||||
@@ -70,6 +106,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,18 +134,77 @@ 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);
|
||||
}
|
||||
|
||||
/** Replaces the page, unless Source is open in it: then the page waits until it closes. */
|
||||
private setHtml(html: string): void {
|
||||
if (this.disposed) return;
|
||||
if (this.source?.shown) {
|
||||
this.heldHtml = html;
|
||||
return;
|
||||
}
|
||||
this.heldHtml = undefined;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
/** Page option for the Source view, on .archify files. */
|
||||
private sourceOption(left: 'Model' | 'Diagram'): { scriptUri: string; left: 'Model' | 'Diagram' } | undefined {
|
||||
return this.source ? { scriptUri: sourceScriptUri(this.panel.webview, this.context.extensionUri), left } : undefined;
|
||||
}
|
||||
|
||||
private state(): PreviewState {
|
||||
return { uri: this.uri.toString(), hash: this.hash };
|
||||
return { uri: this.uri.toString(), hash: this.hash, graph: this.graph };
|
||||
}
|
||||
|
||||
private graphModes(): Record<string, boolean> {
|
||||
return this.context.workspaceState.get<Record<string, boolean>>(GRAPH_MODE_KEY, {});
|
||||
}
|
||||
|
||||
/** Switches a main.archify editor between its model page and the Graph page. */
|
||||
setGraph(on: boolean, query?: string): void {
|
||||
this.graph = on;
|
||||
this.graphQuery = query;
|
||||
const modes = this.graphModes();
|
||||
if (on) modes[this.uri.toString()] = true;
|
||||
else delete modes[this.uri.toString()];
|
||||
void this.context.workspaceState.update(GRAPH_MODE_KEY, modes);
|
||||
this.lastHtml = undefined;
|
||||
this.rendered = false;
|
||||
if (!on) this.graphShown = false;
|
||||
else if (this.graphShown && query !== undefined) this.post({ type: 'query', query });
|
||||
void this.update(true);
|
||||
}
|
||||
|
||||
/** The Graph page of the model, or an update of it when it is already showing. */
|
||||
private showGraphPage(index: Awaited<ReturnType<ModelService['index']>>): void {
|
||||
const graph = buildGraph(index);
|
||||
const fingerprint = `graph\u0000${JSON.stringify(graph)}`;
|
||||
if (fingerprint === this.lastHtml && this.graphShown) return;
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = false;
|
||||
if (this.graphShown) {
|
||||
this.post({ type: 'graph', graph });
|
||||
return;
|
||||
}
|
||||
this.graphShown = true;
|
||||
this.setHtml(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 {
|
||||
this.lastHtml = undefined;
|
||||
this.panel.webview.html = messagePage(
|
||||
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
|
||||
this.setHtml(messagePage(
|
||||
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state(), source: this.sourceOption('Diagram') },
|
||||
title,
|
||||
body,
|
||||
detail,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
/** Opens a diagram of the model, given its path relative to the model root. */
|
||||
@@ -121,7 +225,8 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (fingerprint === this.lastHtml) return;
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = false;
|
||||
this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model);
|
||||
this.graphShown = false;
|
||||
this.setHtml(modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state(), source: this.sourceOption('Model') }, model));
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
@@ -132,6 +237,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
|
||||
async update(force = false): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
if (this.disposed) return;
|
||||
const seq = ++this.seq;
|
||||
let text: string;
|
||||
try {
|
||||
@@ -146,7 +252,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 +271,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 +297,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,19 +319,25 @@ 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,
|
||||
source: this.sourceOption(main ? 'Model' : 'Diagram'),
|
||||
});
|
||||
// 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;
|
||||
this.rendered = true;
|
||||
this.panel.webview.html = html;
|
||||
this.setHtml(html);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
// Renders in flight check the sequence after every await; this ends them.
|
||||
this.seq++;
|
||||
clearTimeout(this.timer);
|
||||
this.onDisposeEmitter.fire();
|
||||
this.onDisposeEmitter.dispose();
|
||||
@@ -286,10 +405,29 @@ export class DiagramViewManager
|
||||
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
|
||||
}
|
||||
|
||||
private readonly host: PreviewHost = { openGraph: (root, query) => this.showGraph(root, query) };
|
||||
|
||||
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true));
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, document.uri, undefined, true));
|
||||
}
|
||||
|
||||
/** Opens the model's main.archify as its Graph page, with a query to start from. */
|
||||
async showGraph(root: vscode.Uri, query?: string): Promise<void> {
|
||||
const main = vscode.Uri.joinPath(root, MAIN_FILE);
|
||||
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
|
||||
const key = main.toString();
|
||||
const deadline = Date.now() + 5000;
|
||||
for (;;) {
|
||||
const editor = [...this.editors].find((e) => e.uri.toString() === key && e.panel.active) ?? [...this.editors].find((e) => e.uri.toString() === key);
|
||||
if (editor) {
|
||||
editor.setGraph(true, query);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||
@@ -304,7 +442,7 @@ export class DiagramViewManager
|
||||
{ viewColumn, preserveFocus },
|
||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||
);
|
||||
this.track(new DiagramPreview(this.context, this.models, panel, uri));
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri));
|
||||
}
|
||||
|
||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||
@@ -312,7 +450,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 {
|
||||
|
||||
+33
-12
@@ -5,31 +5,41 @@ import { RegistryPanel, registryPanelFromIndex, registryPanelStandalone } from '
|
||||
import { ModelService } from '../model/service';
|
||||
import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { sourceScriptUri } from '../webview/viewer3d';
|
||||
import { SourceSession } from './sourceSession';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, registryPage } from '../webview/page';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { REGISTRY_VIEW_TYPE };
|
||||
|
||||
class RegistryDocument implements vscode.CustomDocument {
|
||||
constructor(readonly uri: vscode.Uri) {}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class RegistryView implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private last: string | undefined;
|
||||
root: vscode.Uri | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** The Source view; while it shows, a new page waits in `heldHtml`. */
|
||||
readonly source: SourceSession;
|
||||
private heldHtml: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
) {
|
||||
this.source = new SourceSession(context, panel, uri, 'registry', (shown) => {
|
||||
if (!shown && this.heldHtml !== undefined) {
|
||||
const html = this.heldHtml;
|
||||
this.heldHtml = undefined;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
});
|
||||
this.disposables.push(
|
||||
this.source,
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (this.source.handle(message)) return;
|
||||
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
|
||||
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
|
||||
@@ -84,7 +94,12 @@ class RegistryView implements vscode.Disposable {
|
||||
const fingerprint = JSON.stringify(registry);
|
||||
if (fingerprint === this.last) return;
|
||||
this.last = fingerprint;
|
||||
this.panel.webview.html = registryPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, registry);
|
||||
const html = registryPage(
|
||||
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), source: { scriptUri: sourceScriptUri(this.panel.webview, this.context.extensionUri), left: 'View' } },
|
||||
registry,
|
||||
);
|
||||
if (this.source.shown) this.heldHtml = html;
|
||||
else this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -93,12 +108,15 @@ class RegistryView implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryViewProvider implements vscode.CustomReadonlyEditorProvider<RegistryDocument>, vscode.Disposable {
|
||||
export class RegistryViewProvider implements vscode.CustomTextEditorProvider, vscode.Disposable {
|
||||
private readonly views = new Set<RegistryView>();
|
||||
private active: RegistryView | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly models: ModelService) {
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(REGISTRY_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
@@ -121,13 +139,16 @@ export class RegistryViewProvider implements vscode.CustomReadonlyEditorProvider
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
openCustomDocument(uri: vscode.Uri): RegistryDocument {
|
||||
return new RegistryDocument(uri);
|
||||
/** The view of the focused page, if any. */
|
||||
get activeView(): { source: SourceSession } | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
async resolveCustomEditor(document: RegistryDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
// A text editor provider: the page edits the file's own TextDocument (in
|
||||
// its Source view), so dirty state, save and undo are VS Code's.
|
||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true };
|
||||
const view = new RegistryView(this.models, panel, document.uri);
|
||||
const view = new RegistryView(this.context, this.models, panel, document.uri);
|
||||
this.views.add(view);
|
||||
const onViewState = () => {
|
||||
if (panel.active) this.active = view;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EditorSchemas } from '../language/editorSchemas';
|
||||
import { readSchemas } from '../language/schemaCheck';
|
||||
import { log } from '../util/log';
|
||||
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
|
||||
/** Page → host messages of the Source view. */
|
||||
export type SourceMessage =
|
||||
| { type: 'sourceReady' }
|
||||
| { type: 'sourceOpen' }
|
||||
| { type: 'sourceClose' }
|
||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] };
|
||||
|
||||
export type SourceKind = 'diagram' | 'registry' | 'decision';
|
||||
|
||||
const CHECKS: Record<SourceKind, string> = {
|
||||
diagram: 'YAML · checked against the Archify schema and the model',
|
||||
registry: 'YAML · checked against the registry schema and the model',
|
||||
decision: 'YAML · checked as a decision of the model',
|
||||
};
|
||||
|
||||
let editorSchemas: EditorSchemas | undefined;
|
||||
|
||||
function schemasFor(context: vscode.ExtensionContext): EditorSchemas {
|
||||
editorSchemas ??= new EditorSchemas(readSchemas(vscode.Uri.joinPath(context.extensionUri, 'schemas').fsPath));
|
||||
return editorSchemas;
|
||||
}
|
||||
|
||||
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
|
||||
return message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit';
|
||||
}
|
||||
|
||||
/**
|
||||
* The host half of a page's Source view. The document stays VS Code's: page
|
||||
* edits become WorkspaceEdits (so dirty state, save, undo and Git work as
|
||||
* for any file), and changes from anywhere else (undo, the text editor, git)
|
||||
* go back to the page. Problems are whatever the Problems panel holds for the
|
||||
* file, sent as offsets; the page shows their count on the Source switch even
|
||||
* while the picture is shown.
|
||||
*/
|
||||
export class SourceSession implements vscode.Disposable {
|
||||
/** True while the page shows Source; the view must not replace its HTML then. */
|
||||
shown = false;
|
||||
/** Texts our own edits will produce, oldest first; their change events are not echoed. */
|
||||
private readonly expected: string[] = [];
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private schemaKey: string | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
readonly kind: SourceKind,
|
||||
/** Called when Source opens or closes, so the view can hold or apply its HTML. */
|
||||
private readonly onShown: (shown: boolean) => void,
|
||||
) {
|
||||
const same = (uri: vscode.Uri) => uri.toString() === this.uri.toString();
|
||||
this.disposables = [
|
||||
vscode.languages.onDidChangeDiagnostics((event) => {
|
||||
if (event.uris.some(same)) void this.postDiagnostics();
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!same(event.document.uri) || !event.contentChanges.length) return;
|
||||
this.post({ type: 'sourceDirty', dirty: event.document.isDirty });
|
||||
if (this.shown) this.syncFrom(event.document);
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Asks the page to show its Source view (Archify | Show Source). */
|
||||
reveal(): void {
|
||||
this.post({ type: 'setView', view: 'source' });
|
||||
}
|
||||
|
||||
/** Handles a Source message from the page; false for any other message. */
|
||||
handle(message: { type?: string }): boolean {
|
||||
if (!isSourceMessage(message)) return false;
|
||||
switch (message.type) {
|
||||
case 'sourceReady':
|
||||
void this.postDiagnostics();
|
||||
break;
|
||||
case 'sourceOpen':
|
||||
this.setShown(true);
|
||||
void this.sendText(true);
|
||||
break;
|
||||
case 'sourceClose':
|
||||
this.setShown(false);
|
||||
break;
|
||||
case 'sourceEdit':
|
||||
this.edit(message);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private setShown(shown: boolean): void {
|
||||
if (this.shown === shown) return;
|
||||
this.shown = shown;
|
||||
this.onShown(shown);
|
||||
}
|
||||
|
||||
private post(message: unknown): void {
|
||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||
}
|
||||
|
||||
private document(): Promise<vscode.TextDocument> {
|
||||
return Promise.resolve(vscode.workspace.openTextDocument(this.uri));
|
||||
}
|
||||
|
||||
private async sendText(withDiagnostics: boolean): Promise<void> {
|
||||
const doc = await this.document();
|
||||
const text = normalizeEol(doc.getText());
|
||||
this.expected.length = 0;
|
||||
this.schemaKey = undefined;
|
||||
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
||||
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
|
||||
if (withDiagnostics) await this.postDiagnostics();
|
||||
}
|
||||
|
||||
/** The editor schema when it differs from the one the page has (a diagram's type may change), else undefined. */
|
||||
private schemaChange(text: string): { key: string; schema: object } | null | undefined {
|
||||
const next = schemasFor(this.context).forSource(this.kind, text);
|
||||
const key = next?.key ?? '';
|
||||
if (key === this.schemaKey) return undefined;
|
||||
this.schemaKey = key;
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
private syncFrom(doc: vscode.TextDocument): void {
|
||||
const text = normalizeEol(doc.getText());
|
||||
const index = this.expected.indexOf(text);
|
||||
if (index !== -1) {
|
||||
// Our own edit coming back; anything older is settled too.
|
||||
this.expected.splice(0, index + 1);
|
||||
return;
|
||||
}
|
||||
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
||||
}
|
||||
|
||||
private edit(message: Extract<SourceMessage, { type: 'sourceEdit' }>): void {
|
||||
this.expected.push(message.text);
|
||||
const schema = this.schemaChange(message.text);
|
||||
if (schema !== undefined) this.post({ type: 'sourceSchema', schema });
|
||||
this.queue = this.queue.then(async () => {
|
||||
try {
|
||||
const doc = await this.document();
|
||||
const current = normalizeEol(doc.getText());
|
||||
if (current === message.text) return;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
// Positions come from "\n" text; VS Code turns inserted text into the document's line endings.
|
||||
const at = (text: string, offset: number) => {
|
||||
const { line, character } = positionOf(text, offset);
|
||||
return new vscode.Position(line, character);
|
||||
};
|
||||
if (current === message.base) {
|
||||
for (const change of message.changes) edit.replace(this.uri, new vscode.Range(at(current, change.from), at(current, change.to)), change.insert);
|
||||
} else {
|
||||
// The document moved on under the page (rare): the page's text wins.
|
||||
edit.replace(this.uri, new vscode.Range(at(current, 0), at(current, current.length)), message.text);
|
||||
}
|
||||
if (!(await vscode.workspace.applyEdit(edit))) {
|
||||
log().warn(`Source edit was not applied to ${this.uri.fsPath}`);
|
||||
void this.sendText(false);
|
||||
}
|
||||
} catch (error) {
|
||||
log().error(`Source edit failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Problems of the file as offsets into its "\n" text, and their count for the switch. */
|
||||
async postDiagnostics(): Promise<void> {
|
||||
const diagnostics = vscode.languages.getDiagnostics(this.uri);
|
||||
let items: unknown[] = [];
|
||||
if (this.shown && diagnostics.length) {
|
||||
const text = normalizeEol((await this.document()).getText());
|
||||
items = diagnostics.map((d) => ({
|
||||
from: offsetOf(text, d.range.start.line, d.range.start.character),
|
||||
to: offsetOf(text, d.range.end.line, d.range.end.character),
|
||||
severity: d.severity === vscode.DiagnosticSeverity.Error ? 'error' : d.severity === vscode.DiagnosticSeverity.Warning ? 'warning' : 'info',
|
||||
message: d.message,
|
||||
source: d.source,
|
||||
}));
|
||||
}
|
||||
const count = diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning).length;
|
||||
this.post({ type: 'sourceDiagnostics', items, count });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ModelGraph } from '../model/graph';
|
||||
|
||||
/**
|
||||
* What the bridge script (page.ts) exposes to the bundled page scripts as
|
||||
* `window.__archifyHost`: they cannot call acquireVsCodeApi a second time.
|
||||
*/
|
||||
export interface ArchifyHost {
|
||||
defaultView: '2d' | '3d';
|
||||
post(message: unknown): void;
|
||||
getState(): Record<string, unknown>;
|
||||
setState(patch: Record<string, unknown>): void;
|
||||
/** The Graph page's data, present on that page only. */
|
||||
graph?: { graph: ModelGraph; query?: string };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__archifyHost?: ArchifyHost;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export type WebviewMessage =
|
||||
| { type: 'openDiagram'; path: string }
|
||||
| { type: 'openRegistry'; file: string; line?: number }
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -96,6 +98,8 @@ export async function handleWebviewMessage(
|
||||
break;
|
||||
case 'hash':
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
|
||||
+164
-11
@@ -26,8 +26,15 @@ 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 };
|
||||
/** Pages of a file that can be edited in place: the Source view's script, and the name of the page's own view. */
|
||||
source?: { scriptUri: string; left: 'Model' | 'Diagram' | 'View' };
|
||||
/** 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 +94,9 @@ function bridgeScript(options: PageOptions): string {
|
||||
decision: options.decision,
|
||||
decisions: options.decisions || [],
|
||||
descriptions: options.descriptions || {},
|
||||
graphSwitch: options.graphSwitch,
|
||||
source: options.source,
|
||||
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
|
||||
};
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
@@ -110,7 +120,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,9 +679,97 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
}
|
||||
|
||||
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
||||
// main.archify, View | Source on registry and decision pages). It sits
|
||||
// left of Export in Archify's toolbar, after 2D | 3D, which only says how
|
||||
// the picture is drawn; on our own pages it sits top right. Graph opens the
|
||||
// model's Graph page; Source puts the file's YAML in place of the picture.
|
||||
var sourceOn = false;
|
||||
var syncModeSwitch = function () {};
|
||||
function renderModeSwitch() {
|
||||
var sw = config.graphSwitch;
|
||||
var src = config.source;
|
||||
if ((!sw && !src) || document.getElementById('archify-mode-switch')) return;
|
||||
var group = el('div', 'archify-mode-switch');
|
||||
group.id = 'archify-mode-switch';
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', 'View');
|
||||
function segment(label, title, action) {
|
||||
var button = el('button', 'archify-mode-seg', label);
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.addEventListener('click', function (event) { event.stopPropagation(); action(); });
|
||||
group.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
var name = (src && src.left) || sw.left;
|
||||
var left = segment(name, name === 'View' ? 'Show the page' : 'Show the ' + name.toLowerCase(), function () { setSource(false); });
|
||||
if (sw) segment('Graph', 'Open the model as one graph, with a query box', function () { post({ type: 'showGraph', query: sw.query }); });
|
||||
var source = src ? segment('Source', 'Edit the YAML of this file, checked as you type', function () { setSource(true); }) : null;
|
||||
if (source) {
|
||||
var badge = el('span', 'archify-source-badge');
|
||||
badge.id = 'archify-source-badge';
|
||||
badge.hidden = true;
|
||||
source.appendChild(badge);
|
||||
}
|
||||
syncModeSwitch = function () {
|
||||
left.classList.toggle('on', !sourceOn);
|
||||
left.setAttribute('aria-pressed', String(!sourceOn));
|
||||
if (source) {
|
||||
source.classList.toggle('on', sourceOn);
|
||||
source.setAttribute('aria-pressed', String(sourceOn));
|
||||
}
|
||||
};
|
||||
syncModeSwitch();
|
||||
var exportWrap = document.querySelector('.toolbar .export-wrap');
|
||||
var head = document.querySelector('.archify-model-head');
|
||||
if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(group, exportWrap);
|
||||
else {
|
||||
var row = el('div', 'archify-model-switch-row');
|
||||
row.appendChild(group);
|
||||
if (head) head.insertBefore(row, head.firstChild);
|
||||
else document.body.insertBefore(row, document.body.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// The Source view lives in dist/source.js, loaded the first time it is chosen.
|
||||
function setSource(on) {
|
||||
if (!config.source || on === sourceOn) return;
|
||||
sourceOn = on;
|
||||
vscode.setState(Object.assign({}, vscode.getState() || {}, { sourceView: on }));
|
||||
syncModeSwitch();
|
||||
if (!on) {
|
||||
if (window.__archifySource) window.__archifySource.close();
|
||||
return;
|
||||
}
|
||||
if (window.__archifySource) {
|
||||
window.__archifySource.open();
|
||||
return;
|
||||
}
|
||||
var script = document.createElement('script');
|
||||
script.src = config.source.scriptUri;
|
||||
script.onload = function () { if (sourceOn && window.__archifySource) window.__archifySource.open(); };
|
||||
script.onerror = function () {
|
||||
sourceOn = false;
|
||||
syncModeSwitch();
|
||||
post({ type: 'error', message: 'The Source view could not be loaded.' });
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function showProblemCount(count) {
|
||||
var badge = document.getElementById('archify-source-badge');
|
||||
if (!badge) return;
|
||||
badge.hidden = !count;
|
||||
badge.textContent = count ? String(count) : '';
|
||||
badge.title = count ? count + (count === 1 ? ' problem' : ' problems') + ' in the source' : '';
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
||||
if (message.type === 'setView') setSource(message.view === 'source');
|
||||
});
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
@@ -682,6 +781,12 @@ function bridgeScript(options: PageOptions): string {
|
||||
onReady(renderConnectors);
|
||||
onReady(renderDecisionsRail);
|
||||
onReady(describeElements);
|
||||
onReady(renderModeSwitch);
|
||||
if (config.source) {
|
||||
// The host answers with the problem count for the Source badge.
|
||||
post({ type: 'sourceReady' });
|
||||
if ((vscode.getState() || {}).sourceView) onReady(function () { setSource(true); });
|
||||
}
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
@@ -696,6 +801,8 @@ const MODEL_STYLE = `
|
||||
.archify-model { --m-text: var(--text, var(--vscode-foreground, CanvasText)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground, GrayText)); --m-panel: var(--panel, var(--vscode-editorWidget-background, Canvas)); --m-border: var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); --m-accent: var(--frontend-stroke, var(--vscode-textLink-foreground));
|
||||
max-width: 1400px; margin: 0 auto; padding: 1.25rem 2rem; color: var(--m-text); font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-model-head { padding-top: 1.5rem; }
|
||||
/* Under Archify's fixed toolbar (diagram pages of main.archify), start below it. */
|
||||
.toolbar ~ .archify-model-head { padding-top: 4.25rem; }
|
||||
.archify-model-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-accent); }
|
||||
.archify-model-name { margin: 0.25rem 0 0.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.archify-model-description { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.55; color: var(--m-muted); white-space: pre-line; }
|
||||
@@ -778,6 +885,23 @@ 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; color: var(--text-muted, var(--vscode-descriptionForeground)); 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); }
|
||||
/* Anchored on the id: Archify's .toolbar button rules (one of them with :not(#btn-theme)) would win otherwise. */
|
||||
#archify-mode-switch .archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border: 0; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; font-weight: 550; font-size: 0.8125rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
#archify-mode-switch .archify-mode-seg.on[aria-pressed="true"] { 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 .archify-mode-seg:hover:not(.on) { color: var(--text, var(--vscode-foreground)); }
|
||||
#archify-mode-switch .archify-mode-seg:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 1px; }
|
||||
.archify-source-badge { display: inline-block; min-width: 1rem; margin-left: 0.35rem; padding: 0 0.3rem; border-radius: 999px; background: var(--security-stroke, var(--vscode-editorError-foreground, #f14c4c)); color: var(--bg, #fff); font-size: 0.6875rem; font-weight: 700; line-height: 1rem; text-align: center; }
|
||||
.archify-source-badge[hidden] { display: none; }
|
||||
.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; }
|
||||
body > .archify-model-switch-row { position: relative; z-index: 2; margin: 1rem 1.25rem 0 1rem; }
|
||||
.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); border-radius: 0.75rem; background: var(--m-panel, var(--vscode-editorWidget-background)); 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 +944,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
const head =
|
||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
||||
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
||||
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${SWITCH_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
|
||||
@@ -847,8 +971,8 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
}
|
||||
|
||||
/** The model page for a main.archify without a diagram of its own. */
|
||||
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {} });
|
||||
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, model: ModelPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' }, source: options.source });
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -859,6 +983,27 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${MODEL_STYLE}
|
||||
${SWITCH_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** The Graph page of a model: the bundled script builds everything from the host's graph data. */
|
||||
export function graphPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, graph: NonNullable<PageOptions['graph']>): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph });
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
||||
<script nonce="${options.nonce}">${bridge}</script>
|
||||
<script nonce="${options.nonce}" defer src="${escapeHtml(graph.scriptUri)}"></script>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${NOTICE_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
@@ -866,8 +1011,8 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
|
||||
}
|
||||
|
||||
/** The registry page for one .archify-registry file. */
|
||||
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {} });
|
||||
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, registry: RegistryPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {}, source: options.source });
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -878,6 +1023,8 @@ export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' |
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${MODEL_STYLE}
|
||||
${SWITCH_STYLE}
|
||||
${NOTICE_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
@@ -885,8 +1032,8 @@ export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' |
|
||||
}
|
||||
|
||||
/** The decision page for one .archify-decision file. */
|
||||
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
|
||||
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, decision: DecisionPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {}, source: options.source });
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -897,28 +1044,34 @@ export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' |
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${MODEL_STYLE}
|
||||
${SWITCH_STYLE}
|
||||
${NOTICE_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
/** A small VS Code-styled page for loading and error states; with `source`, the YAML can be fixed in place. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>,
|
||||
title: string,
|
||||
body: string,
|
||||
detail?: string,
|
||||
): string {
|
||||
const script = options.source
|
||||
? bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, source: options.source })
|
||||
: `(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
||||
<script nonce="${options.nonce}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
||||
<script nonce="${options.nonce}">${script}</script>
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
||||
${options.source ? SWITCH_STYLE + NOTICE_STYLE : ''}
|
||||
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
||||
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
||||
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// The package's "./yaml" export needs "bundler" module resolution; this
|
||||
// repository resolves like Node 10, so point at its types directly.
|
||||
declare module 'codemirror-json-schema/yaml' {
|
||||
export * from 'codemirror-json-schema/dist/yaml/index';
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
||||
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state';
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
hoverTooltip,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { stateExtensions, updateSchema } from 'codemirror-json-schema';
|
||||
import { yamlCompletion, yamlSchemaHover } from 'codemirror-json-schema/yaml';
|
||||
import type { TextChange } from '../../util/textDiff';
|
||||
|
||||
/** A problem from the host, as offsets into the "\n" text it checked. */
|
||||
export interface SourceDiagnostic {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** Marks transactions that carry text from the host, so they are not sent back. */
|
||||
const fromHost = Annotation.define<boolean>();
|
||||
|
||||
// The page's own variables (Archify's, or ours on model pages), so the
|
||||
// editor follows the page theme, light or dark.
|
||||
const theme = EditorView.theme({
|
||||
'&': { height: '100%', color: 'var(--text, var(--vscode-editor-foreground))', backgroundColor: 'transparent', fontSize: '12.5px' },
|
||||
'.cm-scroller': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, "SF Mono", monospace)', lineHeight: '1.6' },
|
||||
'.cm-content': { caretColor: 'var(--frontend-stroke, var(--vscode-editorCursor-foreground))' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--frontend-stroke, var(--vscode-editorCursor-foreground))' },
|
||||
'.cm-gutters': { backgroundColor: 'transparent', color: 'var(--text-muted, var(--vscode-editorLineNumber-foreground))', border: 'none' },
|
||||
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'color-mix(in srgb, var(--text, var(--vscode-editor-foreground)) 5%, transparent)' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--frontend-stroke, var(--vscode-editor-selectionBackground)) 28%, transparent) !important',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: 'var(--panel, var(--vscode-editorHoverWidget-background))',
|
||||
color: 'var(--text, var(--vscode-editorHoverWidget-foreground))',
|
||||
border: '1px solid var(--panel-border, var(--vscode-editorHoverWidget-border))',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '440px',
|
||||
},
|
||||
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
||||
'.cm-diagnostic': { padding: '4px 8px' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
||||
});
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [t.propertyName, t.definition(t.propertyName)], color: 'var(--frontend-stroke, var(--vscode-symbolIcon-propertyForeground, #22d3ee))' },
|
||||
{ tag: [t.number, t.bool, t.null, t.atom], color: 'var(--cloud-stroke, var(--vscode-debugTokenExpression-number, #fbbf24))' },
|
||||
{ tag: [t.comment, t.lineComment], color: 'var(--text-muted, var(--vscode-descriptionForeground))', fontStyle: 'italic' },
|
||||
{ tag: [t.punctuation, t.separator, t.squareBracket, t.brace], color: 'var(--text-muted, var(--vscode-descriptionForeground))' },
|
||||
{ tag: [t.keyword, t.typeName, t.labelName, t.meta], color: 'var(--database-stroke, var(--vscode-symbolIcon-keywordForeground, #a78bfa))' },
|
||||
]);
|
||||
|
||||
export class SourceEditor {
|
||||
readonly view: EditorView;
|
||||
private schemaKey: string | undefined;
|
||||
|
||||
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
|
||||
this.view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
// Undo and redo are the editor's own: VS Code sends ⌘Z in a focused
|
||||
// webview to the webview, not to the document. Every step still
|
||||
// reaches the document as an edit. Save and find stay VS Code's
|
||||
// (no search panel here).
|
||||
history(),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap, ...lintKeymap, indentWithTab]),
|
||||
EditorState.tabSize.of(2),
|
||||
indentUnit.of(' '),
|
||||
yaml(),
|
||||
theme,
|
||||
syntaxHighlighting(highlight),
|
||||
lintGutter(),
|
||||
stateExtensions(undefined as never),
|
||||
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
||||
hoverTooltip(yamlSchemaHover()),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
||||
const changes: TextChange[] = [];
|
||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
||||
onEdit(changes, update.state.doc.toString());
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
return this.view.state.doc.toString();
|
||||
}
|
||||
|
||||
/** Text from the host (an edit elsewhere, git): one change, cursor kept, not undoable here. */
|
||||
applyFromHost(change: TextChange): void {
|
||||
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
||||
}
|
||||
|
||||
/** The schema that completion and hover read; `undefined` turns them off. */
|
||||
setSchema(key: string | undefined, schema: object | undefined): void {
|
||||
if (key === this.schemaKey) return;
|
||||
this.schemaKey = key;
|
||||
updateSchema(this.view, schema as never);
|
||||
}
|
||||
|
||||
/** The host's problems for the text it checked; the editor maps them through later typing. */
|
||||
setDiagnostics(items: SourceDiagnostic[]): void {
|
||||
const length = this.view.state.doc.length;
|
||||
const diagnostics = items
|
||||
.filter((d) => d.from <= length)
|
||||
.map((d) => ({ from: d.from, to: Math.min(Math.max(d.to, d.from), length), severity: d.severity, message: d.message, source: d.source }));
|
||||
this.view.dispatch(setDiagnostics(this.view.state, diagnostics));
|
||||
}
|
||||
|
||||
/** Moves the cursor to the next problem and opens its message. */
|
||||
nextProblem(): void {
|
||||
this.view.focus();
|
||||
nextDiagnostic(this.view);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
requestMeasure(): void {
|
||||
this.view.requestMeasure();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ArchifyHost } from '../host';
|
||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||
import { SourceDiagnostic, SourceEditor } from './editor';
|
||||
|
||||
/**
|
||||
* The Source view of an Archify page: the file's YAML in an editor, in the
|
||||
* page under its toolbar and heading, in place of the picture. The bridge
|
||||
* script (page.ts) loads this file the first time Source is chosen and calls
|
||||
* window.__archifySource.open(); the document itself stays in the host, which
|
||||
* applies every edit to it and sends back problems and outside changes.
|
||||
*/
|
||||
|
||||
/** Host → page. */
|
||||
type HostMessage =
|
||||
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
||||
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
||||
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
||||
| { type: 'sourceDirty'; dirty: boolean };
|
||||
|
||||
const STYLE = `
|
||||
html.archify-source-on .container > :not(.header):not(#archify-source),
|
||||
html.archify-source-on body > :not(.toolbar):not(.container):not(.archify-model-head):not(.archify-model-switch-row):not(#archify-source):not(script):not(style):not(.archify-modal-backdrop),
|
||||
html.archify-source-on #btn-3d,
|
||||
html.archify-source-on .archify-vscode-notice { display: none !important; }
|
||||
.archify-source { display: flex; flex-direction: column; box-sizing: border-box; min-height: 260px; margin: 0.75rem 0 1rem; }
|
||||
body > .archify-source { max-width: 1400px; margin: 0.75rem auto 1rem; padding: 0 2rem; }
|
||||
.archify-source-editor { flex: 1; min-height: 0; overflow: hidden; border: 1px solid var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--panel, var(--vscode-editor-background)) 70%, transparent); }
|
||||
.archify-source-editor .cm-editor { height: 100%; }
|
||||
.archify-source-strip { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; align-items: center; padding: 0.45rem 0.25rem 0; font-size: 0.75rem;
|
||||
color: var(--text-muted, var(--vscode-descriptionForeground)); font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-source-strip .is-ok { color: var(--backend-stroke, var(--vscode-testing-iconPassed, #34d399)); }
|
||||
.archify-source-strip .is-bad { color: var(--security-stroke, var(--vscode-editorError-foreground, #fb7185)); }
|
||||
.archify-source-strip button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
.archify-source-strip button:hover { text-decoration: underline; }
|
||||
.archify-source-strip .archify-source-hint { margin-left: auto; }
|
||||
.archify-source-strip kbd { font: inherit; font-size: 0.6875rem; padding: 0 0.3rem; border: 1px solid currentColor; border-radius: 0.25rem; opacity: 0.8; }
|
||||
`;
|
||||
|
||||
const MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
function isUndoRedo(event: KeyboardEvent): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
return (event.metaKey || event.ctrlKey) && !event.altKey && (key === 'z' || key === 'y');
|
||||
}
|
||||
|
||||
class SourcePane {
|
||||
private root: HTMLElement | undefined;
|
||||
private editorHost!: HTMLElement;
|
||||
private stateEl!: HTMLElement;
|
||||
private countEl!: HTMLButtonElement;
|
||||
private checksEl!: HTMLElement;
|
||||
private editor: SourceEditor | undefined;
|
||||
/** The text the host last knew the page to have; edits are sent against it. */
|
||||
private text = '';
|
||||
private diagnostics: SourceDiagnostic[] = [];
|
||||
private shown = false;
|
||||
|
||||
constructor(private readonly host: ArchifyHost) {
|
||||
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
||||
window.addEventListener('resize', () => this.fit());
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.build();
|
||||
this.shown = true;
|
||||
document.documentElement.classList.add('archify-source-on');
|
||||
window.dispatchEvent(new CustomEvent('archify-source', { detail: { on: true } }));
|
||||
this.fit();
|
||||
this.host.post({ type: 'sourceOpen' });
|
||||
this.editor?.focus();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.shown = false;
|
||||
document.documentElement.classList.remove('archify-source-on');
|
||||
window.dispatchEvent(new CustomEvent('archify-source', { detail: { on: false } }));
|
||||
this.host.post({ type: 'sourceClose' });
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
if (this.root) return;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = STYLE;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const root = document.createElement('section');
|
||||
root.id = 'archify-source';
|
||||
root.className = 'archify-source';
|
||||
root.setAttribute('aria-label', 'Source');
|
||||
this.editorHost = document.createElement('div');
|
||||
this.editorHost.className = 'archify-source-editor';
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'archify-source-strip';
|
||||
this.stateEl = document.createElement('span');
|
||||
this.countEl = document.createElement('button');
|
||||
this.countEl.type = 'button';
|
||||
this.countEl.title = 'Go to the next problem';
|
||||
this.countEl.addEventListener('click', () => this.editor?.nextProblem());
|
||||
this.checksEl = document.createElement('span');
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'archify-source-hint';
|
||||
hint.innerHTML = 'Completion as you type, or <kbd>Ctrl</kbd>+<kbd>Space</kbd> · hover a key for its description';
|
||||
strip.append(this.stateEl, this.countEl, this.checksEl, hint);
|
||||
root.append(this.editorHost, strip);
|
||||
this.renderState(false);
|
||||
this.renderCount();
|
||||
|
||||
// Archify's page has single-key shortcuts; typing here must not reach
|
||||
// them. Keys with ⌘ or Ctrl go on to VS Code (save, find, quick open),
|
||||
// except undo and redo, which the editor has handled already.
|
||||
for (const type of ['keydown', 'keypress', 'keyup'] as const) {
|
||||
root.addEventListener(type, (event) => {
|
||||
if (!event.metaKey && !event.ctrlKey) event.stopPropagation();
|
||||
else if (isUndoRedo(event)) event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
const header = document.querySelector('.container > .header');
|
||||
const modelHead = document.querySelector('.archify-model-head');
|
||||
if (header?.parentElement) header.parentElement.insertBefore(root, header.nextSibling);
|
||||
else if (modelHead?.parentElement) modelHead.parentElement.insertBefore(root, modelHead.nextSibling);
|
||||
else document.body.appendChild(root);
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/** The editor fills the window below where it starts. */
|
||||
private fit(): void {
|
||||
if (!this.root || !this.shown) return;
|
||||
const top = this.root.getBoundingClientRect().top;
|
||||
this.root.style.height = `${Math.max(260, window.innerHeight - Math.max(top, 0) - 12)}px`;
|
||||
this.editor?.requestMeasure();
|
||||
}
|
||||
|
||||
private onMessage(message: HostMessage): void {
|
||||
if (!message || typeof message !== 'object') return;
|
||||
switch (message.type) {
|
||||
case 'sourceText':
|
||||
this.build();
|
||||
if (!this.editor) {
|
||||
this.text = message.text;
|
||||
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
|
||||
this.editor.setDiagnostics(this.diagnostics);
|
||||
this.fit();
|
||||
if (this.shown) this.editor.focus();
|
||||
} else {
|
||||
const change = minimalChange(this.editor.text, message.text);
|
||||
if (change) this.editor.applyFromHost(change);
|
||||
this.text = message.text;
|
||||
}
|
||||
if (message.schema !== undefined) this.editor.setSchema(message.schema?.key, message.schema?.schema);
|
||||
this.checksEl.textContent = message.checks;
|
||||
return;
|
||||
case 'sourceDiagnostics':
|
||||
this.diagnostics = message.items;
|
||||
this.editor?.setDiagnostics(message.items);
|
||||
this.renderCount();
|
||||
return;
|
||||
case 'sourceSchema':
|
||||
this.editor?.setSchema(message.schema?.key, message.schema?.schema);
|
||||
return;
|
||||
case 'sourceDirty':
|
||||
this.renderState(message.dirty);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private onEdit(changes: TextChange[], text: string): void {
|
||||
const base = this.text;
|
||||
this.text = text;
|
||||
this.host.post({ type: 'sourceEdit', base, text, changes });
|
||||
}
|
||||
|
||||
private renderState(dirty: boolean): void {
|
||||
if (!this.stateEl) return;
|
||||
this.stateEl.innerHTML = dirty ? `● Unsaved · <kbd>${MAC ? '⌘' : 'Ctrl+'}S</kbd> saves` : 'Saved';
|
||||
}
|
||||
|
||||
private renderCount(): void {
|
||||
if (!this.countEl) return;
|
||||
const problems = this.diagnostics.filter((d) => d.severity !== 'info').length;
|
||||
this.countEl.className = problems ? 'is-bad' : 'is-ok';
|
||||
this.countEl.textContent = problems ? `✕ ${problems} problem${problems === 1 ? '' : 's'}` : '✓ No problems';
|
||||
this.countEl.disabled = !problems;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__archifySource?: { open(): void; close(): void };
|
||||
}
|
||||
}
|
||||
|
||||
const host = window.__archifyHost;
|
||||
if (host) window.__archifySource = new SourcePane(host);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Stands in for codemirror-json-schema's utils/markdown, which brings
|
||||
// markdown-it and shiki (about 400 KB) to format schema descriptions. Ours
|
||||
// only use `code` spans: escape the text and keep those. esbuild.mjs swaps
|
||||
// this file in.
|
||||
const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
||||
|
||||
export function renderMarkdown(markdown: string, inline = true): string {
|
||||
const html = escape(String(markdown)).replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
return inline ? html : `<p>${html.replace(/\n{2,}/g, '</p><p>')}</p>`;
|
||||
}
|
||||
@@ -13,3 +13,13 @@ 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();
|
||||
}
|
||||
|
||||
/** The Source view script (a webview URI), loaded by the page the first time Source is chosen. */
|
||||
export function sourceScriptUri(webview: vscode.Webview, extensionUri: vscode.Uri): string {
|
||||
return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'source.js')).toString();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { EditorSchemas, bundleSchema } from '../src/language/editorSchemas';
|
||||
import { readSchemas } from '../src/language/schemaCheck';
|
||||
|
||||
const schemas = readSchemas(path.resolve(__dirname, '..', '..', 'schemas'));
|
||||
|
||||
/** Every "$ref" in a bundled schema must point inside it. */
|
||||
function resolveAll(schema: object): void {
|
||||
const visit = (node: unknown): void => {
|
||||
if (Array.isArray(node)) return node.forEach(visit);
|
||||
if (!node || typeof node !== 'object') return;
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === '$ref') {
|
||||
assert.match(String(value), /^#/, `local ref: ${value}`);
|
||||
let target: unknown = schema;
|
||||
for (const part of String(value).slice(1).split('/').filter(Boolean)) {
|
||||
target = (target as Record<string, unknown>)[part.replace(/~1/g, '/').replace(/~0/g, '~')];
|
||||
assert.ok(target !== undefined, `${value} resolves`);
|
||||
}
|
||||
} else visit(value);
|
||||
}
|
||||
};
|
||||
visit(schema);
|
||||
}
|
||||
|
||||
test('each schema bundles into one self-contained document', () => {
|
||||
for (const name of Object.keys(schemas)) {
|
||||
const bundled = bundleSchema(schemas, name);
|
||||
resolveAll(bundled);
|
||||
const text = JSON.stringify(bundled);
|
||||
assert.ok(!text.includes('"prefixItems"'), `${name}: no prefixItems`);
|
||||
assert.ok(!text.includes('"$schema"'), `${name}: no $schema`);
|
||||
}
|
||||
});
|
||||
|
||||
test('points become draft-07 tuples', () => {
|
||||
const architecture = bundleSchema(schemas, 'architecture.schema.json') as { $defs: Record<string, { $defs: Record<string, unknown> }> };
|
||||
const point = architecture.$defs['file:common.schema.json'].$defs.point as Record<string, unknown>;
|
||||
assert.deepEqual(point.items, [{ type: 'number' }, { type: 'number' }]);
|
||||
assert.equal(point.additionalItems, false);
|
||||
});
|
||||
|
||||
test('a diagram gets the schema of its diagram_type, a decision none', () => {
|
||||
const editor = new EditorSchemas(schemas);
|
||||
assert.equal(editor.forSource('diagram', 'schema_version: 1\ndiagram_type: sequence\n')?.key, 'sequence.schema.json');
|
||||
assert.equal(editor.forSource('diagram', 'diagram_type: "workflow"\n')?.key, 'workflow.schema.json');
|
||||
assert.equal(editor.forSource('diagram', 'meta:\n title: x\n')?.key, 'archify.schema.json');
|
||||
assert.equal(editor.forSource('diagram', 'diagram_type: poster\n')?.key, 'archify.schema.json');
|
||||
assert.equal(editor.forSource('registry', '')?.key, 'archify-registry.schema.json');
|
||||
assert.equal(editor.forSource('decision', ''), undefined);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { mergeDecisions, parseDecision } from '../src/model/decisions';
|
||||
import { DecisionAttachment, mergeRegistries, parseRegistry, summarizeDiagram } from '../src/model/format';
|
||||
import { ModelGraph, buildGraph } from '../src/model/graph';
|
||||
import { parseQuery, runQuery, shortestPath } from '../src/model/graphQuery';
|
||||
|
||||
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
|
||||
const REGISTRY_FILES = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
|
||||
const DIAGRAM_FILES = ['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify'];
|
||||
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
|
||||
|
||||
function graphOfModel(): ModelGraph {
|
||||
const registry = mergeRegistries(REGISTRY_FILES.map((file) => ({ file, ...parseRegistry(read(file), file) })));
|
||||
const diagrams = DIAGRAM_FILES.map((file) => summarizeDiagram(read(file), file));
|
||||
const decisions = mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
|
||||
const decisionUsage = new Map<string, { path: string; attachedTo: DecisionAttachment }[]>();
|
||||
for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]);
|
||||
return buildGraph({ meta: { id: 'online-shop', name: 'Online Shop' }, registry, diagrams, decisions, decisionUsage });
|
||||
}
|
||||
|
||||
test('diagram summaries carry nodes and edges, shared and inline', () => {
|
||||
const api = summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify');
|
||||
assert.deepEqual(api.nodes.map((n) => n.id), ['web', 'auth', 'orders', 'catalogue', 'db', 'queue']);
|
||||
assert.equal(api.nodes[0].ref, 'web');
|
||||
assert.equal(api.nodes[1].ref, undefined);
|
||||
assert.equal(api.nodes[1].type, 'security');
|
||||
assert.equal(api.edges.length, 6);
|
||||
assert.deepEqual(api.edges[0], { from: 'web', to: 'auth', label: 'sign in' });
|
||||
const broken = summarizeDiagram('diagram_type: [', 'x.archify');
|
||||
assert.deepEqual(broken.nodes, []);
|
||||
assert.deepEqual(broken.edges, []);
|
||||
});
|
||||
|
||||
test('the model graph unites every diagram: elements once, connections once, labels merged', () => {
|
||||
const graph = graphOfModel();
|
||||
// 11 registry elements + 3 inline components of the API diagram, plus 3 decision and 3 registry nodes.
|
||||
assert.equal(graph.nodes.filter((n) => n.kind === 'element').length, 14);
|
||||
assert.equal(graph.nodes.length, 20);
|
||||
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.filter((e) => e.kind === 'connection').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('decisions and registries are nodes of their own, linked to the elements they are about or define', () => {
|
||||
const graph = graphOfModel();
|
||||
const decision = graph.nodes.find((n) => n.id === 'decision:queue-between-api-and-worker')!;
|
||||
assert.equal(decision.kind, 'decision');
|
||||
assert.equal(decision.type, 'decision');
|
||||
assert.equal(decision.status, 'accepted');
|
||||
assert.equal(decision.sublabel, 'queue-between-api-and-worker');
|
||||
assert.deepEqual(graph.edges.filter((e) => e.kind === 'decides' && e.from === decision.id).map((e) => e.to).sort(), ['api', 'queue', 'worker']);
|
||||
const registry = graph.nodes.find((n) => n.id === 'registry:registry/containers.archify-registry')!;
|
||||
assert.equal(registry.kind, 'registry');
|
||||
assert.equal(registry.label, 'registry/containers');
|
||||
assert.deepEqual(registry.elements, ['web', 'api', 'worker', 'queue', 'db', 'cache']);
|
||||
assert.equal(graph.edges.filter((e) => e.kind === 'defines' && e.from === registry.id).length, 6);
|
||||
// Walks and paths do not go through them: a registry is not a shortcut between its elements.
|
||||
assert.deepEqual(shortestPath(graph, 'customer', 'cache')?.nodes, ['customer', 'web', 'api', 'cache']);
|
||||
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
|
||||
assert.ok(!ids('uses:api').some((id) => id.startsWith('registry:') && id !== 'registry:registry/containers.archify-registry'));
|
||||
// They follow their elements into a result, and a negative term removes them again.
|
||||
assert.deepEqual(ids('type:database').filter((id) => id.includes(':')), ['decision:opensearch-for-product-search', 'decision:postgres-as-source-of-truth', 'registry:registry/containers.archify-registry']);
|
||||
assert.deepEqual(ids('type:database -type:decision -type:registry'), ['cache', 'db']);
|
||||
assert.deepEqual(ids('type:registry'), ['registry:registry/containers.archify-registry', 'registry:registry/people.archify-registry', 'registry:registry/systems.archify-registry']);
|
||||
assert.ok(ids('decision:queue-between-api-and-worker').includes('decision:queue-between-api-and-worker'));
|
||||
});
|
||||
|
||||
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.filter((n) => n.kind === 'element').map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]);
|
||||
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost', 'registry:r']);
|
||||
});
|
||||
|
||||
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(' '), []);
|
||||
});
|
||||
|
||||
const elementIds = (graph: ModelGraph, q: string) => [...runQuery(graph, q).nodes].filter((id) => !id.includes(':')).sort();
|
||||
|
||||
test('queries narrow the graph: text, type, diagram, decision, shared', () => {
|
||||
const graph = graphOfModel();
|
||||
const ids = (q: string) => elementIds(graph, q);
|
||||
assert.equal(runQuery(graph, '').nodes.size, 20);
|
||||
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) => elementIds(graph, q);
|
||||
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].filter((id) => !id.includes(':')).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) => { const at = id.indexOf('>', id.startsWith('decision:') || id.startsWith('registry:') ? id.indexOf(':') : 0); return result.nodes.has(id.slice(0, at)) && result.nodes.has(id.slice(at + 1)); }));
|
||||
});
|
||||
@@ -61,9 +61,18 @@ export async function run(): Promise<void> {
|
||||
assert.equal(vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(), false, 'no text editor was opened for it');
|
||||
});
|
||||
|
||||
await check('.archify files are YAML and Show Source opens the text beside the diagram', async () => {
|
||||
await check('Show Source keeps the diagram in its one tab (the page switches to Source)', async () => {
|
||||
const uri = workspaceFile('web-platform.archify');
|
||||
await vscode.commands.executeCommand('archify.showSource');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
assert.equal(vscode.window.visibleTextEditors.some((e) => e.document.uri.toString() === uri.toString()), false, 'no text editor was opened');
|
||||
const tabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs).filter((t) => t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === uri.toString());
|
||||
assert.equal(tabs.length, 1);
|
||||
});
|
||||
|
||||
await check('.archify files are YAML and Open in Text Editor opens the text beside the diagram', async () => {
|
||||
const uri = workspaceFile('web-platform.archify');
|
||||
await vscode.commands.executeCommand('archify.openInTextEditor');
|
||||
const editor = await waitFor('source text editor', () =>
|
||||
vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()),
|
||||
);
|
||||
@@ -317,14 +326,30 @@ export async function run(): Promise<void> {
|
||||
assert.ok(tab);
|
||||
});
|
||||
|
||||
await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => {
|
||||
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 Open in Text Editor opens the YAML', async () => {
|
||||
const registry = workspaceFile('model/registry/people.archify-registry');
|
||||
await vscode.commands.executeCommand('vscode.open', registry);
|
||||
const tab = await waitFor('registry view tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === registry.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
await vscode.commands.executeCommand('archify.showSource');
|
||||
await vscode.commands.executeCommand('archify.openInTextEditor');
|
||||
const editor = await waitFor('registry text editor', () => vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === registry.toString()));
|
||||
assert.equal(editor.document.languageId, 'yaml');
|
||||
});
|
||||
@@ -349,6 +374,30 @@ export async function run(): Promise<void> {
|
||||
await vscode.workspace.fs.delete(bad);
|
||||
});
|
||||
|
||||
await check('Archify | New Registry… creates a registry file and opens the registry view; creators are ordered', async () => {
|
||||
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes as { commands: { command: string }[]; menus: Record<string, { command: string; group: string }[]> };
|
||||
const creators = commands.menus['explorer/context'].filter((m) => /^archify\.new/.test(m.command));
|
||||
assert.deepEqual(creators.map((m) => m.command), ['archify.newModel', 'archify.newRegistry', 'archify.newDiagram', 'archify.newDecision']);
|
||||
assert.deepEqual(creators.map((m) => m.group), ['zzz_archify@1', 'zzz_archify@2', 'zzz_archify@3', 'zzz_archify@4']);
|
||||
const folder = workspaceFile('model/registry');
|
||||
const window = vscode.window as unknown as Record<string, unknown>;
|
||||
const originalInput = window.showInputBox;
|
||||
window.showInputBox = async () => 'partners';
|
||||
try {
|
||||
await vscode.commands.executeCommand('archify.newRegistry', folder);
|
||||
} finally {
|
||||
window.showInputBox = originalInput;
|
||||
}
|
||||
const file = vscode.Uri.joinPath(folder, 'partners.archify-registry');
|
||||
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(file));
|
||||
assert.match(text, /^elements:/m);
|
||||
const tab = await waitFor('registry view tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === file.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
await vscode.workspace.fs.delete(file);
|
||||
});
|
||||
|
||||
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
|
||||
const folder = workspaceFile('new-shop');
|
||||
await vscode.workspace.fs.createDirectory(folder);
|
||||
@@ -419,4 +468,45 @@ export async function run(): Promise<void> {
|
||||
const errors = vscode.languages.getDiagnostics(containers.uri).filter((d) => d.severity === vscode.DiagnosticSeverity.Error);
|
||||
assert.deepEqual(errors.map((d) => d.message), []);
|
||||
});
|
||||
|
||||
await check('a registry is checked against its schema without a YAML extension', async () => {
|
||||
const target = workspaceFile('model/registry/typo.archify-registry');
|
||||
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\nelements:\n billing:\n typ: backend\n label: Billing\n'));
|
||||
const doc = await vscode.workspace.openTextDocument(target);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const unknown = await waitFor('unknown key diagnostic', () =>
|
||||
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown key "typ" in elements\.billing/.test(d.message)),
|
||||
);
|
||||
assert.equal(doc.getText(unknown.range), 'typ');
|
||||
assert.ok(vscode.languages.getDiagnostics(doc.uri).some((d) => /"type" is required in elements\.billing/.test(d.message)));
|
||||
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
|
||||
await vscode.workspace.fs.delete(target);
|
||||
});
|
||||
|
||||
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
|
||||
for (const [file, viewType] of [
|
||||
['model/registry/containers.archify-registry', 'archify.registry'],
|
||||
['model/decisions/0001-postgres-as-source-of-truth.archify-decision', 'archify.decision'],
|
||||
] as const) {
|
||||
const uri = workspaceFile(file);
|
||||
await vscode.commands.executeCommand('vscode.openWith', uri, viewType);
|
||||
await waitFor(`${viewType} tab`, () =>
|
||||
vscode.window.tabGroups.activeTabGroup.activeTab?.input instanceof vscode.TabInputCustom &&
|
||||
(vscode.window.tabGroups.activeTabGroup.activeTab.input as vscode.TabInputCustom).uri.toString() === uri.toString(),
|
||||
);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const before = doc.getText();
|
||||
// What the Source view does with a keystroke: a WorkspaceEdit on the document.
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(uri, new vscode.Position(0, 0), '# edited in Source\n');
|
||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||
assert.ok(doc.isDirty, `${file} is dirty`);
|
||||
const tab = vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||
assert.ok(tab?.isDirty, 'the page tab shows the dirty dot');
|
||||
// ⌘Z in the page reaches VS Code as the undo command.
|
||||
await vscode.commands.executeCommand('undo');
|
||||
await waitFor('undo', () => doc.getText() === before);
|
||||
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -141,3 +141,10 @@ test('summaries and groups: root first, then folders', () => {
|
||||
assert.deepEqual(groups.map((g) => g.group), ['', '0100-context', '0300-components']);
|
||||
assert.deepEqual(groups[0].diagrams.map((d) => d.relativePath), ['broken.archify', 'checkout.archify']);
|
||||
});
|
||||
|
||||
test('a registry YAML error is reported at its line, not the first', () => {
|
||||
const { problems } = parseRegistry('schema_version: 1\nelements:\n api:\n type: backend\n sub\n label: API\n', 'r.archify-registry');
|
||||
const yaml = problems.find((p) => /not valid YAML/.test(p.message));
|
||||
assert.ok(yaml, 'a YAML problem');
|
||||
assert.ok((yaml.line ?? 0) >= 3, `reported on line ${(yaml.line ?? 0) + 1}`);
|
||||
});
|
||||
+27
-1
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { prepareDiagramHtml } from '../src/webview/page';
|
||||
import { decisionPage, messagePage, prepareDiagramHtml, registryPage } from '../src/webview/page';
|
||||
|
||||
const page = `<!DOCTYPE html><html><head>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src *">
|
||||
@@ -61,3 +61,29 @@ test('saved view state is merged, not replaced, when the hash changes', () => {
|
||||
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark', state: { uri: 'file:///a.archify' } });
|
||||
assert.match(html, /vscode\.setState\(Object\.assign\(\{\}, vscode\.getState\(\) \|\| \{\}, config\.state, \{ hash: location\.hash \}\)\)/);
|
||||
});
|
||||
|
||||
test('a page with Source carries its script and the name of its own view', () => {
|
||||
const html = prepareDiagramHtml(page, {
|
||||
cspSource: 'x',
|
||||
nonce: 'n',
|
||||
theme: 'dark',
|
||||
graphSwitch: { left: 'Diagram', query: 'diagram:a.archify' },
|
||||
source: { scriptUri: 'https://file+.vscode-resource/ext/dist/source.js', left: 'Diagram' },
|
||||
});
|
||||
assert.match(html, /"source":\{"scriptUri":"https:\/\/file\+\.vscode-resource\/ext\/dist\/source\.js","left":"Diagram"\}/);
|
||||
// Loaded on first use, not with the page.
|
||||
assert.ok(!/<script[^>]+source\.js/.test(html));
|
||||
assert.match(html, /post\(\{ type: 'sourceReady' \}\)/);
|
||||
});
|
||||
|
||||
test('registry, decision and message pages offer View | Source when given the script', () => {
|
||||
const source = { scriptUri: 'https://x/dist/source.js', left: 'View' as const };
|
||||
const registry = registryPage({ cspSource: 'x', nonce: 'n', source }, { file: 'r.archify-registry', inModel: false, problems: [], elements: [] } as never);
|
||||
const decision = decisionPage({ cspSource: 'x', nonce: 'n', source }, { file: 'd.archify-decision', inModel: false, problems: [], supersededBy: [] } as never);
|
||||
const message = messagePage({ cspSource: 'x', nonce: 'n', source: { ...source, left: 'Diagram' } }, 'Archify could not render this diagram', 'Fix it');
|
||||
for (const html of [registry, decision, message]) {
|
||||
assert.match(html, /"source":\{"scriptUri":"https:\/\/x\/dist\/source\.js"/);
|
||||
assert.match(html, /\.archify-mode-seg/, 'switch styles');
|
||||
}
|
||||
assert.ok(!messagePage({ cspSource: 'x', nonce: 'n' }, 'Rendering…', '').includes('"source"'), 'no Source without the option');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { DIAGRAM_SCHEMA, REGISTRY_SCHEMA, SchemaChecker, readSchemas, readablePointer } from '../src/language/schemaCheck';
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const checker = new SchemaChecker(readSchemas(path.join(root, 'schemas')));
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.archify(-registry)?$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const schemaFor = (file: string) => (file.endsWith('-registry') ? REGISTRY_SCHEMA : DIAGRAM_SCHEMA);
|
||||
|
||||
test('every shipped .archify and registry file passes the schema check', () => {
|
||||
const files = ['examples', 'templates', path.join('test', 'fixtures')].flatMap((dir) => walk(path.join(root, dir)));
|
||||
assert.ok(files.length > 30, `found ${files.length} files`);
|
||||
for (const file of files) {
|
||||
const problems = checker.check(fs.readFileSync(file, 'utf8'), schemaFor(file));
|
||||
assert.deepEqual(problems.map((p) => p.message), [], path.relative(root, file));
|
||||
}
|
||||
});
|
||||
|
||||
function problemsAfter(file: string, from: string, to: string) {
|
||||
const original = fs.readFileSync(path.join(root, file), 'utf8');
|
||||
assert.ok(original.includes(from), `${file} contains ${from}`);
|
||||
const text = original.replace(from, to);
|
||||
return checker.check(text, schemaFor(file)).map((p) => ({ ...p, text: text.slice(p.from, p.to) }));
|
||||
}
|
||||
|
||||
const DIAGRAM = 'templates/models/c4-standard-web-app/0200-containers/web-app.archify';
|
||||
const REGISTRY = 'templates/models/c4-standard-web-app/registry/containers.archify-registry';
|
||||
|
||||
test('an unknown key is placed on the key', () => {
|
||||
const [problem, ...rest] = problemsAfter(DIAGRAM, 'label: SQL', 'lable: SQL');
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(problem.text, 'lable');
|
||||
assert.match(problem.message, /^Unknown key "lable" in connections\[\d+\]\.$/);
|
||||
});
|
||||
|
||||
test('a point with one number is placed on the whole flow list', () => {
|
||||
const problems = problemsAfter(DIAGRAM, 'pos: [690, 160]', 'pos: [690]');
|
||||
assert.equal(problems.length, 1);
|
||||
assert.equal(problems[0].text, '[690]');
|
||||
assert.match(problems[0].message, /pos needs at least 2 items\.$/);
|
||||
});
|
||||
|
||||
test('registry: a misspelt type key, a bad type, a bad id', () => {
|
||||
const typo = problemsAfter(REGISTRY, ' type: database', ' typ: database');
|
||||
assert.deepEqual(typo.map((p) => p.text).sort(), ['db', 'typ']);
|
||||
assert.ok(typo.some((p) => p.text === 'typ' && /Unknown key "typ"/.test(p.message)));
|
||||
assert.ok(typo.some((p) => p.text === 'db' && /"type" is required in elements\.db/.test(p.message)));
|
||||
|
||||
const bad = problemsAfter(REGISTRY, ' type: cloud', ' type: bucket');
|
||||
assert.equal(bad.length, 1);
|
||||
assert.equal(bad[0].text, 'bucket');
|
||||
assert.match(bad[0].message, /must be one of: frontend, backend, database, cloud, security, messagebus, external\.$/);
|
||||
});
|
||||
|
||||
test('a YAML syntax error is reported once, as YAML', () => {
|
||||
const problems = problemsAfter(REGISTRY, 'label: API', 'label: [API');
|
||||
assert.equal(problems.length, 1);
|
||||
assert.equal(problems[0].kind, 'yaml');
|
||||
assert.match(problems[0].message, /^YAML: /);
|
||||
});
|
||||
|
||||
test('pointers read like paths', () => {
|
||||
assert.equal(readablePointer('/connections/4/label'), 'connections[4].label');
|
||||
assert.equal(readablePointer('/elements/db'), 'elements.db');
|
||||
assert.equal(readablePointer(''), 'the file');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyChanges, minimalChange, normalizeEol, offsetOf, positionOf } from '../src/util/textDiff';
|
||||
|
||||
test('minimalChange keeps the common start and end', () => {
|
||||
assert.equal(minimalChange('abc', 'abc'), undefined);
|
||||
assert.deepEqual(minimalChange('label: SQL', 'lable: SQL'), { from: 3, to: 5, insert: 'le' });
|
||||
assert.deepEqual(minimalChange('a\nb\n', 'a\nx\nb\n'), { from: 2, to: 2, insert: 'x\n' });
|
||||
assert.deepEqual(minimalChange('aaa', 'aa'), { from: 2, to: 3, insert: '' });
|
||||
assert.deepEqual(minimalChange('', 'new'), { from: 0, to: 0, insert: 'new' });
|
||||
});
|
||||
|
||||
test('minimalChange round-trips through applyChanges', () => {
|
||||
const pairs = [['schema_version: 1\nmeta:\n title: A\n', 'schema_version: 1\nmeta:\n title: B\n output: b.html\n'], ['x', ''], ['', 'y'], ['abab', 'ab']];
|
||||
for (const [before, after] of pairs) {
|
||||
const change = minimalChange(before, after)!;
|
||||
assert.equal(applyChanges(before, [change]), after);
|
||||
}
|
||||
});
|
||||
|
||||
test('applyChanges takes several changes in the coordinates of the original text', () => {
|
||||
assert.equal(applyChanges('one two three', [{ from: 0, to: 3, insert: '1' }, { from: 8, to: 13, insert: '3' }]), '1 two 3');
|
||||
});
|
||||
|
||||
test('positions and offsets in "\\n" text', () => {
|
||||
const text = 'ab\ncde\n\nf';
|
||||
assert.deepEqual(positionOf(text, 0), { line: 0, character: 0 });
|
||||
assert.deepEqual(positionOf(text, 4), { line: 1, character: 1 });
|
||||
assert.deepEqual(positionOf(text, 7), { line: 2, character: 0 });
|
||||
assert.deepEqual(positionOf(text, 99), { line: 3, character: 1 });
|
||||
for (let offset = 0; offset <= text.length; offset++) {
|
||||
const { line, character } = positionOf(text, offset);
|
||||
assert.equal(offsetOf(text, line, character), offset);
|
||||
}
|
||||
assert.equal(offsetOf(text, 0, 50), 2, 'clamped to the end of the line');
|
||||
assert.equal(offsetOf(text, 9, 0), text.length);
|
||||
});
|
||||
|
||||
test('normalizeEol', () => {
|
||||
assert.equal(normalizeEol('a\r\nb\nc\r\n'), 'a\nb\nc\n');
|
||||
});
|
||||
Reference in new issue
Block a user