Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dde9ffffe | ||
|
|
f6f59fae67 | ||
|
|
0752128789 | ||
|
|
517697197e | ||
|
|
c71296b01a | ||
|
|
86a8adf03b | ||
|
|
6153c7f145 | ||
|
|
885977c4aa | ||
|
|
95c6e60199 | ||
|
|
b54304fef8 | ||
|
|
8407c9a956 | ||
|
|
c2d33780f9 | ||
|
|
90c7be06de | ||
|
|
7d2d55543e | ||
|
|
c262bb06a7 | ||
|
|
63f0633daa | ||
|
|
ef272e4d96 | ||
|
|
f6097b1d00 | ||
|
|
9ccd67b2dd | ||
|
|
d02c331155 | ||
|
|
c99f3e5fe8 | ||
|
|
a9021312b0 | ||
|
|
2726a66113 | ||
|
|
a5a9f92e75 | ||
|
|
268c75cfae | ||
|
|
27d4794ff8 | ||
|
|
5bcd34b839 | ||
|
|
69b87833b7 | ||
|
|
058c0d17a8 | ||
|
|
8398c44ca8 | ||
|
|
60ad7479fd | ||
|
|
bc9f55e2fa | ||
|
|
27d1b4381c | ||
|
|
82dd10253b | ||
|
|
91fcde9fa5 | ||
|
|
17e07adb33 | ||
|
|
3ed2d7eed8 | ||
|
|
99ea076571 | ||
|
|
c8645b228b | ||
|
|
265f8c57f6 | ||
|
|
c66e819abe | ||
|
|
2ba66c1ee2 | ||
|
|
ebf320a023 | ||
|
|
33ebdd6328 | ||
|
|
9684464630 | ||
|
|
094ca2925f | ||
|
|
de1a57e4c6 | ||
|
|
4382730343 | ||
|
|
b9676ece83 | ||
|
|
36bccb69a5 | ||
|
|
38cdc7604f | ||
|
|
57914d435c | ||
|
|
7a2b661afe | ||
|
|
701ffc7607 | ||
|
|
17ca07d9ba | ||
|
|
66a60bada6 | ||
|
|
c71d2e40b9 | ||
|
|
fb7d7706aa | ||
|
|
97b3b5f685 | ||
|
|
20786f4b5a | ||
|
|
0b536f3e10 | ||
|
|
bc294dc6bd | ||
|
|
1a5482b4bf | ||
|
|
ac9200ccdd | ||
|
|
9871f774f4 | ||
|
|
ca3f08bac9 | ||
|
|
86f6d5c668 | ||
|
|
789ccb2d5c | ||
|
|
adc5cdbe97 |
No files matched your search
+137
@@ -1,5 +1,142 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.9 — 2026-11-08
|
||||
|
||||
- The Sync button is square again on the model, registry, decision and Graph pages (0.10.8 squeezed it narrow there).
|
||||
|
||||
## 0.10.8 — 2026-11-08
|
||||
|
||||
- **A model opens as its outline.** On `main.archify` the top-level panels (Shared, Diagrams, Registries, Decisions) start collapsed; open the one you need. What you open or close is kept while the page is open.
|
||||
- **Sync button in line on every page.** On the model, registry and decision pages it sits next to Model | Graph | Source instead of floating above it, and on the Graph page it comes after Export PNG instead of covering it. It takes the height of the controls beside it.
|
||||
|
||||
## 0.10.7 — 2026-11-08
|
||||
|
||||
- **Model | Graph | Source looks like 2D | 3D.** The selected view is the same inset pill, the same height as the other toolbar controls.
|
||||
- The Sync button of a shared model is green in high contrast themes too.
|
||||
|
||||
## 0.10.6 — 2026-11-08
|
||||
|
||||
- **Sync sign-in comes back by itself.** The browser now returns the sign-in to a one-off port on this machine (`127.0.0.1`) instead of a `vscode://` link. Browsers open such links only after a click, so when you were already signed in to the identity provider the editor kept waiting. Remote and web editors still use the link.
|
||||
|
||||
## 0.10.5 — 2026-11-08
|
||||
|
||||
- **Sync button fixed.** It is now an icon only (a cloud; green with a broadcast icon once the model is shared), the same size on every page and theme. On a diagram it sits in the toolbar after **Export** instead of over it; in narrow windows and high contrast themes it no longer shrinks or loses its colour.
|
||||
|
||||
## 0.10.4 — 2026-11-07
|
||||
|
||||
- **A real Sync button.** Every Archify page (diagram, model, graph, registry, decision, and in Source) shows a **Sync** button top right. It opens the Sync menu: sign in, share the model, join one, set the server address. On a model that is shared it turns green and reads **Shared**.
|
||||
- Removed the small title-bar icon and the status bar entry shown before anything was shared, with their settings `archify.sync.titleButton` and `archify.sync.statusBar`. A shared model still shows its live state in the status bar.
|
||||
|
||||
## 0.10.3 — 2026-11-07
|
||||
|
||||
- **Sync button in the title bar.** Every Archify page (and `.archify` text editors) has a **Sync** button top right, next to *Open in Text Editor*: sign in, share a model, join one, set the server address. The status bar entry stays for those who show the status bar. `archify.sync.titleButton: false` hides the button.
|
||||
|
||||
## 0.10.2 — 2026-11-07
|
||||
|
||||
- Fixed: going back from **Source** to **Model**, **Diagram** or **View** left the YAML editor on the page under the picture when nothing had been edited. It now hides at once.
|
||||
|
||||
## 0.10.1 — 2026-11-06
|
||||
|
||||
- **Archify Sync is easier to find.** In workspaces with Archify files, **Archify Sync** sits in the status bar. Click it for a menu: sign in, share a model, join one, set the server address, open the settings. It shows the server you are signed in to, and the live state once a model is shared. `archify.sync.statusBar: false` hides it; nothing else changes without a server.
|
||||
- **Server address as a setting.** `archify.sync.server` (empty by default, for example `https://archify.example.com`) is used by sign-in, share and the menu without asking. In the workspace settings it gives a whole project the same server. *Archify | Set Sync Server Address…* sets it.
|
||||
|
||||
## 0.10.0 — 2026-11-06
|
||||
|
||||
- **Archify Sync (optional).** Several people edit one model at once through a server you run yourself ([archify-vscode-ext-sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync)); the files stay ordinary files on each disk. Commands: *Sign in to Sync Server…*, *Share Model…*, *Copy Invite Link*, *Join Shared Model…*, *Publish Version…*, *Versions…*, *Stop Syncing Model…*. Sign-in through the browser (PKCE), sessions in VS Code's secret storage, an *Archify Sync* account provider. Edits from editors, pages, Git and agents go out at once; others' edits arrive in open editors with undo intact. Everything is kept on the machine first, so work goes on offline and merges later. Teammates' cursors show as named carets in the Source view and in VS Code's text editors, and stay on their word while others type. The status bar shows who is here; the model page gets a **Shared** section with people, the file each has open, and the latest versions with **Compare** and **Restore**.
|
||||
- Without a server nothing changes: the sync code is a separate bundle that loads only when a model in the workspace is shared, after a sign-in, or when a sync command or invite link is used.
|
||||
|
||||
## 0.9.3 — 2026-11-04
|
||||
|
||||
- Fixed: on a `main.archify` page in Source, **Graph** did nothing, and **Model** then showed the Graph page instead of the model. Graph now leaves Source and opens at once, and **Model** on the Graph page comes back to the model, not to Source.
|
||||
|
||||
## 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.
|
||||
- **Diagrams point at decisions** with `decisions: [<id>]` on the diagram, on a component, node or participant, and on a connection, edge, flow, transition or message. The key is stripped before Archify renders. Unknown ids, and the key outside a model, are reported in Problems. Completion, hover and go-to-definition for decision ids; in decision files the same for `elements`, `diagrams` and `supersedes`.
|
||||
- **Where they show.** A **Decisions** panel on the `main.archify` page (one list, live decisions first, superseded and rejected dimmed and last). A **Decisions** rail panel under a diagram with the decisions it points at or that name it; hovering a row lights up the node or connection it is attached to, clicking opens the decision. A decision view when a `.archify-decision` file is opened: status, date, context, decision, consequences, related decisions, the elements as cards, the diagrams with where the decision is attached. Decisions also appear on element cards and in the element dialog.
|
||||
- **Authoring.** *Archify | New Decision…* on a folder inside a model creates a decision file from a template, with the id taken from the title. Editor schema for decision files and the new `decisions` key. A decision outside a model opens, but the page and Problems say it has lost its model.
|
||||
- The three New Model examples carry decisions. Model page: the registry files panel is titled **Registries**.
|
||||
|
||||
## 0.7.1 — 2026-10-19
|
||||
|
||||
- **No generated cards.** The cards 0.7.0 generated under model diagrams from registry descriptions are gone; they repeated the Elements panel. The description now shows as the tooltip of the element's row in the Elements panel. Hand-written `cards:` are unchanged.
|
||||
- **Model page, Registry panel:** lists the registry files of the model, one collapsible header per file with its element count and an **Open** button that opens the registry view; expand a file to see its elements as cards. Before, all elements of all files were shown in one grid.
|
||||
|
||||
## 0.7.0 — 2026-10-19
|
||||
|
||||
- **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used.
|
||||
- **Registry:** `*.archify-registry` files define shared elements once; diagrams draw them with `ref: <id>` plus position, and may override `label`, `sublabel` and `tag`. Hover, go-to-definition and completion on `ref`; unknown names, forbidden overrides, duplicate ids and broken registries in Problems. Registry edits re-render open diagrams.
|
||||
- **Registry view:** `.archify-registry` files open as their elements (cards). Clicking an element opens a dialog with its description, the registry line that defines it and the diagrams that use it; the same dialog opens from the registry cards on the model page. *Show Source* opens the YAML beside the view.
|
||||
- **Elements and Connectors:** the node index under a diagram is titled *Elements*; a new *Connectors* panel lists every connection (from → to, label), highlights it on hover and keeps it highlighted on click.
|
||||
- **Model page panels:** Diagrams and Registry are collapsible panels; inside Diagrams every folder has its own collapsible header above its diagram cards. Open or closed is remembered.
|
||||
- **Cards:** a model diagram without `cards` of its own gets Archify cards from the registry descriptions of the elements it draws; the model page shows the registry as the same cards, each with the diagrams it is used in.
|
||||
- **Links:** `diagram:` on a registry element or a component points at the diagram that explains it; double-click the node (2D view) to open it.
|
||||
- **New Model…:** right-click a folder and choose *Archify | New Model…* (also in *File → New File…* and the Command Palette) to start from one of three complete C4 examples: **C4 - Standard Web App**, **C4 - E-Shop**, **C4 - Event-Based Integration**. Each creates a model folder with `main.archify`, a registry and context, container and component diagrams, named after the folder you choose, and opens the model page.
|
||||
- New commands *Archify | Open Model* (editor title, Command Palette and the Explorer menu of `.archify` and `.archify-registry` files); *New Diagram… → Model (main.archify)* template for an empty model; editor schema for registries and the new keys; example model in `examples/online-shop`.
|
||||
|
||||
## 0.6.2 — 2026-10-19
|
||||
|
||||
- **Sponsor button:** the extension now declares a sponsor link, so VS Code's Extensions view and the Marketplace page show **Sponsor** (Buy Me a Coffee). The README has a Support section with the same link.
|
||||
|
||||
## 0.6.1 — 2026-10-12
|
||||
|
||||
- The 3D menu is replaced by a **2D | 3D** switch next to **Export**: the filled half is the view you are in, one click flips it.
|
||||
- The 3D-only controls moved to the bottom-right dock next to **PATH** and **LENS**: **STACK** (levels by kind, on or off), **TOP** (from above), and the zoom stepper's middle button reads **RESET** in 3D instead of a 2D zoom level.
|
||||
- Nothing is greyed out any more, and no menu stays open over the diagram after a choice.
|
||||
|
||||
## 0.6.0 — 2026-10-11
|
||||
|
||||
- **3D view:** a **3D ▾** menu in the diagram toolbar, styled like Archify's own menus, switches any diagram to 3D. Nodes become raised blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Works for all five diagram kinds, in every theme and style.
|
||||
- The menu also holds **Levels** (*Flat*, or *Stacked* by kind) and **Camera** (*Reset view*, *From above*).
|
||||
- **Animation follows 2D:** the 3D view mirrors Archify's own animations live: the trace when the diagram opens (replayed when 3D is chosen), node pulses, the hover trace, and the Semantic Passport, Path and Lens flows. *Live* off and reduced motion keep it still.
|
||||
- In 3D, clicking a block focuses it as in 2D (Semantic Passport, fading); hover shows the one-hop trace; trackpad scrolling pans, pinch and the mouse wheel zoom; the zoom buttons and <kbd>+</kbd>/<kbd>-</kbd>/<kbd>0</kbd> move the camera; **Export → 3D view** saves a PNG.
|
||||
- **Performance:** the 3D view draws only when something moves (no GPU use while idle), uses lighter textures and pixel density, and the hidden 2D diagram is no longer painted. Hover and animations never rebuild textures.
|
||||
- New setting `archify.defaultView` (`2d` or `3d`). Each open diagram remembers its view, camera and levels.
|
||||
- The source code is reorganised by job (`commands/`, `views/`, `webview/`, `archify/`, `sources/`, `language/`, `util/`); no change in behaviour.
|
||||
|
||||
## 0.5.2 — 2026-10-10
|
||||
|
||||
- **Right-click menus:** the Archify commands (Archify | New Diagram…, Archify | Open Preview, Archify | Render to HTML File…, Archify | Open in HTML Viewer) now sit in their own section at the very bottom of the Explorer and editor-tab menus, instead of between New File, New Folder and the other built-in entries.
|
||||
- **All commands are named "Archify | …"**, the same in right-click menus, the editor title bar and the Command Palette: Archify | New Diagram… (was *New Diagram Archify…*), Archify | Open Preview, Archify | Open Preview to the Side, Archify | Render to HTML File…, Archify | Open in HTML Viewer, Archify | Open in Browser, Archify | Refresh Preview, Archify | Show Source.
|
||||
|
||||
## 0.5.1 — 2026-10-07
|
||||
|
||||
- **Clearer descriptions:** the Marketplace description, README and messages now say plainly that diagrams are `.archify` files in YAML. Archify's own JSON files (`*.architecture.json`, …) and rendered HTML are described as also supported, in their own section.
|
||||
- The rendered-HTML editor is now called **Archify HTML Viewer** (was *Archify Diagram Viewer*, the same name as the extension), and its command **Open in Archify HTML Viewer**.
|
||||
- Messages: "open a .archify diagram first"; an empty or broken `.archify` file asks for valid YAML, not JSON.
|
||||
|
||||
## 0.5.0 — 2026-10-04
|
||||
|
||||
- **Brand completion:** after `brand:` in `.archify` files and Archify JSON sources, the editor suggests the 107 built-in brand IDs with their product names. Aliases and product names match too (`kafka` → `apache-kafka`, `postgres` → `postgresql`).
|
||||
- **Unknown brands are underlined on their own line.** Archify reports these without a location, so they used to appear at the top of the file.
|
||||
|
||||
## 0.4.1 — 2026-10-04
|
||||
|
||||
- The new-diagram command is now titled **New Diagram Archify…** everywhere, including the Explorer folder menu, where it read *New Diagram…*.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
# Archify Diagram Viewer
|
||||
|
||||
View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Click a `.archify` file (YAML) and the interactive diagram opens straight away; open its source beside it and the diagram follows your edits. Rendered Archify HTML files open in a viewer tab instead of a browser.
|
||||
Create and view [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. You write a diagram as a **`.archify` file in YAML**; clicking it opens the interactive diagram, and the diagram follows your edits as you type.
|
||||
|
||||

|
||||
|
||||
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
||||
|
||||
Free and open source. If it saves you time, you can [buy me a coffee](https://buymeacoffee.com/root.at.skic) ☕
|
||||
|
||||
## Supported files
|
||||
|
||||
| File | Format | What the extension does |
|
||||
|---|---|---|
|
||||
| **`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. |
|
||||
|
||||
New diagrams should be `.archify` files; everything below describes them unless it says otherwise.
|
||||
|
||||
## Quick start
|
||||
|
||||
The fastest start: right-click a folder in the Explorer and choose **New Diagram Archify…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
|
||||
The fastest start: right-click a folder in the Explorer and choose **Archify | New Diagram…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
|
||||
|
||||
For a whole set of diagrams, choose **Archify | New Model…** instead and pick one of the examples (C4 - Standard Web App, C4 - E-Shop, C4 - Event-Based Integration). A model folder with context, container and component diagrams and a shared registry is created and its model page opens. See [Models](#models).
|
||||
|
||||
To write one by hand:
|
||||
|
||||
@@ -30,42 +44,126 @@ 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).
|
||||
|
||||
## The `.archify` format
|
||||
|
||||
A `.archify` file is an Archify diagram source written in **YAML**. It has the same fields as the JSON the Archify CLI renders, and plain JSON content still works, since JSON is valid YAML. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`.
|
||||
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's own JSON naming, `name.architecture.json`, `name.workflow.json` and so on, works too. Those open as text with a preview button, and get schema help from VS Code's built-in JSON support.
|
||||
`.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.
|
||||
|
||||
## Archify's JSON files
|
||||
|
||||
The Archify CLI and the AI agents that use it write diagrams as JSON, named `name.architecture.json`, `name.workflow.json` and so on. The extension supports these too: they open as text with a preview button in the editor title (<kbd>Cmd/Ctrl+K V</kbd> to the side, <kbd>Cmd/Ctrl+Shift+V</kbd> in place), get validation in Problems, and get schema completion from VS Code's built-in JSON support. Any other `.json` file that declares an Archify `diagram_type` previews the same way.
|
||||
|
||||
## Features
|
||||
|
||||
- **New diagram from a template**: *New Diagram Archify…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
|
||||
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
|
||||
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
|
||||
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram 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 source.
|
||||
- **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files.
|
||||
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
|
||||
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
|
||||
- **Model graph**: the **Graph** switch on any page of a model shows the whole model as one graph, with a query box (`type:database`, `uses:api`, `from:web to:db`, …), a side panel per element and PNG export. See [Models](#models).
|
||||
- **Diagram editor**: `.archify` files open as the rendered diagram. **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, 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: 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**: *New Diagram Archify…*, *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 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.
|
||||
|
||||

|
||||
|
||||
The 3D view is built from the diagram Archify draws, so it looks the same: each node becomes a raised block whose top is the node as Archify draws it, relationships become pipes along Archify's routes with their colours, dashes and arrowheads, and the canvas, frames, lanes and legend form the floor. It works for all five diagram kinds, in every theme and style (Classic, Signal Flow, Blueprint, Editorial).
|
||||
|
||||
While 3D is on, three controls join the bottom-right dock next to **PATH** and **LENS**:
|
||||
|
||||
- **STACK** lifts nodes into levels by kind: clients and external systems on top, security below them, services in the middle, data on the floor. Click again for the flat layout.
|
||||
- **TOP** looks straight down, like 2D.
|
||||
- **RESET** (the middle of the zoom stepper, or <kbd>0</kbd>) flies back to the whole diagram, wherever you have moved to.
|
||||
|
||||
Moving around:
|
||||
|
||||
- Drag to turn, right-drag to move. On a trackpad, scroll with two fingers to move in any direction and pinch to zoom; a mouse wheel zooms. The dock's **−** and **+** and the <kbd>-</kbd> and <kbd>+</kbd> keys work too, beyond the 2D limits.
|
||||
- Click a block to focus it, as in 2D: the Semantic Passport opens and unrelated parts fade. Hover a block to see its one-hop trace. Double-click a block to fly to it, or empty space to show everything.
|
||||
|
||||
Animation follows 2D exactly, because the 3D view reads Archify's own animations as they play: the trace (relationships drawing in, nodes pulsing) when the diagram opens or 3D is chosen, the hover trace, and the flows from the Semantic Passport, Path and Lens. *Live* off, or reduced motion, keeps it still.
|
||||
|
||||
**Export → 3D view** saves the current 3D view as a PNG. Each open diagram remembers whether it was in 3D, its camera and its levels; set `archify.defaultView` to `3d` to open diagrams in 3D.
|
||||
|
||||
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
|
||||
|
||||
## Models
|
||||
|
||||
A single `.archify` file is one diagram. A folder with a **`main.archify`** at its top is a **model**: a set of diagrams that belong together, of any kind, with shared elements defined once. Diagrams inside a model still open, render and export on their own.
|
||||
|
||||
```
|
||||
online-shop/
|
||||
main.archify id, name, description; optionally an overview diagram
|
||||
registry/
|
||||
people.archify-registry shared elements: one name for one thing
|
||||
systems.archify-registry
|
||||
containers.archify-registry
|
||||
decisions/
|
||||
0002-queue-between-api-and-worker.archify-decision
|
||||
0100-context/online-shop.archify
|
||||
0200-containers/online-shop.archify
|
||||
0300-components/api.archify
|
||||
```
|
||||
|
||||
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registries**, one collapsible header per registry file (with an **Open** button for its view) and the file's elements as cards inside, each with the diagrams it is used in. Open or closed is remembered per page.
|
||||
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems.
|
||||
- **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. **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`.
|
||||
- **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).
|
||||
|
||||
## Working together (Archify Sync)
|
||||
|
||||
Optional. Without a sync server the extension works exactly as described above and loads none of this.
|
||||
|
||||
[Archify Sync](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync) is a small server you run yourself (one container and PostgreSQL; a Helm chart is included). With it, a model is edited by several people at once, like a shared document, and its files stay ordinary files on each person's disk, so Git, agents and every page of this extension keep working.
|
||||
|
||||
- **Where.** Every Archify page (diagram, model, graph, registry, decision, also in Source) has a Sync button (a cloud) top right; on a diagram it sits after **Export**. It opens the Sync menu: sign in, share the model, join one, the server address, settings. On a shared model it turns green with a broadcast icon. The same menu is *Archify | Sync…* in the Command Palette.
|
||||
- **Server address.** Set `archify.sync.server` once (Settings › Archify › Sync, or *Archify | Set Sync Server Address…*), for example `https://archify.example.com`. Put it in the workspace settings and everyone in the project uses the same server. Left empty, the address is asked for once and remembered.
|
||||
- **Sign in.** *Archify | Sign in to Sync Server…* uses that address (or asks for it) and signs in through the browser; the session is kept in VS Code's secret storage and shows under **Accounts** as *Archify Sync*. A new server is set up first on its `/setup` page with the code from its log.
|
||||
- **Share.** *Archify | Share Model…* (also on `main.archify` in the Explorer) puts the model on the server and writes a small `.archify-sync` file next to `main.archify`: the server and the model's id, no secrets. Commit it, and everyone who clones the repository and is a member opens the shared model.
|
||||
- **Invite and join.** *Archify | Copy Invite Link* copies a link for the model; send it any way you like. Opening it (or *Archify | Join Shared Model…*) signs the person in, or up, and puts the files in an empty folder of their choice.
|
||||
- **Live.** Edits in any editor, page, Source view, by Git or by an agent reach the others within a moment. Their edits arrive in your open editors with undo intact and are saved to disk; your own unsaved work stays unsaved. The status bar shows **Archify live · 3 here**; the model page gets a **Shared** section with who is here and in which file, and each diagram card says who has it open. In the Source view and in text editors, teammates' cursors show as coloured carets with their names.
|
||||
- **Offline.** Every edit is kept on your machine first. Without a connection you keep working; edits on both sides merge when it is back, also after a restart, and also edits made while VS Code was closed.
|
||||
- **Versions.** The server takes a snapshot after a pause in editing (0.1, 0.2, …). *Archify | Publish Version…* names the current state as a major version (1.0 "Q4 baseline"). The model page lists the latest versions with **Compare** (a diff per changed file) and **Restore** (brings the files back for everyone, as a new version; nothing is lost); *Archify | Versions…* lists them all.
|
||||
- **Stop.** *Archify | Stop Syncing Model…* removes `.archify-sync` here and leaves the files as they are.
|
||||
|
||||
Only model files (`.archify`, `.archify-registry`, `.archify-decision`) are shared, and only below `main.archify`.
|
||||
|
||||
## Source evidence
|
||||
|
||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||
|
||||
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Render to HTML File…* always verifies strictly and asks before rendering without source links.
|
||||
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Archify | Render to HTML File…* always verifies strictly and asks before rendering without source links.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
||||
| `archify.defaultView` | `2d` | `3d` opens diagrams in the 3D view. Each open diagram remembers its own choice. |
|
||||
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
||||
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
||||
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
||||
@@ -76,7 +174,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
||||
|
||||
## Security
|
||||
|
||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code. The extension itself talks to a network only for Archify Sync, and only to a server you sign in to or a model's `.archify-sync` file names, over HTTPS (plain HTTP only to this machine).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -90,6 +188,20 @@ npm run package # build archify-vscode-ext-<version>.vsix
|
||||
|
||||
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
||||
|
||||
### Code layout
|
||||
|
||||
| Folder | What it holds |
|
||||
|---|---|
|
||||
| `src/extension.ts` | Activation: creates the views and registers everything |
|
||||
| `src/commands/` | Command handlers, grouped by job, and the editor context keys |
|
||||
| `src/views/` | The diagram view (editor for `.archify`, side preview for JSON) and the HTML viewer |
|
||||
| `src/webview/` | The page the views show: CSP, the bridge script, messages from the page |
|
||||
| `src/archify/` | Running the bundled Archify CLI: render, validate, runtime settings |
|
||||
| `src/sources/` | Reading diagram files: type detection, YAML/JSON, source evidence |
|
||||
| `src/language/` | Editor help: Problems, brand completion, templates |
|
||||
| `src/util/` | Logging, settings, file reading |
|
||||
| `src/viewer3d/` | The 3D view; runs inside the Archify page and is bundled to `dist/viewer3d.js` with three.js |
|
||||
|
||||
### Updating the bundled renderer
|
||||
|
||||
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
||||
@@ -101,6 +213,14 @@ npm test
|
||||
|
||||
`vendor/archify.json` records the upstream version and commit.
|
||||
|
||||
## Support
|
||||
|
||||
Archify Diagram Viewer is built and maintained in my spare time. If it helps you, a coffee keeps the releases coming:
|
||||
|
||||
[](https://buymeacoffee.com/root.at.skic)
|
||||
|
||||
Bug reports and ideas are welcome in the [repository issues](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE). The bundled renderer is MIT-licensed by tt-a1i; see `vendor/archify/LICENSE` and `vendor/archify/THIRD_PARTY_NOTICES.md`.
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Models: the file formats
|
||||
|
||||
A model is a folder of diagrams that belong together: a `main.archify` at its top, any number of diagrams below it, and `*.archify-registry` files for the things several diagrams share. Three file kinds, all YAML, all read by the extension; Archify itself only ever sees plain diagrams.
|
||||
|
||||
## `main.archify`
|
||||
|
||||
```yaml
|
||||
schema_version: 1
|
||||
model:
|
||||
id: online-shop
|
||||
name: Online Shop
|
||||
description: >
|
||||
Web shop, its API, workers and stores.
|
||||
```
|
||||
|
||||
Optionally, below the `model` block, a diagram exactly as in any other `.archify` file (`diagram_type`, `meta`, `components`, …). The extension shows it on the model page; Archify never sees the `model` block.
|
||||
|
||||
## `name.archify-registry`
|
||||
|
||||
```yaml
|
||||
schema_version: 1
|
||||
elements:
|
||||
api:
|
||||
type: backend
|
||||
label: API
|
||||
sublabel: REST service
|
||||
brand: node-js
|
||||
description: >
|
||||
Node.js service behind the load balancer.
|
||||
diagram: 0300-components/api.archify
|
||||
db:
|
||||
type: database
|
||||
label: Database
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: Orders, customers and catalogue.
|
||||
```
|
||||
|
||||
- The key is the element's id, unique across all registry files of the model.
|
||||
- `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources` are Archify's component fields and are copied into every diagram that refers to the element.
|
||||
- `description` is the one description of the thing; shown on hover and on the model page, never sent to Archify.
|
||||
- `diagram` is the link: the diagram that explains the element, relative to the model root. Clicking the element in any diagram opens it.
|
||||
|
||||
## A diagram that refers to the registry
|
||||
|
||||
```yaml
|
||||
components:
|
||||
- ref: api
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
label: Orders DB # a diagram may override label, sublabel, tag
|
||||
pos: [460, 260]
|
||||
size: [130, 60]
|
||||
- id: cron # an unshared element, inline, as today
|
||||
type: backend
|
||||
label: Nightly job
|
||||
pos: [460, 420]
|
||||
size: [130, 60]
|
||||
```
|
||||
|
||||
- `ref` names a registry element; `id` defaults to the same name. `id: db-replica` with `ref: db` draws the same element twice or under another id.
|
||||
- A diagram may override `label`, `sublabel` and `tag`; never `type` or `brand` (if those differ, it is a different element).
|
||||
- Before Archify runs, the extension replaces each `ref` component with the registry element plus the diagram's own fields, drops `description` and `diagram`, and hands Archify an ordinary component.
|
||||
- Connections, boundaries and everything else stay as they are and use the ids as before.
|
||||
|
||||
## Descriptions under a model diagram
|
||||
|
||||
Archify's **Elements** panel under a diagram lists its nodes. For a `ref` component the row's tooltip is the element's registry `description`. Archify's own `cards:` stay what they are: write them in the diagram when you want text blocks below it; nothing is generated.
|
||||
|
||||
## Links between diagrams
|
||||
|
||||
A diagram may point at another diagram from a component, whether shared or inline:
|
||||
|
||||
```yaml
|
||||
- ref: api
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
diagram: 0300-components/api.archify # overrides the registry's link for this diagram
|
||||
```
|
||||
|
||||
Registry `diagram` links and component `diagram` links are the same mechanism; the component's wins when both exist. Paths are relative to the model root.
|
||||
|
||||
## Outside a model
|
||||
|
||||
A diagram with `ref` components outside any model (no `main.archify` above it) reports "no registry found" and shows the component by its ref name with type `external`, so the picture still renders.
|
||||
|
||||
## Starting a model
|
||||
|
||||
*Archify | New Model…* (right-click a folder, *File → New File…*, Command Palette) offers three complete examples: **C4 - Standard Web App**, **C4 - E-Shop** and **C4 - Event-Based Integration**. Pick one and a folder name; the example is copied into that folder with `model.id`, `model.name` and the overview title taken from the folder name (an example kept under its own name is copied as is). Each example has `registry/{people,systems,containers}.archify-registry` and diagrams under `0100-context`, `0200-containers` and `0300-components`; every registry element is used by at least one diagram and every diagram validates.
|
||||
|
||||
## Decisions
|
||||
|
||||
An architecture decision is one `*.archify-decision` file, anywhere under the model root (`decisions/` is the convention):
|
||||
|
||||
```yaml
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: queue-between-api-and-worker # unique in the model, any short string
|
||||
title: A queue between the API and the worker
|
||||
status: accepted # proposed | accepted | superseded | rejected
|
||||
date: 2026-09-16
|
||||
elements: [api, queue, worker] # registry ids
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
supersedes: [] # ids of older decisions this one replaces
|
||||
context: >
|
||||
Why a decision was needed.
|
||||
decision: >
|
||||
What was decided.
|
||||
consequences: >
|
||||
What becomes easier, harder, or must change.
|
||||
```
|
||||
|
||||
Ids are strings; an unquoted number such as `0001` is refused because YAML reads it as 1. A decision another decision supersedes shows as superseded whatever its own status says. Unknown elements, missing diagrams, duplicate ids, unknown statuses and dangling `supersedes` are problems on the decision file.
|
||||
|
||||
Diagrams point back at decisions with a `decisions` list of ids on the diagram itself, on a component (or node, participant, state) and on a connection (or edge, flow, transition, message):
|
||||
|
||||
```yaml
|
||||
decisions: [postgres-as-source-of-truth]
|
||||
components:
|
||||
- ref: queue
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [queue-between-api-and-worker]
|
||||
connections:
|
||||
- { from: api, to: queue, label: enqueue, decisions: [queue-between-api-and-worker] }
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
+58
-9
@@ -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.
|
||||
@@ -33,16 +44,54 @@ if (tests) {
|
||||
sourcemap: true,
|
||||
});
|
||||
} else {
|
||||
const ctx = await esbuild.context({
|
||||
...common,
|
||||
entryPoints: ['src/extension.ts'],
|
||||
outfile: 'dist/extension.js',
|
||||
external: ['vscode'],
|
||||
});
|
||||
const contexts = await Promise.all([
|
||||
esbuild.context({
|
||||
...common,
|
||||
entryPoints: ['src/extension.ts'],
|
||||
outfile: 'dist/extension.js',
|
||||
external: ['vscode'],
|
||||
}),
|
||||
// Archify Sync, loaded only when a model is shared (src/sync/loader.ts).
|
||||
// ws's native speed-ups are optional and not shipped.
|
||||
esbuild.context({
|
||||
...common,
|
||||
entryPoints: ['src/sync/index.ts'],
|
||||
outfile: 'dist/sync.js',
|
||||
external: ['vscode', 'bufferutil', 'utf-8-validate'],
|
||||
}),
|
||||
// Runs inside the Archify page in the webview; three.js is bundled in.
|
||||
esbuild.context({
|
||||
...common,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
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 ctx.watch();
|
||||
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||
} else {
|
||||
await ctx.rebuild();
|
||||
await ctx.dispose();
|
||||
await Promise.all(contexts.map((ctx) => ctx.rebuild()));
|
||||
await Promise.all(contexts.map((ctx) => ctx.dispose()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: System context
|
||||
output: context.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: support
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: shop
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [520, 60]
|
||||
size: [150, 60]
|
||||
- ref: email
|
||||
pos: [520, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||
- { from: support, to: shop, label: manages orders }
|
||||
- { from: shop, to: psp, label: charges, variant: security }
|
||||
- { from: shop, to: email, label: sends mail }
|
||||
@@ -0,0 +1,41 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Containers
|
||||
output: containers.html
|
||||
decisions: [postgres-as-source-of-truth]
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: web
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: api
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: cache
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
- ref: queue
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [queue-between-api-and-worker]
|
||||
- ref: worker
|
||||
pos: [670, 460]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Online Shop
|
||||
wraps: [web, api, cache, db, queue, worker]
|
||||
connections:
|
||||
- { from: customer, to: web, label: HTTPS, variant: emphasis }
|
||||
- { from: web, to: api, label: JSON }
|
||||
- { from: api, to: cache, label: read }
|
||||
- { from: api, to: db, label: SQL }
|
||||
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
|
||||
- { from: queue, to: worker, label: poll }
|
||||
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
|
||||
@@ -0,0 +1,42 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: API components
|
||||
output: api.html
|
||||
components:
|
||||
- ref: web
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: auth
|
||||
type: security
|
||||
label: Auth
|
||||
sublabel: sessions, OIDC
|
||||
pos: [250, 60]
|
||||
size: [130, 60]
|
||||
- id: orders
|
||||
type: backend
|
||||
label: Orders
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- id: catalogue
|
||||
type: backend
|
||||
label: Catalogue
|
||||
pos: [250, 260]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: queue
|
||||
pos: [460, 310]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: API
|
||||
wraps: [auth, orders, catalogue]
|
||||
connections:
|
||||
- { from: web, to: auth, label: sign in, variant: security }
|
||||
- { from: web, to: orders, label: place order }
|
||||
- { from: web, to: catalogue, label: browse }
|
||||
- { from: orders, to: db, label: SQL }
|
||||
- { from: catalogue, to: db, label: SQL }
|
||||
- { from: orders, to: queue, label: enqueue }
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: postgres-as-source-of-truth
|
||||
title: PostgreSQL as the single source of truth
|
||||
status: accepted
|
||||
date: 2026-09-02
|
||||
elements: [db, api, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Orders, customers and the catalogue were spread over the
|
||||
shop database and two spreadsheets kept by support.
|
||||
Refunds were reconciled by hand every Monday.
|
||||
decision: >
|
||||
One PostgreSQL database owns orders, customers and the
|
||||
catalogue. The API is the only writer. Everything else
|
||||
(cache, search, reports) is derived and may be rebuilt.
|
||||
consequences: >
|
||||
Support loses the spreadsheets and gets the admin console.
|
||||
Any new store must be declared derived or get its own
|
||||
decision. Backups and migrations are the API team's job.
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: queue-between-api-and-worker
|
||||
title: A queue between the API and the worker
|
||||
status: accepted
|
||||
date: 2026-09-16
|
||||
elements: [api, queue, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Order confirmation emails and invoices were sent inside
|
||||
the checkout request. A slow mail provider made checkout
|
||||
slow; a failing one made checkout fail.
|
||||
decision: >
|
||||
The API enqueues a job per side effect on SQS. One worker
|
||||
service consumes the queue and does the slow work.
|
||||
Checkout returns as soon as the order is stored.
|
||||
consequences: >
|
||||
Emails arrive seconds later, not instantly. Jobs must be
|
||||
idempotent because SQS delivers at least once. The worker
|
||||
is the second place that writes to the database.
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: opensearch-for-product-search
|
||||
title: OpenSearch for product search
|
||||
status: proposed
|
||||
date: 2026-10-19
|
||||
elements: [api, worker, db]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Catalogue search runs as SQL LIKE queries on the
|
||||
database. Typos find nothing and the catalogue page is
|
||||
the slowest page in the shop.
|
||||
decision: >
|
||||
Add a dedicated search service on OpenSearch. The API
|
||||
queries it; the worker rebuilds the index from the
|
||||
database after every catalogue change.
|
||||
consequences: >
|
||||
The index is derived data under decision 0001 and may be
|
||||
rebuilt at any time. One more service to run. Search
|
||||
results can lag the database by the rebuild interval.
|
||||
@@ -0,0 +1,24 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: online-shop
|
||||
name: Online Shop
|
||||
description: >
|
||||
The web shop, its API and workers, and the stores behind them.
|
||||
Context, containers and components, C4 style.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Online Shop — overview
|
||||
output: main.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: shop
|
||||
pos: [250, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [480, 160]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||
- { from: shop, to: psp, label: charges }
|
||||
@@ -0,0 +1,37 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
web:
|
||||
type: frontend
|
||||
label: Web App
|
||||
sublabel: React
|
||||
brand: react
|
||||
description: The shop as customers see it; talks only to the API.
|
||||
api:
|
||||
type: backend
|
||||
label: API
|
||||
sublabel: Node.js
|
||||
brand: node-js
|
||||
description: Owns authentication, orders and the catalogue.
|
||||
diagram: 0300-components/api.archify
|
||||
worker:
|
||||
type: backend
|
||||
label: Worker
|
||||
sublabel: async jobs
|
||||
description: Picks jobs from the queue; emails, invoices, exports.
|
||||
queue:
|
||||
type: messagebus
|
||||
label: Queue
|
||||
sublabel: SQS
|
||||
description: Job queue between the API and the workers.
|
||||
db:
|
||||
type: database
|
||||
label: Database
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: Orders, customers and catalogue; the source of truth.
|
||||
cache:
|
||||
type: database
|
||||
label: Cache
|
||||
sublabel: Redis
|
||||
brand: redis
|
||||
description: Sessions and catalogue cache; nothing here is the source of truth.
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
customer:
|
||||
type: external
|
||||
label: Customer
|
||||
sublabel: web and mobile
|
||||
description: Buys products; signs in with email or a social account.
|
||||
support:
|
||||
type: external
|
||||
label: Support agent
|
||||
sublabel: back office
|
||||
description: Handles refunds and order problems in the admin console.
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
shop:
|
||||
type: cloud
|
||||
label: Online Shop
|
||||
sublabel: the system
|
||||
description: Everything we build and run; web app, API, workers and stores.
|
||||
diagram: 0200-containers/online-shop.archify
|
||||
psp:
|
||||
type: external
|
||||
label: Payment provider
|
||||
sublabel: Stripe
|
||||
brand: stripe
|
||||
description: Charges cards and pays out; we never see card numbers.
|
||||
email:
|
||||
type: external
|
||||
label: Email service
|
||||
sublabel: transactional mail
|
||||
description: Sends order confirmations and password resets.
|
||||
Generated
+1204
-23
File diff suppressed because it is too large.
Load diff
+277
-38
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"description": "Visualize Archify diagrams: .archify files (YAML) open straight into the interactive diagram; also previews Archify JSON sources and rendered Archify HTML inside VS Code.",
|
||||
"version": "0.4.1",
|
||||
"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.10.9",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
"url": "https://buymeacoffee.com/root.at.skic"
|
||||
},
|
||||
"icon": "media/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#0f172a",
|
||||
@@ -23,10 +26,16 @@
|
||||
],
|
||||
"keywords": [
|
||||
"archify",
|
||||
"diagram",
|
||||
"architecture",
|
||||
"c4",
|
||||
"diagram",
|
||||
"model",
|
||||
"registry",
|
||||
"sequence",
|
||||
"workflow"
|
||||
"workflow",
|
||||
"yaml",
|
||||
"adr",
|
||||
"decisions"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
@@ -34,56 +43,132 @@
|
||||
"onLanguage:jsonc",
|
||||
"onLanguage:html",
|
||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||
"onWebviewPanel:archify.preview"
|
||||
"onWebviewPanel:archify.preview",
|
||||
"workspaceContains:**/*.archify-registry",
|
||||
"workspaceContains:**/*.archify-decision",
|
||||
"onUri"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"title": "Archify | New Model…",
|
||||
"icon": "$(new-folder)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newRegistry",
|
||||
"title": "Archify | New Registry…",
|
||||
"icon": "$(symbol-field)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "New Diagram Archify\u2026",
|
||||
"title": "Archify | New Diagram…",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"title": "Archify | New Decision…",
|
||||
"icon": "$(law)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"title": "Open Preview",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Open Preview",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreviewToSide",
|
||||
"title": "Open Preview to the Side",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Open Preview to the Side",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"title": "Open in Archify Viewer",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Open in HTML Viewer",
|
||||
"icon": "$(open-preview)"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"title": "Render to HTML File\u2026",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Render to HTML File…",
|
||||
"icon": "$(file-code)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openInBrowser",
|
||||
"title": "Open in Browser",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Open in Browser",
|
||||
"icon": "$(globe)"
|
||||
},
|
||||
{
|
||||
"command": "archify.refreshPreview",
|
||||
"title": "Refresh Preview",
|
||||
"category": "Archify",
|
||||
"title": "Archify | Refresh Preview",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"title": "Show Source",
|
||||
"category": "Archify",
|
||||
"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)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.signIn",
|
||||
"title": "Archify | Sign in to Sync Server…",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.signOut",
|
||||
"title": "Archify | Sign out of Sync Server",
|
||||
"icon": "$(sign-out)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.share",
|
||||
"title": "Archify | Share Model…",
|
||||
"icon": "$(live-share)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.invite",
|
||||
"title": "Archify | Copy Invite Link",
|
||||
"icon": "$(person-add)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.join",
|
||||
"title": "Archify | Join Shared Model…",
|
||||
"icon": "$(link)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.publish",
|
||||
"title": "Archify | Publish Version…",
|
||||
"icon": "$(tag)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.versions",
|
||||
"title": "Archify | Versions…",
|
||||
"icon": "$(history)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.stop",
|
||||
"title": "Archify | Stop Syncing Model…",
|
||||
"icon": "$(debug-disconnect)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.menu",
|
||||
"title": "Archify | Sync…",
|
||||
"icon": "$(live-share)"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.setServer",
|
||||
"title": "Archify | Set Sync Server Address…",
|
||||
"icon": "$(server)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -114,7 +199,35 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
||||
"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'"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.invite",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.publish",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.versions",
|
||||
"when": "archify.sync.active"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.stop",
|
||||
"when": "archify.sync.active"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -134,8 +247,8 @@
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
|
||||
"command": "archify.openInTextEditor",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
@@ -147,41 +260,93 @@
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
||||
"group": "1_archify"
|
||||
},
|
||||
{
|
||||
"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.newModel",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newRegistry",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "navigation@10"
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@4"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "navigation@20"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "navigation@21"
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@5"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html",
|
||||
"group": "navigation@20"
|
||||
"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@8"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModelGraph",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@9"
|
||||
},
|
||||
{
|
||||
"command": "archify.sync.share",
|
||||
"when": "resourceFilename == main.archify",
|
||||
"group": "archify@9"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "1_open"
|
||||
"group": "zzz_archify@1"
|
||||
}
|
||||
],
|
||||
"file/newFile": [
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"group": "file@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newRegistry",
|
||||
"group": "file@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file"
|
||||
"group": "file@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"group": "file@4"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -210,9 +375,29 @@
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.registry",
|
||||
"displayName": "Archify Registry",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.archify-registry"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.decision",
|
||||
"displayName": "Archify Decision",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.archify-decision"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.htmlViewer",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"displayName": "Archify HTML Viewer",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.html"
|
||||
@@ -263,6 +448,19 @@
|
||||
"default": "vscode",
|
||||
"description": "Theme used when showing Archify diagrams."
|
||||
},
|
||||
"archify.defaultView": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"2d",
|
||||
"3d"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Open diagrams as Archify draws them.",
|
||||
"Open diagrams in the 3D view. The 3D button in the diagram toolbar switches back."
|
||||
],
|
||||
"default": "2d",
|
||||
"description": "How diagrams open. Each open diagram remembers its own choice."
|
||||
},
|
||||
"archify.preview.liveUpdate": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
@@ -316,6 +514,14 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Node.js executable (18 or newer) used to run the renderer. Empty means the runtime bundled with VS Code."
|
||||
},
|
||||
"archify.sync.server": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"scope": "window",
|
||||
"pattern": "^$|^https?://",
|
||||
"patternErrorMessage": "An address such as https://archify.example.com",
|
||||
"markdownDescription": "Address of your Archify Sync server, for example `https://archify.example.com`. The **Sync** button on Archify pages, *Sign in* and *Share Model* use it without asking. Set it in the workspace settings so everyone in the project uses the same server. Empty: asked once and remembered. Archify Sync is optional; see the README."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -323,7 +529,9 @@
|
||||
{
|
||||
"id": "yaml",
|
||||
"extensions": [
|
||||
".archify"
|
||||
".archify",
|
||||
".archify-registry",
|
||||
".archify-decision"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -331,6 +539,16 @@
|
||||
{
|
||||
"fileMatch": "*.archify",
|
||||
"url": "./schemas/archify.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.archify-registry",
|
||||
"url": "./schemas/archify-registry.schema.json"
|
||||
}
|
||||
],
|
||||
"authentication": [
|
||||
{
|
||||
"id": "archify-sync",
|
||||
"label": "Archify Sync"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -345,11 +563,14 @@
|
||||
"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",
|
||||
"@types/ws": "^8.18.2",
|
||||
"@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"
|
||||
@@ -359,6 +580,24 @@
|
||||
},
|
||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||
"dependencies": {
|
||||
"yaml": "^2.9.1"
|
||||
"@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",
|
||||
"lib0": "^0.2.119",
|
||||
"three": "^0.186.1",
|
||||
"ws": "^8.22.1",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yaml": "^2.9.1",
|
||||
"yjs": "^13.6.33"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify architecture decision",
|
||||
"description": "One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"decision"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"description": "What was decided."
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "The situation and what forced a decision."
|
||||
},
|
||||
"consequences": {
|
||||
"type": "string",
|
||||
"description": "What becomes easier, harder, or must change."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify model registry",
|
||||
"description": "Shared elements of a model: one name for one thing. Diagrams refer to an element with `ref: <id>`.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"elements"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"elements": {
|
||||
"type": "object",
|
||||
"description": "Element id → element. Ids are unique across all registry files of the model.",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"frontend",
|
||||
"backend",
|
||||
"database",
|
||||
"cloud",
|
||||
"security",
|
||||
"messagebus",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"description": "Decorative corner icon. Omit the icon field for the type default; none hides it. Does not change type, color, or legend grouping.",
|
||||
"enum": [
|
||||
"calendar",
|
||||
"clock",
|
||||
"person",
|
||||
"briefcase",
|
||||
"flag",
|
||||
"moon",
|
||||
"frontend",
|
||||
"backend",
|
||||
"database",
|
||||
"cloud",
|
||||
"security",
|
||||
"messagebus",
|
||||
"external",
|
||||
"start",
|
||||
"active",
|
||||
"waiting",
|
||||
"success",
|
||||
"failure",
|
||||
"neutral",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"brand": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048,
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 80,
|
||||
"pattern": "^[^\\r\\n]+$"
|
||||
},
|
||||
{
|
||||
"pattern": "^https?://"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"url",
|
||||
"sha256"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"maxLength": 2048,
|
||||
"pattern": "^https?://"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 48
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The one description of this thing; shown on hover and on the model page."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,11 +158,6 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -215,8 +210,39 @@
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag."
|
||||
},
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"ref"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
@@ -314,12 +340,53 @@
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "Stable id of the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Name shown on the model page."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What the model covers; shown on the model page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,14 @@
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,12 +264,53 @@
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "Stable id of the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Name shown on the model page."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What the model covers; shown on the model page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,14 @@
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,12 +296,53 @@
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "Stable id of the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Name shown on the model page."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What the model covers; shown on the model page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,14 @@
|
||||
},
|
||||
"sources": {
|
||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +221,14 @@
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +261,39 @@
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "Stable id of the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Name shown on the model page."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What the model covers; shown on the model page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,14 @@
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,12 +427,53 @@
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 0.5
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||
"description": "Stable id of the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Name shown on the model page."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What the model covers; shown on the model page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// Adds the model keys to the editor schemas in schemas/ (the CLI keeps the
|
||||
// strict originals): `model` on main.archify, `ref` and `diagram` on
|
||||
// architecture components, and a schema for *.archify-registry files.
|
||||
// Run after sync-archify.mjs, or on its own after editing this file.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const schemas = path.join(root, 'schemas');
|
||||
const read = (file) => JSON.parse(fs.readFileSync(path.join(schemas, file), 'utf8'));
|
||||
const write = (file, value) => fs.writeFileSync(path.join(schemas, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
|
||||
const MODEL = {
|
||||
type: 'object',
|
||||
description: 'Only in main.archify: the entry point of a model (a folder of diagrams that belong together).',
|
||||
additionalProperties: false,
|
||||
required: ['id', 'name'],
|
||||
properties: {
|
||||
id: { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'Stable id of the model.' },
|
||||
name: { type: 'string', minLength: 1, description: 'Name shown on the model page.' },
|
||||
description: { type: 'string', description: 'What the model covers; shown on the model page.' },
|
||||
},
|
||||
};
|
||||
|
||||
const DIAGRAM_LINK = { type: 'string', description: 'Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it.' };
|
||||
const DECISIONS = { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram.' };
|
||||
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'];
|
||||
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'];
|
||||
|
||||
for (const file of fs.readdirSync(schemas)) {
|
||||
if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue;
|
||||
const schema = read(file);
|
||||
schema.properties.model = MODEL;
|
||||
schema.properties.decisions = DECISIONS;
|
||||
for (const key of [...NODE_COLLECTIONS, ...EDGE_COLLECTIONS]) {
|
||||
const items = schema.properties[key]?.items;
|
||||
if (items?.properties) items.properties.decisions = DECISIONS;
|
||||
}
|
||||
if (file === 'architecture.schema.json') {
|
||||
const item = schema.properties.components.items;
|
||||
item.properties.ref = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag.' };
|
||||
item.properties.diagram = DIAGRAM_LINK;
|
||||
// A ref brings type and label with it; an inline component must state them.
|
||||
item.anyOf = [{ required: ['ref'] }, { required: ['id', 'type', 'label'] }];
|
||||
delete item.required;
|
||||
}
|
||||
write(file, schema);
|
||||
}
|
||||
|
||||
const common = read('common.schema.json');
|
||||
const componentType = common.$defs.componentType;
|
||||
const registry = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
title: 'Archify model registry',
|
||||
description: 'Shared elements of a model: one name for one thing. Diagrams refer to an element with `ref: <id>`.',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['elements'],
|
||||
properties: {
|
||||
schema_version: { const: 1 },
|
||||
elements: {
|
||||
type: 'object',
|
||||
description: 'Element id → element. Ids are unique across all registry files of the model.',
|
||||
propertyNames: { pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$' },
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['type', 'label'],
|
||||
properties: {
|
||||
type: componentType,
|
||||
label: { type: 'string', minLength: 1 },
|
||||
sublabel: { type: 'string' },
|
||||
tag: { type: 'string' },
|
||||
icon: common.$defs.nodeIcon,
|
||||
brand: common.$defs.brandMark,
|
||||
sources: common.$defs.sourceReferences,
|
||||
description: { type: 'string', description: 'The one description of this thing; shown on hover and on the model page.' },
|
||||
diagram: DIAGRAM_LINK,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
write('archify-registry.schema.json', registry);
|
||||
|
||||
const decision = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
title: 'Archify architecture decision',
|
||||
description: 'One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['decision'],
|
||||
properties: {
|
||||
schema_version: { const: 1 },
|
||||
decision: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id', 'title'],
|
||||
properties: {
|
||||
id: { type: 'string', minLength: 1, description: 'Unique in the model. Any short string.' },
|
||||
title: { type: 'string', minLength: 1 },
|
||||
status: { enum: ['proposed', 'accepted', 'superseded', 'rejected'], description: 'A decision another one supersedes shows as superseded whatever this says.' },
|
||||
date: { type: 'string', description: 'YYYY-MM-DD.' },
|
||||
elements: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Registry element ids the decision is about.' },
|
||||
diagrams: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Diagram paths (from the model root) the decision names.' },
|
||||
supersedes: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of older decisions this one replaces.' },
|
||||
},
|
||||
},
|
||||
context: { type: 'string', description: 'The situation and what forced a decision.' },
|
||||
decision: { type: 'string', description: 'What was decided.' },
|
||||
consequences: { type: 'string', description: 'What becomes easier, harder, or must change.' },
|
||||
},
|
||||
};
|
||||
write('archify-decision.schema.json', decision);
|
||||
console.log('Patched editor schemas: model, ref, diagram, decisions; wrote archify-registry.schema.json and archify-decision.schema.json');
|
||||
@@ -92,3 +92,4 @@ fs.writeFileSync(
|
||||
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
||||
);
|
||||
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
||||
await import('./patch-schemas.mjs');
|
||||
@@ -1,7 +1,8 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
|
||||
import { DiagramSource, DiagramType } from '../sources/detect';
|
||||
import { hasSourceEvidence, stripSourceEvidence } from '../sources/evidence';
|
||||
|
||||
export interface RuntimeOptions {
|
||||
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
||||
@@ -1,15 +1,8 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { RuntimeOptions } from './renderer';
|
||||
import { ViewerTheme } from './webviewHtml';
|
||||
|
||||
let output: vscode.LogOutputChannel | undefined;
|
||||
|
||||
export function log(): vscode.LogOutputChannel {
|
||||
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
||||
return output;
|
||||
}
|
||||
import { log } from '../util/log';
|
||||
import { RuntimeOptions } from './cli';
|
||||
|
||||
const gitRoots = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
@@ -60,18 +53,3 @@ export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.U
|
||||
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
||||
}
|
||||
|
||||
export function viewerTheme(): ViewerTheme {
|
||||
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
||||
if (setting === 'dark' || setting === 'light') return setting;
|
||||
if (setting === 'diagram') return undefined;
|
||||
const kind = vscode.window.activeColorTheme.kind;
|
||||
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
/** Current text of a document: the open editor buffer, else the file on disk. */
|
||||
export async function readText(uri: vscode.Uri): Promise<string> {
|
||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||
if (open) return open.getText();
|
||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
||||
import { ModelService } from '../model/service';
|
||||
import { DiagramViewManager } from '../views/diagramView';
|
||||
import { RegistryViewProvider } from '../views/registryView';
|
||||
import { DecisionViewProvider } from '../views/decisionView';
|
||||
|
||||
/** What command handlers share: the extension context and the open views. */
|
||||
export interface CommandContext {
|
||||
extension: vscode.ExtensionContext;
|
||||
views: DiagramViewManager;
|
||||
htmlViewer: HtmlViewerProvider;
|
||||
registry: RegistryViewProvider;
|
||||
decision: DecisionViewProvider;
|
||||
models: ModelService;
|
||||
}
|
||||
|
||||
export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
return /\.html?$/i.test(uri.path);
|
||||
}
|
||||
|
||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
if (arg instanceof vscode.Uri) return arg;
|
||||
return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.decision.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
}
|
||||
|
||||
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
const uri = targetUri(ctx, arg);
|
||||
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
|
||||
return uri;
|
||||
}
|
||||
|
||||
export const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||
|
||||
export async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { clearGitRootCache } from '../archify/runtime';
|
||||
import { ModelService } from '../model/service';
|
||||
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
|
||||
export function registerContextKeys(context: vscode.ExtensionContext, models: ModelService): void {
|
||||
let contextTimer: NodeJS.Timeout | undefined;
|
||||
const updateContext = () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
if (doc) void models.rootOf(doc.uri).then((root) => vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(root)));
|
||||
const diagram = Boolean(
|
||||
doc &&
|
||||
(isArchifyFile(doc.fileName) ||
|
||||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
||||
);
|
||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
||||
};
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
||||
clearTimeout(contextTimer);
|
||||
contextTimer = setTimeout(updateContext, 300);
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
||||
}),
|
||||
{ dispose: () => clearTimeout(contextTimer) },
|
||||
);
|
||||
updateContext();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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, 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';
|
||||
import { CommandContext, exists, targetUri } from './context';
|
||||
|
||||
/** Folder the new model folder goes into: the clicked folder (or a file's folder), else a workspace folder. */
|
||||
async function parentFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||
if (arg instanceof vscode.Uri) {
|
||||
const stat = await vscode.workspace.fs.stat(arg);
|
||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||
}
|
||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||
if (folders.length <= 1) return folders[0]?.uri;
|
||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new model' }))?.uri;
|
||||
}
|
||||
|
||||
export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return [
|
||||
// Opens the model page (main.archify) of the model the current file is in.
|
||||
vscode.commands.registerCommand('archify.openModel', 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 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);
|
||||
if (!folder) return;
|
||||
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
|
||||
if (!root) {
|
||||
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} is not inside a model (no ${MAIN_FILE} in it or a folder above it). Decisions belong to a model.`);
|
||||
return;
|
||||
}
|
||||
const index = await ctx.models.index(root);
|
||||
const title = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Decision',
|
||||
prompt: `Title of the decision (file goes into ${vscode.workspace.asRelativePath(folder, true)})`,
|
||||
placeHolder: 'OpenSearch for product search',
|
||||
validateInput: async (value) => {
|
||||
if (!value.trim()) return 'Enter a title.';
|
||||
const id = slugify(value);
|
||||
if (index.decisions.decisions.has(id)) return `A decision with id "${id}" already exists in this model.`;
|
||||
if (await exists(vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`))) return `${id}${DECISION_EXT} already exists.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (title === undefined) return;
|
||||
const id = slugify(title);
|
||||
const target = vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`);
|
||||
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'decision.archify-decision'));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const text = template.replace('__ID__', id).replace('__TITLE__', JSON.stringify(title.trim())).replace('__DATE__', today);
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, DECISION_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
// Creates a new model folder from one of the shipped examples and opens its model page.
|
||||
vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => {
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
MODEL_EXAMPLES.map((example) => ({ label: example.label, description: example.description, example })),
|
||||
{ title: 'Archify | New Model', placeHolder: 'Example to start from' },
|
||||
);
|
||||
if (!picked) return;
|
||||
const { example } = picked;
|
||||
|
||||
let parent = await parentFolder(arg);
|
||||
if (!parent) {
|
||||
const chosen = await vscode.window.showOpenDialog({ title: 'Archify | New Model', canSelectFiles: false, canSelectFolders: true, openLabel: 'Create model here' });
|
||||
parent = chosen?.[0];
|
||||
if (!parent) return;
|
||||
}
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Model',
|
||||
prompt: `Folder name in ${vscode.workspace.asRelativePath(parent, true)}`,
|
||||
value: example.folder,
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(parent, diagramName(value)))) ? `${diagramName(value)} already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
const folderName = diagramName(name);
|
||||
const target = vscode.Uri.joinPath(parent, folderName);
|
||||
if (await exists(target)) {
|
||||
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(target, true)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'models', example.folder);
|
||||
await vscode.workspace.fs.copy(source, target, { overwrite: false });
|
||||
const main = vscode.Uri.joinPath(target, MAIN_FILE);
|
||||
await vscode.workspace.fs.writeFile(main, Buffer.from(mainFromExample(await readText(main), folderName, example), 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { diagramFromTemplate, diagramName, invalidNameReason, modelFromTemplate, TEMPLATES } from '../language/templates';
|
||||
import { diagramBaseName, isArchifyFile } from '../sources/detect';
|
||||
import { readText } from '../util/files';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { CommandContext, exists } from './context';
|
||||
|
||||
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
||||
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||
if (arg instanceof vscode.Uri) {
|
||||
const stat = await vscode.workspace.fs.stat(arg);
|
||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||
}
|
||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||
if (folders.length <= 1) return folders[0]?.uri;
|
||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
||||
}
|
||||
|
||||
export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
||||
const template = await vscode.window.showQuickPick(
|
||||
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
||||
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
||||
);
|
||||
if (!template) return;
|
||||
const { file, defaultName, fixedName } = template.template;
|
||||
|
||||
const folder = await newDiagramFolder(arg);
|
||||
let target: vscode.Uri | undefined;
|
||||
if (folder && fixedName) {
|
||||
target = vscode.Uri.joinPath(folder, fixedName);
|
||||
if (await exists(target)) {
|
||||
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} already has a ${fixedName}.`);
|
||||
return;
|
||||
}
|
||||
} else if (folder) {
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Diagram',
|
||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
||||
value: defaultName,
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ??
|
||||
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
||||
} else {
|
||||
// No workspace: let the user choose where to save.
|
||||
target = await vscode.window.showSaveDialog({
|
||||
title: 'Archify | New Diagram',
|
||||
filters: { 'Archify Diagram': ['archify'] },
|
||||
saveLabel: 'Create',
|
||||
});
|
||||
if (!target) return;
|
||||
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
||||
}
|
||||
|
||||
const templateText = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', file));
|
||||
const text = fixedName
|
||||
? modelFromTemplate(templateText, path.basename(path.dirname(target.fsPath)))
|
||||
: diagramFromTemplate(templateText, diagramBaseName(target.path));
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
import { prepareDiagram } from '../model/prepare';
|
||||
import { diagramBaseName } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import { readText } from '../util/files';
|
||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||
|
||||
export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
const text = await readText(uri);
|
||||
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
}
|
||||
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
||||
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
||||
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
||||
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
defaultUri,
|
||||
filters: { HTML: ['html'] },
|
||||
saveLabel: 'Render',
|
||||
});
|
||||
if (!target) return;
|
||||
|
||||
const runtime = await runtimeFor(ctx.extension, uri);
|
||||
let result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderToFile(runtime, source, target.fsPath),
|
||||
);
|
||||
if (!result.ok && isEvidenceError(result.error)) {
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
||||
{ modal: true, detail: result.error },
|
||||
'Render Without Source Links',
|
||||
);
|
||||
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
||||
else return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
log().error(result.error ?? 'render failed');
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`Rendered ${path.basename(target.fsPath)}`,
|
||||
'Open in Viewer',
|
||||
'Open in Browser',
|
||||
);
|
||||
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
||||
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) {
|
||||
await vscode.env.openExternal(uri);
|
||||
return;
|
||||
}
|
||||
const text = await readText(uri);
|
||||
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
}
|
||||
const runtime = await runtimeFor(ctx.extension, uri);
|
||||
const result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
||||
);
|
||||
if (!result.ok || !result.html) {
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const dir = path.join(ctx.extension.globalStorageUri.fsPath, 'browser');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
||||
await fs.writeFile(file, result.html, 'utf8');
|
||||
await vscode.env.openExternal(vscode.Uri.file(file));
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { isDecisionFile } from '../model/decisions';
|
||||
import { isRegistryFile } from '../model/format';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
|
||||
import { DECISION_VIEW_TYPE } from '../views/decisionView';
|
||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||
|
||||
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||
if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE);
|
||||
if (isDecisionFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DECISION_VIEW_TYPE);
|
||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||
if (isArchifyFile(uri.path)) {
|
||||
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preserveFocus: true,
|
||||
});
|
||||
}
|
||||
ctx.views.show(uri, vscode.ViewColumn.Beside, true);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (uri) return openHtmlViewer(uri);
|
||||
}),
|
||||
|
||||
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.
|
||||
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
||||
return;
|
||||
}
|
||||
if (preview) {
|
||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||
return;
|
||||
}
|
||||
const page = ctx.registry.activeUri ?? ctx.decision.activeUri;
|
||||
if (page) {
|
||||
await vscode.commands.executeCommand('vscode.openWith', page, 'default', vscode.ViewColumn.Beside);
|
||||
return;
|
||||
}
|
||||
const html = ctx.htmlViewer.activeUri;
|
||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { parse, ParseError } from 'jsonc-parser';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyFile, readDiagramSource } from './detect';
|
||||
import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity {
|
||||
if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) {
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
}
|
||||
switch (diagnostic.severity) {
|
||||
case 'warning':
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
case 'info':
|
||||
return vscode.DiagnosticSeverity.Information;
|
||||
default:
|
||||
return vscode.DiagnosticSeverity.Error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `archify validate` on diagram sources and reports into Problems. */
|
||||
export class DiagnosticsController implements vscode.Disposable {
|
||||
private readonly collection = vscode.languages.createDiagnosticCollection('archify');
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly sequence = new Map<string, number>();
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
this.disposables = [
|
||||
this.collection,
|
||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.contentChanges.length) this.schedule(event.document, 700);
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.revalidateAll();
|
||||
}),
|
||||
];
|
||||
this.revalidateAll();
|
||||
}
|
||||
|
||||
private enabled(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('validation.enabled', true);
|
||||
}
|
||||
|
||||
revalidateAll(): void {
|
||||
for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0);
|
||||
}
|
||||
|
||||
private clear(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.delete(key);
|
||||
this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1);
|
||||
this.collection.delete(uri);
|
||||
}
|
||||
|
||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
|
||||
const key = doc.uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
||||
}
|
||||
|
||||
private async validate(doc: vscode.TextDocument): Promise<void> {
|
||||
const key = doc.uri.toString();
|
||||
this.timers.delete(key);
|
||||
const seq = (this.sequence.get(key) ?? 0) + 1;
|
||||
this.sequence.set(key, seq);
|
||||
|
||||
const text = doc.getText();
|
||||
if (!this.enabled(doc.uri) || doc.isClosed) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
if (isArchifyFile(doc.fileName)) {
|
||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||
// that does not parse has no diagram to validate yet.
|
||||
const problems = yamlSyntaxProblems(text);
|
||||
if (problems.length) {
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
problems.map((problem) => {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
|
||||
`YAML: ${problem.message}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const source = readDiagramSource(text, doc.fileName);
|
||||
if (!source) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
if (source.format === 'json') {
|
||||
// Leave JSON syntax errors to the JSON language service.
|
||||
const syntax: ParseError[] = [];
|
||||
parse(text, syntax, { allowTrailingComma: false });
|
||||
if (syntax.length) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, doc.uri);
|
||||
const result = await validate(runtime, source.type, source.json);
|
||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||
|
||||
if (result.crash) {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(0, 0, 0, 0),
|
||||
`Archify validation did not complete: ${result.crash.split('\n')[0]}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
this.collection.set(doc.uri, [diagnostic]);
|
||||
return;
|
||||
}
|
||||
|
||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
result.diagnostics.map((item) => {
|
||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, item.subject?.path) : rangeForPointer(text, item.subject?.path);
|
||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||
const fix = item.supportedFixes?.[0];
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
range,
|
||||
fix ? `${item.message}\nFix: ${fix}` : item.message,
|
||||
severity(item, downgradeEvidence),
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
if (item.code) diagnostic.code = item.code;
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
+47
-238
@@ -1,247 +1,56 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
||||
import { DiagnosticsController } from './diagnostics';
|
||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
|
||||
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
||||
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
return /\.html?$/i.test(uri.path);
|
||||
}
|
||||
import { CommandContext } from './commands/context';
|
||||
import { registerContextKeys } from './commands/contextKeys';
|
||||
import { newDiagramCommands } from './commands/newDiagram';
|
||||
import { renderCommands } from './commands/renderCommands';
|
||||
import { viewCommands } from './commands/viewCommands';
|
||||
import { modelCommands } from './commands/modelCommands';
|
||||
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
|
||||
import { REF_SELECTOR, RefLanguage } from './language/refLanguage';
|
||||
import { ModelService } from './model/service';
|
||||
import { DiagnosticsController } from './language/diagnostics';
|
||||
import { log } from './util/log';
|
||||
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||
import { DiagramViewManager } from './views/diagramView';
|
||||
import { RegistryViewProvider } from './views/registryView';
|
||||
import { DecisionViewProvider } from './views/decisionView';
|
||||
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
||||
import { registerSync } from './sync/loader';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const previews = new PreviewManager(context);
|
||||
const htmlViewer = new HtmlViewerProvider();
|
||||
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
|
||||
|
||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
||||
if (arg instanceof vscode.Uri) return arg;
|
||||
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
}
|
||||
|
||||
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
||||
const uri = targetUri(arg);
|
||||
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Context keys for editor title buttons and keybindings.
|
||||
let contextTimer: NodeJS.Timeout | undefined;
|
||||
const updateContext = () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
const diagram = Boolean(
|
||||
doc &&
|
||||
(isArchifyFile(doc.fileName) ||
|
||||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
||||
);
|
||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
||||
const models = new ModelService();
|
||||
const ctx: CommandContext = {
|
||||
extension: context,
|
||||
views: new DiagramViewManager(context, models),
|
||||
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||
registry: new RegistryViewProvider(context, models),
|
||||
decision: new DecisionViewProvider(context, models),
|
||||
models,
|
||||
};
|
||||
const refs = new RefLanguage(models);
|
||||
const decisions = new DecisionLanguage(models);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
||||
clearTimeout(contextTimer);
|
||||
contextTimer = setTimeout(updateContext, 300);
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
||||
}),
|
||||
{ dispose: () => clearTimeout(contextTimer) },
|
||||
);
|
||||
updateContext();
|
||||
|
||||
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||
|
||||
const exists = async (uri: vscode.Uri) => {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
||||
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||
if (arg instanceof vscode.Uri) {
|
||||
const stat = await vscode.workspace.fs.stat(arg);
|
||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||
}
|
||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||
if (folders.length <= 1) return folders[0]?.uri;
|
||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||
if (isArchifyFile(uri.path)) {
|
||||
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preserveFocus: true,
|
||||
});
|
||||
}
|
||||
previews.show(uri, vscode.ViewColumn.Beside, true);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (uri) return openHtmlViewer(uri);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
||||
const template = await vscode.window.showQuickPick(
|
||||
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
||||
{ title: 'New Diagram Archify', placeHolder: 'Kind of diagram' },
|
||||
);
|
||||
if (!template) return;
|
||||
const { file, defaultName } = template.template;
|
||||
|
||||
const folder = await newDiagramFolder(arg);
|
||||
let target: vscode.Uri | undefined;
|
||||
if (folder) {
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'New Diagram Archify',
|
||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
||||
value: defaultName,
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ??
|
||||
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
||||
} else {
|
||||
// No workspace: let the user choose where to save.
|
||||
target = await vscode.window.showSaveDialog({
|
||||
title: 'New Diagram Archify',
|
||||
filters: { 'Archify Diagram': ['archify'] },
|
||||
saveLabel: 'Create',
|
||||
});
|
||||
if (!target) return;
|
||||
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
||||
}
|
||||
|
||||
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
|
||||
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
||||
|
||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||
const preview = previews.activePreview;
|
||||
if (preview?.isEditor) {
|
||||
// The diagram is the editor; open the YAML text beside it.
|
||||
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
||||
return;
|
||||
}
|
||||
if (preview) {
|
||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||
return;
|
||||
}
|
||||
const html = htmlViewer.activeUri;
|
||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
}
|
||||
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
||||
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
||||
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
||||
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
defaultUri,
|
||||
filters: { HTML: ['html'] },
|
||||
saveLabel: 'Render',
|
||||
});
|
||||
if (!target) return;
|
||||
|
||||
const runtime = await runtimeFor(context, uri);
|
||||
let result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderToFile(runtime, source, target.fsPath),
|
||||
);
|
||||
if (!result.ok && isEvidenceError(result.error)) {
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
||||
{ modal: true, detail: result.error },
|
||||
'Render Without Source Links',
|
||||
);
|
||||
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
||||
else return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
log().error(result.error ?? 'render failed');
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`Rendered ${path.basename(target.fsPath)}`,
|
||||
'Open in Viewer',
|
||||
'Open in Browser',
|
||||
);
|
||||
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
||||
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
||||
const uri = sourceUri(arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) {
|
||||
await vscode.env.openExternal(uri);
|
||||
return;
|
||||
}
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
}
|
||||
const runtime = await runtimeFor(context, uri);
|
||||
const result = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
||||
);
|
||||
if (!result.ok || !result.html) {
|
||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||
return;
|
||||
}
|
||||
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
||||
await fs.writeFile(file, result.html, 'utf8');
|
||||
await vscode.env.openExternal(vscode.Uri.file(file));
|
||||
}),
|
||||
models,
|
||||
ctx.views,
|
||||
ctx.htmlViewer,
|
||||
ctx.registry,
|
||||
ctx.decision,
|
||||
new DiagnosticsController(context, models),
|
||||
log(),
|
||||
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||
vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'),
|
||||
vscode.languages.registerHoverProvider(REF_SELECTOR, refs),
|
||||
vscode.languages.registerDefinitionProvider(REF_SELECTOR, refs),
|
||||
vscode.languages.registerCompletionItemProvider(DECISION_SELECTOR, decisions, ' ', '[', ',', '"'),
|
||||
vscode.languages.registerHoverProvider(DECISION_SELECTOR, decisions),
|
||||
vscode.languages.registerDefinitionProvider(DECISION_SELECTOR, decisions),
|
||||
...viewCommands(ctx),
|
||||
...newDiagramCommands(ctx),
|
||||
...renderCommands(ctx),
|
||||
...modelCommands(ctx),
|
||||
...registerSync(context, models),
|
||||
);
|
||||
registerContextKeys(context, models);
|
||||
}
|
||||
|
||||
export function deactivate(): void {}
|
||||
@@ -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; } }
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
||||
|
||||
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
||||
|
||||
export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [
|
||||
{ pattern: '**/*.archify' },
|
||||
{ language: 'json', pattern: JSON_SOURCES },
|
||||
{ language: 'jsonc', pattern: JSON_SOURCES },
|
||||
];
|
||||
|
||||
/** Suggests Archify's built-in brand IDs for `brand` values. */
|
||||
export class BrandCompletionProvider implements vscode.CompletionItemProvider {
|
||||
private brands: Brand[] | undefined;
|
||||
|
||||
constructor(private readonly extensionPath: string) {}
|
||||
|
||||
private catalog(): Brand[] {
|
||||
if (!this.brands) {
|
||||
try {
|
||||
this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json'));
|
||||
} catch (error) {
|
||||
log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.brands = [];
|
||||
}
|
||||
}
|
||||
return this.brands;
|
||||
}
|
||||
|
||||
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined {
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json');
|
||||
if (!value) return undefined;
|
||||
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||
return this.catalog().map((brand) => {
|
||||
const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value);
|
||||
item.detail = `${brand.title} · ${brand.category}`;
|
||||
if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`;
|
||||
// Typing an alias or the product name finds the brand too.
|
||||
item.filterText = [brand.id, ...brand.aliases, brand.title].join(' ');
|
||||
item.range = range;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
/** Archify's built-in brand marks, read from brand-marks/catalog.json. */
|
||||
export function loadBrandCatalog(file: string): Brand[] {
|
||||
const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial<Brand>[] };
|
||||
return (catalog.marks ?? [])
|
||||
.filter((mark): mark is Partial<Brand> & { id: string } => typeof mark.id === 'string')
|
||||
.map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] }));
|
||||
}
|
||||
|
||||
export interface BrandValue {
|
||||
/** What has been typed of the value so far. */
|
||||
typed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text before the cursor ends inside a `brand` value:
|
||||
* `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON.
|
||||
*/
|
||||
export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined {
|
||||
const match =
|
||||
format === 'yaml'
|
||||
? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix)
|
||||
: /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix);
|
||||
return match ? { typed: match[1] } : undefined;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions';
|
||||
import { ModelIndex, ModelService } from '../model/service';
|
||||
|
||||
/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */
|
||||
export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }];
|
||||
|
||||
type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes';
|
||||
|
||||
/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */
|
||||
export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined {
|
||||
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix);
|
||||
if (match) {
|
||||
const items = match[2].split(',');
|
||||
return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') };
|
||||
}
|
||||
// Block list item under the key: " - 00" (the key is on a line above; the caller checks).
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The list item under a position on a flow-list line, with its range. */
|
||||
export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined {
|
||||
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line);
|
||||
if (!match) return undefined;
|
||||
const listStart = match.index + match[0].indexOf('[') + 1;
|
||||
const re = /[^,\s"'\]]+/g;
|
||||
const inner = match[2];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(inner))) {
|
||||
const start = listStart + m.index;
|
||||
const end = start + m[0].length;
|
||||
if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString {
|
||||
const md = new vscode.MarkdownString();
|
||||
const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions);
|
||||
md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`);
|
||||
if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`);
|
||||
md.appendMarkdown(`*${d.file}*`);
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */
|
||||
export class DecisionLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
|
||||
constructor(private readonly models: ModelService) {}
|
||||
|
||||
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
|
||||
const at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName);
|
||||
if (!at) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
if (!index) return undefined;
|
||||
const range = new vscode.Range(position.translate(0, -at.typed.length), position);
|
||||
const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => {
|
||||
const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference);
|
||||
it.detail = detail;
|
||||
it.documentation = doc;
|
||||
it.filterText = `${label} ${description ?? ''}`;
|
||||
it.range = range;
|
||||
return it;
|
||||
};
|
||||
if (at.key === 'decisions' || at.key === 'supersedes') {
|
||||
return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index)));
|
||||
}
|
||||
if (at.key === 'elements') {
|
||||
return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · ')));
|
||||
}
|
||||
return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram'));
|
||||
}
|
||||
|
||||
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
||||
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||
if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
const range = new vscode.Range(position.line, hit.start, position.line, hit.end);
|
||||
const d = index?.decisions.decisions.get(hit.value);
|
||||
if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range);
|
||||
if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range);
|
||||
return new vscode.Hover(describeDecision(d, index), range);
|
||||
}
|
||||
|
||||
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
||||
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||
if (!hit) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
if (!index) return undefined;
|
||||
if (hit.key === 'decisions' || hit.key === 'supersedes') {
|
||||
const d = index.decisions.decisions.get(hit.value);
|
||||
return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined;
|
||||
}
|
||||
if (hit.key === 'elements') {
|
||||
const e = index.registry.elements.get(hit.value);
|
||||
return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined;
|
||||
}
|
||||
const diagram = index.diagrams.find((d) => d.relativePath === hit.value);
|
||||
return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { parse, ParseError } from 'jsonc-parser';
|
||||
import * as vscode from 'vscode';
|
||||
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format';
|
||||
import { isDecisionFile, parseDecision } from '../model/decisions';
|
||||
import { prepareDiagram } from '../model/prepare';
|
||||
import { ModelService } from '../model/service';
|
||||
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']);
|
||||
|
||||
function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity {
|
||||
if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) {
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
}
|
||||
switch (diagnostic.severity) {
|
||||
case 'warning':
|
||||
return vscode.DiagnosticSeverity.Warning;
|
||||
case 'info':
|
||||
return vscode.DiagnosticSeverity.Information;
|
||||
default:
|
||||
return vscode.DiagnosticSeverity.Error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `archify validate` on diagram sources and reports into Problems. */
|
||||
export class DiagnosticsController implements vscode.Disposable {
|
||||
private readonly collection = vscode.languages.createDiagnosticCollection('archify');
|
||||
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,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
this.collection,
|
||||
models.onDidChangeModel(() => this.revalidateAll()),
|
||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (event.contentChanges.length) this.schedule(event.document, 700);
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.revalidateAll();
|
||||
}),
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
revalidateAll(): void {
|
||||
for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0);
|
||||
}
|
||||
|
||||
private clear(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.delete(key);
|
||||
this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1);
|
||||
this.collection.delete(uri);
|
||||
}
|
||||
|
||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(doc.fileName) && !isDecisionFile(doc.fileName)) return;
|
||||
const key = doc.uri.toString();
|
||||
clearTimeout(this.timers.get(key));
|
||||
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
||||
}
|
||||
|
||||
private async validate(doc: vscode.TextDocument): Promise<void> {
|
||||
const key = doc.uri.toString();
|
||||
this.timers.delete(key);
|
||||
const seq = (this.sequence.get(key) ?? 0) + 1;
|
||||
this.sequence.set(key, seq);
|
||||
|
||||
const text = doc.getText();
|
||||
if (!this.enabled(doc.uri) || doc.isClosed) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
if (isRegistryFile(doc.fileName)) {
|
||||
await this.validateRegistry(doc, seq);
|
||||
return;
|
||||
}
|
||||
if (isDecisionFile(doc.fileName)) {
|
||||
await this.validateDecision(doc, seq);
|
||||
return;
|
||||
}
|
||||
if (isArchifyFile(doc.fileName)) {
|
||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||
// that does not parse has no diagram to validate yet.
|
||||
const problems = yamlSyntaxProblems(text);
|
||||
if (problems.length) {
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
problems.map((problem) => {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
|
||||
`YAML: ${problem.message}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const prepared = await prepareDiagram(this.models, doc.uri, text);
|
||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||
if (!prepared) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
const source = prepared.source;
|
||||
const modelDiagnostics = prepared.problems
|
||||
.filter((problem) => !problem.file)
|
||||
.map((problem) => this.modelDiagnostic(doc, text, source.format, problem));
|
||||
if (source.format === 'json') {
|
||||
// Leave JSON syntax errors to the JSON language service.
|
||||
const syntax: ParseError[] = [];
|
||||
parse(text, syntax, { allowTrailingComma: false });
|
||||
if (syntax.length) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||
|
||||
if (result.crash) {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(0, 0, 0, 0),
|
||||
`Archify validation did not complete: ${result.crash.split('\n')[0]}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
this.collection.set(doc.uri, [diagnostic]);
|
||||
return;
|
||||
}
|
||||
|
||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||
this.collection.set(doc.uri, [
|
||||
...modelDiagnostics,
|
||||
...result.diagnostics.map((item) => {
|
||||
const pointer = diagnosticPointer(item);
|
||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||
const fix = item.supportedFixes?.[0];
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
range,
|
||||
fix ? `${item.message}\nFix: ${fix}` : item.message,
|
||||
severity(item, downgradeEvidence),
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
if (item.code) diagnostic.code = item.code;
|
||||
return diagnostic;
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private modelDiagnostic(doc: vscode.TextDocument, text: string, format: 'yaml' | 'json', problem: ModelProblem): vscode.Diagnostic {
|
||||
const offsets = format === 'yaml' ? rangeForPointerYaml(text, problem.pointer) : rangeForPointer(text, problem.pointer);
|
||||
const range = problem.line !== undefined && !problem.pointer ? doc.lineAt(Math.min(problem.line, doc.lineCount - 1)).range : new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||
const diagnostic = new vscode.Diagnostic(range, problem.message, problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error);
|
||||
diagnostic.source = 'archify';
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
/** A registry file: its own YAML and element problems, plus duplicates across the model. */
|
||||
private async validateDecision(doc: vscode.TextDocument, seq: number): Promise<void> {
|
||||
const text = doc.getText();
|
||||
const root = await this.models.rootOf(doc.uri);
|
||||
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath).split(path.sep).join('/') : path.basename(doc.uri.fsPath);
|
||||
const problems: ModelProblem[] = [...parseDecision(text, rel).problems];
|
||||
if (root) {
|
||||
try {
|
||||
const index = await this.models.index(root);
|
||||
for (const problem of index.decisions.problems) {
|
||||
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
|
||||
}
|
||||
} catch {
|
||||
// The index reports its own failures when the model page opens.
|
||||
}
|
||||
} else {
|
||||
problems.push({ message: `This decision has lost its model: no main.archify above ${path.basename(doc.uri.fsPath)}, so its elements and diagrams cannot be resolved.`, 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)));
|
||||
}
|
||||
|
||||
private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise<void> {
|
||||
const text = doc.getText();
|
||||
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);
|
||||
for (const problem of index.registry.problems) {
|
||||
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
|
||||
}
|
||||
} catch {
|
||||
// The index reports its own failures when the model page opens.
|
||||
}
|
||||
} else {
|
||||
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)),
|
||||
...schemaProblems.map((problem) => this.schemaDiagnostic(doc, problem)),
|
||||
]);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@
|
||||
import { parseDocument } from 'yaml';
|
||||
import { modelIdFromName, titleFromName } from './templates';
|
||||
|
||||
/**
|
||||
* Example models shipped with the extension (templates/models/<folder>).
|
||||
* "Archify | New Model…" offers them; each is a complete model folder:
|
||||
* main.archify, registry/*.archify-registry and diagrams by level.
|
||||
*/
|
||||
export interface ModelExample {
|
||||
/** Folder under templates/models and the default name of the new model folder. */
|
||||
folder: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const MODEL_EXAMPLES: readonly ModelExample[] = [
|
||||
{ folder: 'c4-standard-web-app', label: 'C4 - Standard Web App', description: 'Single-page app, API, database, cache, file storage, identity provider' },
|
||||
{ folder: 'c4-e-shop', label: 'C4 - E-Shop', description: 'Web shop, API, worker, queue, payment and email providers' },
|
||||
{ folder: 'c4-event-based-integration', label: 'C4 - Event-Based Integration', description: 'Event bus, router, dead-letter queue, event store, consumers' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The example's main.archify for a new model folder. When the folder keeps
|
||||
* the example's name the file is used as written; otherwise id, name and the
|
||||
* overview title follow the folder name and the description is kept.
|
||||
*/
|
||||
export function mainFromExample(mainText: string, folderName: string, example: ModelExample): string {
|
||||
if (folderName === example.folder) return mainText;
|
||||
const doc = parseDocument(mainText);
|
||||
doc.setIn(['model', 'id'], modelIdFromName(folderName));
|
||||
doc.setIn(['model', 'name'], titleFromName(folderName));
|
||||
if (doc.hasIn(['meta', 'title'])) doc.setIn(['meta', 'title'], `${titleFromName(folderName)} — overview`);
|
||||
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||
}
|
||||
@@ -6,6 +6,14 @@ export interface OffsetRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON pointer an Archify diagnostic is about. Most diagnostics carry it
|
||||
* in subject.path; brand diagnostics only start their message with it.
|
||||
*/
|
||||
export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined {
|
||||
return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1];
|
||||
}
|
||||
|
||||
/** Splits an RFC 6901 JSON pointer into path segments. */
|
||||
export function pointerSegments(pointer: string): (string | number)[] {
|
||||
if (!pointer || pointer === '/') return [];
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { RegistryElement } from '../model/format';
|
||||
import { ModelService } from '../model/service';
|
||||
|
||||
export const REF_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }];
|
||||
|
||||
/** `ref: api` or `{ ref: ap` → what has been typed of the value. */
|
||||
export function refValueAt(linePrefix: string): { typed: string } | undefined {
|
||||
const match = /(?:^|[\s{,])ref:\s*["']?([\w.-]*)$/.exec(linePrefix);
|
||||
return match ? { typed: match[1] } : undefined;
|
||||
}
|
||||
|
||||
/** The `ref` value under a position, with its range, or undefined. */
|
||||
export function refAt(line: string, character: number): { id: string; start: number; end: number } | undefined {
|
||||
const match = /(?:^|[\s{,])ref:\s*["']?([\w.-]+)/.exec(line);
|
||||
if (!match) return undefined;
|
||||
const start = match.index + match[0].length - match[1].length;
|
||||
const end = start + match[1].length;
|
||||
return character >= start && character <= end ? { id: match[1], start, end } : undefined;
|
||||
}
|
||||
|
||||
function describe(element: RegistryElement): vscode.MarkdownString {
|
||||
const md = new vscode.MarkdownString();
|
||||
md.appendMarkdown(`**${element.label ?? element.id}**${element.sublabel ? ` · ${element.sublabel}` : ''}${element.type ? ` \`${element.type}\`` : ''}\n\n`);
|
||||
if (element.description) md.appendMarkdown(`${element.description.trim()}\n\n`);
|
||||
if (element.diagram) md.appendMarkdown(`Opens: \`${element.diagram}\`\n\n`);
|
||||
md.appendMarkdown(`*Defined in ${element.file}, line ${element.line + 1}*`);
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Completion, hover and go-to-definition for `ref:` values in diagrams of a model. */
|
||||
export class RefLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
|
||||
constructor(private readonly models: ModelService) {}
|
||||
|
||||
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
|
||||
const value = refValueAt(document.lineAt(position.line).text.slice(0, position.character));
|
||||
if (!value) return undefined;
|
||||
const found = await this.models.registryFor(document.uri);
|
||||
if (!found) return undefined;
|
||||
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||
return [...found.registry.elements.values()].map((element) => {
|
||||
const item = new vscode.CompletionItem({ label: element.id, description: element.label }, vscode.CompletionItemKind.Reference);
|
||||
item.detail = [element.label, element.sublabel, element.type].filter(Boolean).join(' · ');
|
||||
item.documentation = describe(element);
|
||||
item.filterText = [element.id, element.label ?? '', element.sublabel ?? ''].join(' ');
|
||||
item.range = range;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
||||
const ref = refAt(document.lineAt(position.line).text, position.character);
|
||||
if (!ref) return undefined;
|
||||
const found = await this.models.registryFor(document.uri);
|
||||
const element = found?.registry.elements.get(ref.id);
|
||||
const range = new vscode.Range(position.line, ref.start, position.line, ref.end);
|
||||
if (!element) {
|
||||
return new vscode.Hover(found ? `Unknown element \`${ref.id}\`: no registry of this model defines it.` : `\`${ref.id}\` cannot be resolved: this file is not inside a model.`, range);
|
||||
}
|
||||
return new vscode.Hover(describe(element), range);
|
||||
}
|
||||
|
||||
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
||||
const ref = refAt(document.lineAt(position.line).text, position.character);
|
||||
if (!ref) return undefined;
|
||||
const found = await this.models.registryFor(document.uri);
|
||||
const element = found?.registry.elements.get(ref.id);
|
||||
if (!found || !element) return undefined;
|
||||
const file = vscode.Uri.joinPath(found.root, ...element.file.split(/[\\/]/));
|
||||
return new vscode.Location(file, new vscode.Position(element.line, 2));
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
import { isMap, parseDocument } from 'yaml';
|
||||
import { DiagramType } from './detect';
|
||||
import { DiagramType } from '../sources/detect';
|
||||
|
||||
export interface DiagramTemplate {
|
||||
/** File name under templates/. */
|
||||
@@ -9,6 +9,8 @@ export interface DiagramTemplate {
|
||||
description: string;
|
||||
/** Suggested name for the new file, without .archify. */
|
||||
defaultName: string;
|
||||
/** Reserved file name; the user is not asked for one. */
|
||||
fixedName?: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||
@@ -18,6 +20,7 @@ export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
||||
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
|
||||
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
||||
{ file: 'model.archify', type: 'architecture', label: 'Model (main.archify)', description: 'Entry point of a folder of diagrams that belong together', defaultName: 'main', fixedName: 'main.archify' },
|
||||
];
|
||||
|
||||
/** Strips a typed .archify suffix and surrounding spaces. */
|
||||
@@ -40,6 +43,21 @@ export function titleFromName(name: string): string {
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
||||
}
|
||||
|
||||
/** "online-shop" → "online-shop"; "My Shop 2" → "my-shop-2": a model id from a folder name. */
|
||||
export function modelIdFromName(name: string): string {
|
||||
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return /^[a-z]/.test(id) ? id : `m-${id || 'model'}`;
|
||||
}
|
||||
|
||||
/** The model template with id, name and overview title taken from the folder name. */
|
||||
export function modelFromTemplate(templateText: string, folderName: string): string {
|
||||
const doc = parseDocument(templateText);
|
||||
doc.setIn(['model', 'id'], modelIdFromName(folderName));
|
||||
doc.setIn(['model', 'name'], titleFromName(folderName));
|
||||
doc.setIn(['meta', 'title'], `${titleFromName(folderName)} overview`);
|
||||
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Template YAML with meta.title and meta.output set for the new file.
|
||||
* Works on the YAML document so the template's comments and layout stay.
|
||||
@@ -0,0 +1,185 @@
|
||||
import { LineCounter, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||
import { ModelProblem, Registry } from './format';
|
||||
|
||||
/**
|
||||
* Architecture decisions of a model (see docs/model.md): one
|
||||
* `*.archify-decision` file per decision, anywhere under the model root.
|
||||
* Pure functions, no VS Code imports.
|
||||
*/
|
||||
|
||||
export const DECISION_EXT = '.archify-decision';
|
||||
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const;
|
||||
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
|
||||
|
||||
export interface Decision {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
date?: string;
|
||||
/** Registry element ids the decision is about. */
|
||||
elements: string[];
|
||||
/** Diagram paths (relative to the model root) the decision names. */
|
||||
diagrams: string[];
|
||||
/** Ids of older decisions this one replaces. */
|
||||
supersedes: string[];
|
||||
context?: string;
|
||||
decision?: string;
|
||||
consequences?: string;
|
||||
/** Decision file relative to the model root, and 0-based lines for diagnostics. */
|
||||
file: string;
|
||||
line: number;
|
||||
lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] };
|
||||
}
|
||||
|
||||
export function isDecisionFile(fileName: string): boolean {
|
||||
return fileName.endsWith(DECISION_EXT);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined);
|
||||
|
||||
/** "OpenSearch for product search" → "opensearch-for-product-search". */
|
||||
export function slugify(title: string): string {
|
||||
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'decision';
|
||||
}
|
||||
|
||||
/** Parses one decision file. Problems carry 0-based lines. */
|
||||
export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } {
|
||||
const problems: ModelProblem[] = [];
|
||||
// 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: 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);
|
||||
const root = doc.contents;
|
||||
if (!isMap(root)) {
|
||||
problems.push({ message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 });
|
||||
return { problems };
|
||||
}
|
||||
const headNode = root.get('decision', true);
|
||||
if (!isMap(headNode)) {
|
||||
problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' });
|
||||
return { problems };
|
||||
}
|
||||
const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision');
|
||||
const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0;
|
||||
const keyLine = (key: string) => {
|
||||
const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key);
|
||||
return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine;
|
||||
};
|
||||
const list = (key: string): { values: string[]; lines: number[] } => {
|
||||
const node = headNode.get(key, true);
|
||||
const values: string[] = [];
|
||||
const lines: number[] = [];
|
||||
if (isSeq(node)) {
|
||||
for (const item of node.items) {
|
||||
if (!isScalar(item)) continue;
|
||||
if (typeof item.value === 'number') {
|
||||
problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` });
|
||||
continue;
|
||||
}
|
||||
if (str(item.value)) {
|
||||
values.push(String(item.value));
|
||||
lines.push(lineOf(item.range?.[0]));
|
||||
}
|
||||
}
|
||||
} else if (node !== undefined) {
|
||||
problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` });
|
||||
}
|
||||
return { values, lines };
|
||||
};
|
||||
const head = headNode.toJSON() as Record<string, unknown>;
|
||||
if (typeof head.id === 'number') {
|
||||
problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' });
|
||||
return { problems };
|
||||
}
|
||||
const id = str(head.id);
|
||||
if (!id) {
|
||||
problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' });
|
||||
return { problems };
|
||||
}
|
||||
const status = str(head.status);
|
||||
if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) {
|
||||
problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' });
|
||||
}
|
||||
const elements = list('elements');
|
||||
const diagrams = list('diagrams');
|
||||
const supersedes = list('supersedes');
|
||||
const body = root.toJSON() as Record<string, unknown>;
|
||||
return {
|
||||
problems,
|
||||
decision: {
|
||||
id,
|
||||
title: str(head.title),
|
||||
status,
|
||||
date: str(head.date),
|
||||
elements: elements.values,
|
||||
diagrams: diagrams.values,
|
||||
supersedes: supersedes.values,
|
||||
context: str(body.context),
|
||||
decision: str(body.decision),
|
||||
consequences: str(body.consequences),
|
||||
file,
|
||||
line: headLine,
|
||||
lines: { id: keyLine('id'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DecisionSet {
|
||||
decisions: Map<string, Decision>;
|
||||
files: string[];
|
||||
problems: ModelProblem[];
|
||||
}
|
||||
|
||||
/** Merges parsed decision files; an id used twice is an error on the second file. */
|
||||
export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet {
|
||||
const decisions = new Map<string, Decision>();
|
||||
const problems: ModelProblem[] = [];
|
||||
for (const part of parts) {
|
||||
problems.push(...part.problems);
|
||||
if (!part.decision) continue;
|
||||
const existing = decisions.get(part.decision.id);
|
||||
if (existing) {
|
||||
problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id });
|
||||
continue;
|
||||
}
|
||||
decisions.set(part.decision.id, part.decision);
|
||||
}
|
||||
return { decisions, files: parts.map((p) => p.file), problems };
|
||||
}
|
||||
|
||||
/** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */
|
||||
export function effectiveStatus(decision: Decision, all: Map<string, Decision>): { status: string; supersededBy?: string } {
|
||||
for (const other of all.values()) {
|
||||
if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id };
|
||||
}
|
||||
return { status: decision.status ?? 'proposed' };
|
||||
}
|
||||
|
||||
/** Link checks across the model: elements, diagrams and supersedes must exist. */
|
||||
export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set<string>): ModelProblem[] {
|
||||
const problems: ModelProblem[] = [];
|
||||
for (const d of set.decisions.values()) {
|
||||
d.elements.forEach((id, i) => {
|
||||
if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` });
|
||||
});
|
||||
d.diagrams.forEach((path, i) => {
|
||||
if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` });
|
||||
});
|
||||
d.supersedes.forEach((id, i) => {
|
||||
if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
||||
else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
||||
});
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Sort for lists: live decisions newest first, superseded and rejected last. */
|
||||
export function sortDecisions(decisions: Decision[], all: Map<string, Decision>): Decision[] {
|
||||
const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0);
|
||||
return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id));
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { LineCounter, isMap, isScalar, parse as parseYaml, parseDocument } from 'yaml';
|
||||
|
||||
/**
|
||||
* The model file formats (see docs/model.md): `main.archify` with a `model`
|
||||
* block, `*.archify-registry` files with shared elements, and `ref`
|
||||
* components in diagrams. Pure functions, no VS Code imports, so they run
|
||||
* in unit tests and can be reused by other tools.
|
||||
*/
|
||||
|
||||
export const MAIN_FILE = 'main.archify';
|
||||
export const REGISTRY_EXT = '.archify-registry';
|
||||
|
||||
/** Component fields copied from a registry element into every diagram that refers to it. */
|
||||
export const COPIED_FIELDS = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources'] as const;
|
||||
/** The subset a diagram may override on a `ref` component. */
|
||||
export const OVERRIDABLE_FIELDS = ['label', 'sublabel', 'tag'] as const;
|
||||
|
||||
export interface ModelMeta {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RegistryElement {
|
||||
id: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
tag?: string;
|
||||
icon?: unknown;
|
||||
brand?: string;
|
||||
sources?: unknown;
|
||||
description?: string;
|
||||
/** Diagram that explains the element, relative to the model root. */
|
||||
diagram?: string;
|
||||
/** Registry file that defines it, and the 0-based line of its key. */
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ModelProblem {
|
||||
message: string;
|
||||
severity: 'error' | 'warning';
|
||||
/** File the problem is about; defaults to the diagram being resolved. */
|
||||
file?: string;
|
||||
/** JSON pointer into that file's document, when it is a diagram. */
|
||||
pointer?: string;
|
||||
/** 0-based line, when known (registry files). */
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
elements: Map<string, RegistryElement>;
|
||||
files: string[];
|
||||
problems: ModelProblem[];
|
||||
}
|
||||
|
||||
export function isMainFile(fileName: string): boolean {
|
||||
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
|
||||
}
|
||||
|
||||
export function isRegistryFile(fileName: string): boolean {
|
||||
return fileName.endsWith(REGISTRY_EXT);
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const isObj = (v: unknown): v is Obj => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined);
|
||||
|
||||
/** The `model` block of a `main.archify` document, if present. */
|
||||
export function readModelMeta(doc: unknown): ModelMeta | undefined {
|
||||
if (!isObj(doc) || !isObj(doc.model)) return undefined;
|
||||
const { id, name, description } = doc.model;
|
||||
return { id: str(id), name: str(name), description: str(description) };
|
||||
}
|
||||
|
||||
/** True when the document also carries a diagram (a `diagram_type`). */
|
||||
export function hasDiagramBody(doc: unknown): boolean {
|
||||
return isObj(doc) && typeof doc.diagram_type === 'string';
|
||||
}
|
||||
|
||||
/** Parses one registry file. Line numbers come from the YAML document. */
|
||||
export function parseRegistry(text: string, file: string): { elements: RegistryElement[]; problems: ModelProblem[] } {
|
||||
const problems: ModelProblem[] = [];
|
||||
const elements: RegistryElement[] = [];
|
||||
// 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: 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);
|
||||
const root = doc.contents;
|
||||
if (!isMap(root)) {
|
||||
problems.push({ message: 'A registry must be a mapping with an "elements" key.', severity: 'error', file, line: 0 });
|
||||
return { elements, problems };
|
||||
}
|
||||
const elementsNode = root.get('elements', true);
|
||||
if (!isMap(elementsNode)) {
|
||||
problems.push({ message: 'A registry needs an "elements" mapping (id → element).', severity: 'error', file, line: 0 });
|
||||
return { elements, problems };
|
||||
}
|
||||
for (const pair of elementsNode.items) {
|
||||
const key = pair.key;
|
||||
const id = isScalar(key) ? String(key.value) : undefined;
|
||||
const line = isScalar(key) ? lineOf(key.range?.[0]) : 0;
|
||||
if (!id) continue;
|
||||
const value = isMap(pair.value) ? (pair.value.toJSON() as Obj) : undefined;
|
||||
if (!value) {
|
||||
problems.push({ message: `Element "${id}" must be a mapping (type, label, description, …).`, severity: 'error', file, line });
|
||||
continue;
|
||||
}
|
||||
elements.push({
|
||||
id,
|
||||
type: str(value.type),
|
||||
label: str(value.label),
|
||||
sublabel: str(value.sublabel),
|
||||
tag: str(value.tag),
|
||||
icon: value.icon,
|
||||
brand: str(value.brand),
|
||||
sources: value.sources,
|
||||
description: str(value.description),
|
||||
diagram: str(value.diagram),
|
||||
file,
|
||||
line,
|
||||
});
|
||||
}
|
||||
return { elements, problems };
|
||||
}
|
||||
|
||||
/** Merges parsed registry files; an id defined twice is an error on the second definition. */
|
||||
export function mergeRegistries(parts: { elements: RegistryElement[]; problems: ModelProblem[]; file: string }[]): Registry {
|
||||
const elements = new Map<string, RegistryElement>();
|
||||
const problems: ModelProblem[] = [];
|
||||
for (const part of parts) {
|
||||
problems.push(...part.problems);
|
||||
for (const element of part.elements) {
|
||||
const existing = elements.get(element.id);
|
||||
if (existing) {
|
||||
problems.push({
|
||||
message: `Element "${element.id}" is defined twice (also in ${existing.file}, line ${existing.line + 1}).`,
|
||||
severity: 'error',
|
||||
file: element.file,
|
||||
line: element.line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
elements.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return { elements, files: parts.map((p) => p.file), problems };
|
||||
}
|
||||
|
||||
/** Where a diagram attaches a decision: the diagram itself, a node or an edge. */
|
||||
export type DecisionAttachment = { kind: 'diagram' } | { kind: 'node'; id: string } | { kind: 'edge'; from: string; to: string; label?: string };
|
||||
|
||||
export interface DecisionRef {
|
||||
id: string;
|
||||
pointer: string;
|
||||
attachedTo: DecisionAttachment;
|
||||
}
|
||||
|
||||
/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */
|
||||
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const;
|
||||
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const;
|
||||
|
||||
export interface ResolvedDiagram {
|
||||
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
|
||||
diagram: Obj;
|
||||
problems: ModelProblem[];
|
||||
/** Component index → the registry element it came from. */
|
||||
refs: Map<number, RegistryElement>;
|
||||
/** Node id → diagram path (relative to the model root) it links to. */
|
||||
links: Map<string, string>;
|
||||
/** Registry element ids used by this diagram. */
|
||||
used: Set<string>;
|
||||
/** Node id → registry description, for the Elements panel. */
|
||||
descriptions: Map<string, string>;
|
||||
/** Decisions the diagram points at, with where they are attached. */
|
||||
decisionRefs: DecisionRef[];
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Undefined when the diagram is not inside a model. */
|
||||
registry?: Registry;
|
||||
/** Known decision ids of the model; undefined outside a model or when unknown. */
|
||||
decisions?: Set<string>;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/** Strips `decisions` keys from a diagram and collects what they point at. Mutates `diagram`. */
|
||||
function takeDecisionRefs(diagram: Obj, options: ResolveOptions, problems: ModelProblem[]): DecisionRef[] {
|
||||
const refs: DecisionRef[] = [];
|
||||
const take = (owner: Obj, pointer: string, attachedTo: DecisionAttachment) => {
|
||||
if (!('decisions' in owner)) return;
|
||||
const raw = owner.decisions;
|
||||
delete owner.decisions;
|
||||
const ids = Array.isArray(raw) ? raw : [raw];
|
||||
ids.forEach((value, i) => {
|
||||
const id = value === undefined || value === null ? undefined : String(value);
|
||||
if (!id) return;
|
||||
const at = `${pointer}/decisions/${i}`;
|
||||
if (typeof value === 'number') {
|
||||
problems.push({ message: `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, severity: 'error', pointer: at });
|
||||
return;
|
||||
}
|
||||
if (!options.registry) {
|
||||
problems.push({ message: `Decision "${id}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`, severity: 'error', pointer: at });
|
||||
} else if (options.decisions && !options.decisions.has(id)) {
|
||||
problems.push({ message: `Unknown decision "${id}": no .archify-decision file of this model defines it.`, severity: 'error', pointer: at });
|
||||
}
|
||||
refs.push({ id, pointer: at, attachedTo });
|
||||
});
|
||||
};
|
||||
take(diagram, '', { kind: 'diagram' });
|
||||
for (const key of NODE_COLLECTIONS) {
|
||||
const items = diagram[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
diagram[key] = items.map((item, index) => {
|
||||
if (!isObj(item)) return item;
|
||||
const copy = { ...item };
|
||||
take(copy, `/${key}/${index}`, { kind: 'node', id: str(copy.id) ?? str(copy.ref) ?? String(index) });
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
for (const key of EDGE_COLLECTIONS) {
|
||||
const items = diagram[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
diagram[key] = items.map((item, index) => {
|
||||
if (!isObj(item)) return item;
|
||||
const copy = { ...item };
|
||||
take(copy, `/${key}/${index}`, { kind: 'edge', from: str(copy.from) ?? '', to: str(copy.to) ?? '', label: str(copy.label) });
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands `ref` components from the registry and strips what Archify does
|
||||
* not know. Component order and indexes are kept, so Archify's diagnostics
|
||||
* (JSON pointers) still point at the right component in the source file.
|
||||
*/
|
||||
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
|
||||
const problems: ModelProblem[] = [];
|
||||
const refs = new Map<number, RegistryElement>();
|
||||
const links = new Map<string, string>();
|
||||
const used = new Set<string>();
|
||||
const descriptions = new Map<string, string>();
|
||||
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions, decisionRefs: [] };
|
||||
const diagram: Obj = { ...doc };
|
||||
delete diagram.model;
|
||||
const decisionRefs = takeDecisionRefs(diagram, options, problems);
|
||||
const { registry } = options;
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : undefined;
|
||||
if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
|
||||
diagram.components = components.map((raw, index) => {
|
||||
if (!isObj(raw)) return raw;
|
||||
const ref = str(raw.ref);
|
||||
const linkFromComponent = str(raw.diagram);
|
||||
if (!ref) {
|
||||
const { diagram: _link, ...plain } = raw;
|
||||
const id = str(plain.id);
|
||||
if (id && linkFromComponent) links.set(id, linkFromComponent);
|
||||
return plain;
|
||||
}
|
||||
const { ref: _ref, diagram: _link, ...own } = raw;
|
||||
const element = registry?.elements.get(ref);
|
||||
const id = str(own.id) ?? ref;
|
||||
if (!element) {
|
||||
problems.push({
|
||||
message: registry
|
||||
? `Unknown element "${ref}": no registry of this model defines it.`
|
||||
: `"${ref}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`,
|
||||
severity: 'error',
|
||||
pointer: `/components/${index}/ref`,
|
||||
});
|
||||
return { type: 'external', label: ref, ...own, id };
|
||||
}
|
||||
used.add(ref);
|
||||
refs.set(index, element);
|
||||
if (element.description) descriptions.set(id, element.description.trim());
|
||||
for (const key of Object.keys(own)) {
|
||||
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
|
||||
problems.push({
|
||||
message: `"${key}" comes from the registry element "${ref}" and cannot be changed in a diagram.`,
|
||||
severity: 'error',
|
||||
pointer: `/components/${index}/${key}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const merged: Obj = { id };
|
||||
for (const key of COPIED_FIELDS) if (element[key] !== undefined) merged[key] = element[key];
|
||||
for (const [key, value] of Object.entries(own)) {
|
||||
if (key === 'id') continue;
|
||||
if (!(COPIED_FIELDS as readonly string[]).includes(key) || (OVERRIDABLE_FIELDS as readonly string[]).includes(key)) merged[key] = value;
|
||||
}
|
||||
const link = linkFromComponent ?? element.diagram;
|
||||
if (link) links.set(id, link);
|
||||
return merged;
|
||||
});
|
||||
return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
}
|
||||
|
||||
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
|
||||
export function groupOf(relativePath: string): string {
|
||||
const parts = relativePath.split(/[\\/]/);
|
||||
parts.pop();
|
||||
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;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
/** What the model page needs to know about one diagram file. */
|
||||
export function summarizeDiagram(text: string, relativePath: string): DiagramSummary {
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||
} catch (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 }, []);
|
||||
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. */
|
||||
export function sortedGroups(summaries: DiagramSummary[]): { group: string; diagrams: DiagramSummary[] }[] {
|
||||
const byGroup = new Map<string, DiagramSummary[]>();
|
||||
for (const s of summaries) byGroup.set(s.group, [...(byGroup.get(s.group) ?? []), s]);
|
||||
const groups = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
|
||||
return groups.map((group) => ({ group, diagrams: byGroup.get(group)!.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) }));
|
||||
}
|
||||
@@ -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,194 @@
|
||||
import { Decision, effectiveStatus, sortDecisions } from './decisions';
|
||||
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
|
||||
import { ModelIndex } from './service';
|
||||
|
||||
/** A decision as the lists show it. */
|
||||
export interface DecisionRow {
|
||||
id: string;
|
||||
title?: string;
|
||||
/** Status shown: superseded when another decision supersedes it. */
|
||||
status: string;
|
||||
supersededBy?: string;
|
||||
date?: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
/** A diagram an element is used in, as the pages show it. */
|
||||
export interface UsedIn {
|
||||
path: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/** A registry element as the model page and the registry page show it. */
|
||||
export interface ElementCard {
|
||||
id: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
type?: string;
|
||||
brand?: string;
|
||||
description?: string;
|
||||
/** Diagram that explains the element, relative to the model root. */
|
||||
diagram?: string;
|
||||
/** Registry file (relative to the model root) and 0-based line of the element. */
|
||||
file: string;
|
||||
line: number;
|
||||
usedIn: UsedIn[];
|
||||
/** Decisions about this element, from the decision files and from diagram attachments. */
|
||||
decisions: DecisionRow[];
|
||||
}
|
||||
|
||||
/** What the model page shows; serialised into the webview. */
|
||||
export interface ModelPanel {
|
||||
meta: { id?: string; name?: string; description?: string };
|
||||
/** True when main.archify also carries a diagram, shown between metadata and the list. */
|
||||
hasDiagram: boolean;
|
||||
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
|
||||
registry: ElementCard[];
|
||||
decisions: DecisionRow[];
|
||||
problems: string[];
|
||||
nested: string[];
|
||||
}
|
||||
|
||||
/** What the decision page (one .archify-decision file) shows. */
|
||||
export interface DecisionPanel {
|
||||
file: string;
|
||||
inModel: boolean;
|
||||
model?: { id?: string; name?: string };
|
||||
decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] };
|
||||
/** Decisions that supersede this one. */
|
||||
supersededBy: DecisionRow[];
|
||||
elements: ElementCard[];
|
||||
/** Diagrams the decision names. */
|
||||
diagrams: UsedIn[];
|
||||
/** Diagrams that point at the decision, with where. */
|
||||
referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[];
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
/** What the registry page (one .archify-registry file) shows. */
|
||||
export interface RegistryPanel {
|
||||
/** The registry file, relative to the model root (or its base name outside a model). */
|
||||
file: string;
|
||||
inModel: boolean;
|
||||
model?: { id?: string; name?: string };
|
||||
elements: ElementCard[];
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
function usedIn(id: string, usage: Map<string, string[]>, diagrams: DiagramSummary[]): UsedIn[] {
|
||||
return (usage.get(id) ?? []).map((path) => {
|
||||
const summary = diagrams.find((d) => d.relativePath === path);
|
||||
return { path, title: summary?.title, kind: summary?.kind };
|
||||
});
|
||||
}
|
||||
|
||||
export function decisionRow(d: Decision, all: Map<string, Decision>): DecisionRow {
|
||||
const { status, supersededBy } = effectiveStatus(d, all);
|
||||
return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file };
|
||||
}
|
||||
|
||||
/** Decisions about an element: named by the decision, or attached to its node in some diagram. */
|
||||
function decisionsOfElement(id: string, index: Pick<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
|
||||
if (!index) return [];
|
||||
const all = index.decisions.decisions;
|
||||
const ids = new Set<string>();
|
||||
for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id);
|
||||
for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId);
|
||||
return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all));
|
||||
}
|
||||
|
||||
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): ElementCard {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sublabel: e.sublabel,
|
||||
type: e.type,
|
||||
brand: e.brand,
|
||||
description: e.description,
|
||||
diagram: e.diagram,
|
||||
file: e.file,
|
||||
line: e.line,
|
||||
usedIn: usedIn(e.id, usage, diagrams),
|
||||
decisions: decisionsOfElement(e.id, index),
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
|
||||
|
||||
export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams, index));
|
||||
const all = index.decisions.decisions;
|
||||
return {
|
||||
meta: index.meta,
|
||||
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
|
||||
hasDiagram: index.mainHasDiagram,
|
||||
groups: index.groups.map((g) => ({
|
||||
group: g.group,
|
||||
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
|
||||
})),
|
||||
registry,
|
||||
problems: [...index.registry.problems, ...index.decisions.problems].map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
|
||||
nested: index.nested,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of one file inside a model: its elements, in file order, with usage. */
|
||||
export function registryPanelFromIndex(index: ModelIndex, file: string): RegistryPanel {
|
||||
const elements = [...index.registry.elements.values()]
|
||||
.filter((e) => e.file === file)
|
||||
.sort((a, b) => a.line - b.line)
|
||||
.map((e) => elementCard(e, index.usage, index.diagrams, index));
|
||||
return {
|
||||
file,
|
||||
inModel: true,
|
||||
model: { id: index.meta.id, name: index.meta.name },
|
||||
elements,
|
||||
problems: index.registry.problems.filter((p) => p.file === file).map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of a file outside a model: elements only, nothing is used anywhere. */
|
||||
export function registryPanelStandalone(file: string, parsed: { elements: RegistryElement[]; problems: { message: string }[] }): RegistryPanel {
|
||||
return {
|
||||
file,
|
||||
inModel: false,
|
||||
elements: parsed.elements.map((e) => elementCard(e, new Map(), [])),
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** The decision page of one file inside a model. */
|
||||
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
|
||||
const all = index.decisions.decisions;
|
||||
const d = [...all.values()].find((x) => x.file === file);
|
||||
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
|
||||
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
|
||||
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
|
||||
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
|
||||
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
|
||||
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
|
||||
return {
|
||||
...base,
|
||||
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
|
||||
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
|
||||
elements,
|
||||
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
|
||||
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
|
||||
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
|
||||
const d = parsed.decision;
|
||||
return {
|
||||
file,
|
||||
inModel: false,
|
||||
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
|
||||
supersededBy: [],
|
||||
elements: [],
|
||||
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
|
||||
referencedBy: [],
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { DiagramSource, readDiagramSource } from '../sources/detect';
|
||||
import { DecisionAttachment, ModelProblem, Registry, resolveRefs } from './format';
|
||||
import { effectiveStatus } from './decisions';
|
||||
import { ModelService } from './service';
|
||||
|
||||
/**
|
||||
* A diagram ready for Archify: the source as written, plus the resolved JSON
|
||||
* with registry references expanded, the model problems found on the way,
|
||||
* and the diagram links the viewer should offer.
|
||||
*/
|
||||
export interface PreparedDiagram {
|
||||
source: DiagramSource;
|
||||
/** Model root when the file is inside a model. */
|
||||
root?: vscode.Uri;
|
||||
registry?: Registry;
|
||||
problems: ModelProblem[];
|
||||
/** Node id → diagram path relative to the model root. */
|
||||
links: Map<string, string>;
|
||||
/** Node id → registry description. */
|
||||
descriptions: Map<string, string>;
|
||||
/** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */
|
||||
decisions: DiagramDecision[];
|
||||
}
|
||||
|
||||
export interface DiagramDecision {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
file: string;
|
||||
attachedTo: DecisionAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a diagram and resolves it against its model, if any. Every place
|
||||
* that hands a diagram to Archify (preview, Problems, render, browser) goes
|
||||
* through here, so they all see the same resolved diagram.
|
||||
*/
|
||||
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) return undefined;
|
||||
const index = await models.indexFor(uri);
|
||||
const found = index ? { root: index.root, registry: index.registry } : undefined;
|
||||
const resolved = resolveRefs(source.diagram, { registry: found?.registry, decisions: index ? new Set(index.decisions.decisions.keys()) : undefined, fileName: uri.path });
|
||||
// Only diagrams that use refs (or carry a model block) need the resolved form.
|
||||
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0 || resolved.decisionRefs.length > 0;
|
||||
const prepared: DiagramSource = changed
|
||||
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
|
||||
: source;
|
||||
// Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram.
|
||||
const decisions: DiagramDecision[] = [];
|
||||
if (index) {
|
||||
const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/');
|
||||
const byId = new Map<string, DiagramDecision>();
|
||||
const add = (id: string, attachedTo?: DecisionAttachment) => {
|
||||
const d = index.decisions.decisions.get(id);
|
||||
if (!d) return;
|
||||
let row = byId.get(id);
|
||||
if (!row) {
|
||||
row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] };
|
||||
byId.set(id, row);
|
||||
decisions.push(row);
|
||||
}
|
||||
if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo);
|
||||
};
|
||||
for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo);
|
||||
for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' });
|
||||
}
|
||||
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions };
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { log } from '../util/log';
|
||||
import {
|
||||
DecisionAttachment,
|
||||
DiagramSummary,
|
||||
MAIN_FILE,
|
||||
ModelMeta,
|
||||
REGISTRY_EXT,
|
||||
Registry,
|
||||
hasDiagramBody,
|
||||
isMainFile,
|
||||
isRegistryFile,
|
||||
mergeRegistries,
|
||||
parseRegistry,
|
||||
readModelMeta,
|
||||
sortedGroups,
|
||||
summarizeDiagram,
|
||||
} from './format';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { DECISION_EXT, DecisionSet, isDecisionFile, mergeDecisions, parseDecision, validateDecisions } from './decisions';
|
||||
|
||||
/**
|
||||
* Finds models in the workspace and keeps what they contain. A model is the
|
||||
* folder tree under a `main.archify`; this service answers "which model is
|
||||
* this file in?", loads the model's registry, lists its diagrams, and tells
|
||||
* listeners when any of that changes.
|
||||
*/
|
||||
|
||||
export interface ModelIndex {
|
||||
root: vscode.Uri;
|
||||
main: vscode.Uri;
|
||||
meta: ModelMeta;
|
||||
/** True when main.archify also carries a diagram. */
|
||||
mainHasDiagram: boolean;
|
||||
registry: Registry;
|
||||
diagrams: DiagramSummary[];
|
||||
groups: { group: string; diagrams: DiagramSummary[] }[];
|
||||
/** Element id → diagrams (relative paths) that refer to it. */
|
||||
usage: Map<string, string[]>;
|
||||
/** The model's decisions, with link problems already checked. */
|
||||
decisions: DecisionSet;
|
||||
/** Decision id → diagrams that point at it, with where. */
|
||||
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
|
||||
/** Nested main.archify files found below the root; not supported. */
|
||||
nested: string[];
|
||||
}
|
||||
|
||||
const SKIP = '{**/node_modules/**,**/.git/**}';
|
||||
|
||||
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function text(bytes: Uint8Array): string {
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
|
||||
/** Open editor text when the file is open, else the file on disk. */
|
||||
async function readText(uri: vscode.Uri): Promise<string> {
|
||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||
if (open) return open.getText();
|
||||
return text(await vscode.workspace.fs.readFile(uri));
|
||||
}
|
||||
|
||||
export class ModelService implements vscode.Disposable {
|
||||
private readonly roots = new Map<string, vscode.Uri | null>();
|
||||
private readonly indexes = new Map<string, Promise<ModelIndex>>();
|
||||
private readonly changeEmitter = new vscode.EventEmitter<{ root: vscode.Uri; file: vscode.Uri }>();
|
||||
/** Fires with the model root and the file under it that changed. */
|
||||
readonly onDidChangeModel = this.changeEmitter.event;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor() {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT},${DECISION_EXT}}`);
|
||||
const changed = (uri: vscode.Uri) => void this.onFileChanged(uri);
|
||||
this.disposables = [
|
||||
watcher,
|
||||
watcher.onDidCreate(changed),
|
||||
watcher.onDidDelete(changed),
|
||||
watcher.onDidChange(changed),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
// Registry edits in the editor should re-render open diagrams before a save.
|
||||
if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName) || isDecisionFile(event.document.fileName)) changed(event.document.uri);
|
||||
}),
|
||||
this.changeEmitter,
|
||||
];
|
||||
}
|
||||
|
||||
private async onFileChanged(uri: vscode.Uri): Promise<void> {
|
||||
const name = path.basename(uri.path);
|
||||
if (name === MAIN_FILE) this.roots.clear(); // a model may have appeared or disappeared
|
||||
const root = await this.rootOf(uri);
|
||||
if (!root) return;
|
||||
this.indexes.delete(root.toString());
|
||||
this.changeEmitter.fire({ root, file: uri });
|
||||
}
|
||||
|
||||
/** The model root (folder with main.archify) above a file, or undefined. */
|
||||
async rootOf(uri: vscode.Uri): Promise<vscode.Uri | undefined> {
|
||||
if (uri.scheme !== 'file') return undefined;
|
||||
const dirKey = path.dirname(uri.fsPath);
|
||||
const cached = this.roots.get(dirKey);
|
||||
if (cached !== undefined) return cached ?? undefined;
|
||||
let dir = dirKey;
|
||||
const visited: string[] = [];
|
||||
let found: vscode.Uri | null = null;
|
||||
for (;;) {
|
||||
visited.push(dir);
|
||||
if (await exists(vscode.Uri.file(path.join(dir, MAIN_FILE)))) {
|
||||
found = vscode.Uri.file(dir);
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
for (const d of visited) this.roots.set(d, found);
|
||||
return found ?? undefined;
|
||||
}
|
||||
|
||||
/** The model index of the model a file belongs to, or undefined outside a model. */
|
||||
async indexFor(uri: vscode.Uri): Promise<ModelIndex | undefined> {
|
||||
const root = await this.rootOf(uri);
|
||||
return root ? this.index(root) : undefined;
|
||||
}
|
||||
|
||||
/** The registry of the model a file belongs to, or undefined outside a model. */
|
||||
async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> {
|
||||
const root = await this.rootOf(uri);
|
||||
if (!root) return undefined;
|
||||
const index = await this.index(root);
|
||||
return { root, registry: index.registry };
|
||||
}
|
||||
|
||||
/** Everything known about a model, cached until a file under it changes. */
|
||||
index(root: vscode.Uri): Promise<ModelIndex> {
|
||||
const key = root.toString();
|
||||
let pending = this.indexes.get(key);
|
||||
if (!pending) {
|
||||
pending = this.build(root).catch((error) => {
|
||||
this.indexes.delete(key);
|
||||
throw error;
|
||||
});
|
||||
this.indexes.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async build(root: vscode.Uri): Promise<ModelIndex> {
|
||||
const main = vscode.Uri.joinPath(root, MAIN_FILE);
|
||||
let meta: ModelMeta = {};
|
||||
let mainHasDiagram = false;
|
||||
try {
|
||||
const doc = parseYaml(await readText(main), { prettyErrors: false, uniqueKeys: false });
|
||||
meta = readModelMeta(doc) ?? {};
|
||||
mainHasDiagram = hasDiagramBody(doc);
|
||||
} catch (error) {
|
||||
log().warn(`main.archify of ${root.fsPath} does not parse: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const registryFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${REGISTRY_EXT}`), SKIP);
|
||||
const parts = await Promise.all(
|
||||
registryFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
|
||||
const rel = path.relative(root.fsPath, file.fsPath);
|
||||
return { file: rel, ...parseRegistry(await readText(file), rel) };
|
||||
}),
|
||||
);
|
||||
const registry = mergeRegistries(parts);
|
||||
|
||||
const diagramFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.archify'), SKIP);
|
||||
const nested: string[] = [];
|
||||
const diagrams: DiagramSummary[] = [];
|
||||
for (const file of diagramFiles) {
|
||||
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
|
||||
if (rel === MAIN_FILE) continue;
|
||||
if (path.basename(file.fsPath) === MAIN_FILE) {
|
||||
nested.push(rel);
|
||||
continue;
|
||||
}
|
||||
diagrams.push(summarizeDiagram(await readText(file), rel));
|
||||
}
|
||||
const usage = new Map<string, string[]>();
|
||||
for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]);
|
||||
|
||||
const decisionFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${DECISION_EXT}`), SKIP);
|
||||
const decisionParts = await Promise.all(
|
||||
decisionFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
|
||||
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
|
||||
return { file: rel, ...parseDecision(await readText(file), rel) };
|
||||
}),
|
||||
);
|
||||
const decisions = mergeDecisions(decisionParts);
|
||||
decisions.problems.push(...validateDecisions(decisions, registry, new Set(diagrams.map((d) => d.relativePath))));
|
||||
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 { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, decisions, decisionUsage, nested };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { readDiagramSource } from './detect';
|
||||
import { renderDiagram } from './renderer';
|
||||
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
|
||||
interface PreviewState {
|
||||
uri: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
class DiagramPreview implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private hash: string | undefined;
|
||||
private lastHtml: string | undefined;
|
||||
private rendered = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
state?: PreviewState,
|
||||
/** True when this is the custom editor of the file, not a side preview. */
|
||||
readonly isEditor = false,
|
||||
) {
|
||||
this.hash = state?.hash;
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||
if (!isEditor) {
|
||||
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||
}
|
||||
|
||||
this.disposables.push(
|
||||
panel.onDidDispose(() => this.dispose()),
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message.type === 'hash') {
|
||||
this.hash = message.hash;
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
);
|
||||
const onDisk = () => {
|
||||
// Open documents are tracked through text change events instead.
|
||||
if (!vscode.workspace.textDocuments.some((doc) => doc.uri.toString() === uri.toString())) this.scheduleUpdate();
|
||||
};
|
||||
this.disposables.push(watcher, watcher.onDidChange(onDisk), watcher.onDidCreate(onDisk));
|
||||
}
|
||||
|
||||
this.showMessage('Rendering…', `Rendering ${path.basename(uri.path)} with Archify.`);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
private state(): PreviewState {
|
||||
return { uri: this.uri.toString(), hash: this.hash };
|
||||
}
|
||||
|
||||
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() },
|
||||
title,
|
||||
body,
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), delay);
|
||||
}
|
||||
|
||||
async update(force = false): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
const seq = ++this.seq;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readText(this.uri);
|
||||
} catch (error) {
|
||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
const source = readDiagramSource(text, this.uri.path);
|
||||
if (!source) {
|
||||
this.showMessage(
|
||||
'Nothing to show yet',
|
||||
'Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid JSON.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, this.uri);
|
||||
result = await renderDiagram(runtime, source, evidenceMode(this.uri));
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
if (seq !== this.seq) return; // a newer render superseded this one
|
||||
|
||||
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({
|
||||
type: 'notice',
|
||||
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
|
||||
});
|
||||
} else {
|
||||
this.showMessage('Archify could not render this diagram', 'Fix the problems below (also listed in the Problems panel) and save.', result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const html = prepareDiagramHtml(result.html, {
|
||||
cspSource: this.panel.webview.cspSource,
|
||||
nonce: makeNonce(),
|
||||
theme: viewerTheme(),
|
||||
hash: this.hash,
|
||||
state: this.state(),
|
||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||
});
|
||||
// Nonces differ per render, so compare the renderer output instead.
|
||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
}
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = true;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.onDisposeEmitter.fire();
|
||||
this.onDisposeEmitter.dispose();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function firstLines(text: string | undefined, count = 6): string {
|
||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||
}
|
||||
|
||||
export class PreviewManager
|
||||
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
||||
{
|
||||
/** Side previews, one per source file. */
|
||||
private readonly previews = new Map<string, DiagramPreview>();
|
||||
/** Custom-editor tabs of .archify files (several per file are allowed). */
|
||||
private readonly editors = new Set<DiagramPreview>();
|
||||
private active: DiagramPreview | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
this.disposables.push(
|
||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
|
||||
for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
for (const preview of this.forUri(doc.uri)) void preview.update();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.refreshAll();
|
||||
}),
|
||||
vscode.window.onDidChangeActiveColorTheme(() => this.refreshAll()),
|
||||
);
|
||||
}
|
||||
|
||||
private liveUpdate(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('preview.liveUpdate', true);
|
||||
}
|
||||
|
||||
get activePreview(): DiagramPreview | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private forUri(uri: vscode.Uri): DiagramPreview[] {
|
||||
const key = uri.toString();
|
||||
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
|
||||
}
|
||||
|
||||
/** 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: [] };
|
||||
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
||||
}
|
||||
|
||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||
const existing = this.previews.get(uri.toString());
|
||||
if (existing) {
|
||||
existing.panel.reveal(viewColumn, preserveFocus);
|
||||
return;
|
||||
}
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
PREVIEW_VIEW_TYPE,
|
||||
`Preview ${path.basename(uri.path)}`,
|
||||
{ viewColumn, preserveFocus },
|
||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
|
||||
);
|
||||
this.track(new DiagramPreview(this.context, panel, uri));
|
||||
}
|
||||
|
||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||
if (!state?.uri) {
|
||||
panel.dispose();
|
||||
return;
|
||||
}
|
||||
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
|
||||
}
|
||||
|
||||
private track(preview: DiagramPreview): void {
|
||||
const key = preview.uri.toString();
|
||||
if (preview.isEditor) this.editors.add(preview);
|
||||
else {
|
||||
this.previews.get(key)?.panel.dispose();
|
||||
this.previews.set(key, preview);
|
||||
}
|
||||
const onViewState = () => {
|
||||
if (preview.panel.active) this.active = preview;
|
||||
else if (this.active === preview) this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
||||
};
|
||||
preview.panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
preview.onDidDispose(() => {
|
||||
this.editors.delete(preview);
|
||||
if (this.previews.get(key) === preview) this.previews.delete(key);
|
||||
if (this.active === preview) {
|
||||
this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshAll(force = false): void {
|
||||
for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const preview of this.previews.values()) preview.panel.dispose();
|
||||
this.previews.clear();
|
||||
this.editors.clear();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,6 @@ import { parse as parseYaml } from 'yaml';
|
||||
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
||||
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
||||
|
||||
/** Node collection that may carry `sources` evidence, per diagram type. */
|
||||
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
||||
const ARCHIFY_EXT_RE = /\.archify$/i;
|
||||
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
||||
@@ -92,24 +83,3 @@ export function isArchifyHtml(text: string): boolean {
|
||||
const head = text.slice(0, 4096);
|
||||
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
||||
}
|
||||
|
||||
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
||||
const meta = diagram.meta as Record<string, unknown> | undefined;
|
||||
if (meta && meta.repository) return true;
|
||||
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
||||
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
||||
}
|
||||
|
||||
/** Returns a copy of the diagram with repository evidence removed. */
|
||||
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
||||
const copy = structuredClone(diagram);
|
||||
const meta = copy.meta as Record<string, unknown> | undefined;
|
||||
if (meta) delete meta.repository;
|
||||
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
||||
if (Array.isArray(nodes)) {
|
||||
for (const node of nodes) {
|
||||
if (node && typeof node === 'object') delete node.sources;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DiagramType } from './detect';
|
||||
|
||||
/** Node collection that may carry `sources` evidence, per diagram type. */
|
||||
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
||||
const meta = diagram.meta as Record<string, unknown> | undefined;
|
||||
if (meta && meta.repository) return true;
|
||||
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
||||
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
||||
}
|
||||
|
||||
/** Returns a copy of the diagram with repository evidence removed. */
|
||||
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
||||
const copy = structuredClone(diagram);
|
||||
const meta = copy.meta as Record<string, unknown> | undefined;
|
||||
if (meta) delete meta.repository;
|
||||
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
||||
if (Array.isArray(nodes)) {
|
||||
for (const node of nodes) {
|
||||
if (node && typeof node === 'object') delete node.sources;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ApiError,
|
||||
Invite,
|
||||
JoinResponse,
|
||||
Member,
|
||||
PROTOCOL_VERSION,
|
||||
ServerInfo,
|
||||
Space,
|
||||
TokenResponse,
|
||||
Version,
|
||||
VersionFiles,
|
||||
} from './protocol';
|
||||
|
||||
export class SyncApiError extends Error {
|
||||
constructor(readonly status: number, readonly code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
/**
|
||||
* The server address as the extension keeps it: http(s), no trailing slash.
|
||||
* Plain http is refused except on this machine; tokens travel on it.
|
||||
*/
|
||||
export function checkServerUrl(raw: string): { url?: string; problem?: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return { problem: 'Enter the server address, for example https://archify.example.com' };
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' };
|
||||
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' };
|
||||
return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` };
|
||||
}
|
||||
|
||||
export function syncSocketUrl(server: string, space: string): string {
|
||||
return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`;
|
||||
}
|
||||
|
||||
/** Reads `server` and `invite` from an invite link: `https://…/join/<token>` or the extension's `…/join?server=&invite=`. */
|
||||
export function parseInviteLink(raw: string): { server: string; invite: string } | undefined {
|
||||
try {
|
||||
const url = new URL(raw.trim());
|
||||
const server = url.searchParams.get('server');
|
||||
const invite = url.searchParams.get('invite');
|
||||
if (server && invite) return { server: server.replace(/\/+$/, ''), invite };
|
||||
const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname);
|
||||
if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] };
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The server's HTTP API. `token` gives a fresh access token for calls that need one. */
|
||||
export class SyncApi {
|
||||
constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise<string | undefined>) {}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown, signedIn = true): Promise<T> {
|
||||
const send = async (force: boolean) => {
|
||||
const headers: Record<string, string> = { accept: 'application/json' };
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
if (signedIn) {
|
||||
const token = await this.token?.(force);
|
||||
if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.');
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
};
|
||||
let response = await send(false);
|
||||
// The access token may have expired between the check and the call.
|
||||
if (response.status === 401 && signedIn) response = await send(true);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let error: Partial<ApiError> = {};
|
||||
try {
|
||||
error = JSON.parse(text) as ApiError;
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`);
|
||||
}
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
info = () => this.request<ServerInfo>('GET', '/v1/info', undefined, false);
|
||||
exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
|
||||
this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false);
|
||||
refresh = (refreshToken: string) => this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false);
|
||||
logout = (refreshToken: string) => this.request<void>('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false);
|
||||
spaces = () => this.request<Space[]>('GET', '/v1/spaces');
|
||||
createSpace = (name: string) => this.request<Space>('POST', '/v1/spaces', { name });
|
||||
members = (space: string) => this.request<Member[]>('GET', `/v1/spaces/${space}/members`);
|
||||
invite = (space: string) => this.request<Invite>('POST', `/v1/spaces/${space}/invites`, {});
|
||||
acceptInvite = (token: string) => this.request<JoinResponse>('POST', `/v1/invites/${encodeURIComponent(token)}/accept`);
|
||||
versions = (space: string) => this.request<Version[]>('GET', `/v1/spaces/${space}/versions`);
|
||||
publish = (space: string, name: string) => this.request<Version>('POST', `/v1/spaces/${space}/versions`, { name });
|
||||
versionFiles = (space: string, version: number) => this.request<VersionFiles>('GET', `/v1/spaces/${space}/versions/${version}/files`);
|
||||
restore = (space: string, version: number) => this.request<Version>('POST', `/v1/spaces/${space}/versions/${version}/restore`);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as http from 'node:http';
|
||||
import * as vscode from 'vscode';
|
||||
import { SyncApi, SyncApiError } from './api';
|
||||
import { Account, TokenResponse } from './protocol';
|
||||
|
||||
export const AUTH_PROVIDER_ID = 'archify-sync';
|
||||
const SECRET_KEY = 'archifySync.sessions';
|
||||
const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
interface StoredSession {
|
||||
id: string;
|
||||
server: string;
|
||||
accessToken: string;
|
||||
/** Epoch ms. */
|
||||
expiresAt: number;
|
||||
refreshToken: string;
|
||||
account: Account;
|
||||
}
|
||||
|
||||
interface PendingSignIn {
|
||||
resolve: (code: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Archify Sync" in the Accounts menu: one session per server. Sign-in runs
|
||||
* in the browser and comes back through the extension's URI handler with a
|
||||
* one-time code, exchanged with a PKCE verifier. Tokens live in SecretStorage.
|
||||
* Refresh tokens are single use, so refreshes are serialised per server and
|
||||
* read the latest stored session first (another window may have refreshed).
|
||||
*/
|
||||
export class SyncAuth implements vscode.AuthenticationProvider, vscode.Disposable {
|
||||
private readonly changed = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
||||
readonly onDidChangeSessions = this.changed.event;
|
||||
private readonly pending = new Map<string, PendingSignIn>();
|
||||
private readonly refreshing = new Map<string, Promise<string | undefined>>();
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext, private readonly log: vscode.LogOutputChannel) {
|
||||
this.disposables = [
|
||||
vscode.authentication.registerAuthenticationProvider(AUTH_PROVIDER_ID, 'Archify Sync', this, { supportsMultipleAccounts: true }),
|
||||
// Another window signed in or out.
|
||||
context.secrets.onDidChange((e) => {
|
||||
if (e.key === SECRET_KEY) this.changed.fire({ added: [], removed: [], changed: [] });
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredSession[]> {
|
||||
try {
|
||||
return JSON.parse((await this.context.secrets.get(SECRET_KEY)) ?? '[]') as StoredSession[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async save(sessions: StoredSession[]): Promise<void> {
|
||||
await this.context.secrets.store(SECRET_KEY, JSON.stringify(sessions));
|
||||
}
|
||||
|
||||
private toSession(s: StoredSession): vscode.AuthenticationSession {
|
||||
return {
|
||||
id: s.id,
|
||||
accessToken: s.accessToken,
|
||||
account: { id: s.account.id, label: `${s.account.display_name} (${new URL(s.server).host})` },
|
||||
scopes: [s.server],
|
||||
};
|
||||
}
|
||||
|
||||
async getSessions(scopes?: readonly string[]): Promise<vscode.AuthenticationSession[]> {
|
||||
const server = scopes?.[0];
|
||||
return (await this.load()).filter((s) => !server || s.server === server).map((s) => this.toSession(s));
|
||||
}
|
||||
|
||||
async createSession(scopes: readonly string[]): Promise<vscode.AuthenticationSession> {
|
||||
const server = scopes[0];
|
||||
if (!server) throw new Error('Archify Sync: say which server to sign in to.');
|
||||
return this.toSession(await this.signIn(server));
|
||||
}
|
||||
|
||||
async removeSession(id: string): Promise<void> {
|
||||
const sessions = await this.load();
|
||||
const gone = sessions.find((s) => s.id === id);
|
||||
if (!gone) return;
|
||||
await this.save(sessions.filter((s) => s.id !== id));
|
||||
new SyncApi(gone.server).logout(gone.refreshToken).catch(() => undefined);
|
||||
this.changed.fire({ added: [], removed: [this.toSession(gone)], changed: [] });
|
||||
}
|
||||
|
||||
async session(server: string): Promise<StoredSession | undefined> {
|
||||
return (await this.load()).find((s) => s.server === server);
|
||||
}
|
||||
|
||||
async servers(): Promise<string[]> {
|
||||
return [...new Set((await this.load()).map((s) => s.server))];
|
||||
}
|
||||
|
||||
/** Signs in through the browser; with an invite, the page also offers to make an account. */
|
||||
async signIn(server: string, invite?: string): Promise<StoredSession> {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
const state = crypto.randomBytes(16).toString('base64url');
|
||||
// On the desktop the browser comes back to a port on this machine: no
|
||||
// click needed, and it reaches this window. Remote and web editors use
|
||||
// the extension's URI handler (vscode://…/auth) instead.
|
||||
const loopback = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Desktop ? await this.listen().catch(() => undefined) : undefined;
|
||||
const redirectUri = loopback
|
||||
? `http://127.0.0.1:${loopback.port}/auth`
|
||||
: (await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://${this.context.extension.id}/auth`))).toString(true);
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
if (invite) query.set('invite', invite);
|
||||
|
||||
const code = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: `Archify: signing in to ${new URL(server).host} in the browser…`, cancellable: true },
|
||||
(_progress, cancel) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('The sign-in took too long. Try again.')), SIGN_IN_TIMEOUT_MS);
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(state);
|
||||
loopback?.close();
|
||||
};
|
||||
this.pending.set(state, {
|
||||
resolve: (c) => (done(), resolve(c)),
|
||||
reject: (e) => (done(), reject(e)),
|
||||
});
|
||||
cancel.onCancellationRequested(() => this.pending.get(state)?.reject(new Error('Sign-in cancelled.')));
|
||||
void vscode.env.openExternal(vscode.Uri.parse(`${server}/authorize?${query}`, true)).then((opened) => {
|
||||
if (!opened) this.pending.get(state)?.reject(new Error('The browser did not open.'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
const tokens = await new SyncApi(server).exchangeCode(code, verifier, redirectUri);
|
||||
const session = await this.store(server, tokens);
|
||||
await this.context.globalState.update('archifySync.used', true);
|
||||
this.log.info(`sync: signed in to ${server} as ${tokens.account.name}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A one-off HTTP listener on 127.0.0.1 for the browser's way back; closed when the sign-in ends. */
|
||||
private listen(): Promise<{ port: number; close: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const known = url.pathname === '/auth' && this.pending.has(url.searchParams.get('state') ?? '');
|
||||
res.writeHead(known ? 200 : 404, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(
|
||||
known
|
||||
? '<!doctype html><meta charset="utf-8"><title>Archify Sync</title><body style="font:15px system-ui;margin:48px auto;max-width:420px"><h2>Signed in</h2><p>You can close this tab and go back to the editor.</p></body>'
|
||||
: 'Not found',
|
||||
);
|
||||
if (known) this.completeSignIn(url.searchParams);
|
||||
});
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') return reject(new Error('no port'));
|
||||
resolve({ port: address.port, close: () => server.close() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The browser came back: `…/auth?code=&state=`. */
|
||||
handleAuthUri(uri: vscode.Uri): void {
|
||||
this.completeSignIn(new URLSearchParams(uri.query));
|
||||
}
|
||||
|
||||
private completeSignIn(query: URLSearchParams): void {
|
||||
const state = query.get('state') ?? '';
|
||||
const code = query.get('code');
|
||||
const pending = this.pending.get(state);
|
||||
if (!pending) {
|
||||
this.log.warn('sync: a sign-in came back that this window did not start');
|
||||
return;
|
||||
}
|
||||
if (code) pending.resolve(code);
|
||||
else pending.reject(new Error(query.get('error_description') ?? 'The sign-in was not completed.'));
|
||||
}
|
||||
|
||||
private async store(server: string, tokens: TokenResponse, id?: string): Promise<StoredSession> {
|
||||
const session: StoredSession = {
|
||||
id: id ?? crypto.randomUUID(),
|
||||
server,
|
||||
accessToken: tokens.access_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
refreshToken: tokens.refresh_token,
|
||||
account: tokens.account,
|
||||
};
|
||||
const others = (await this.load()).filter((s) => s.server !== server);
|
||||
await this.save([...others, session]);
|
||||
const event = id ? { added: [], removed: [], changed: [this.toSession(session)] } : { added: [this.toSession(session)], removed: [], changed: [] };
|
||||
this.changed.fire(event);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A valid access token for the server, refreshed when close to expiry; undefined without a session. */
|
||||
accessToken(server: string, force = false): Promise<string | undefined> {
|
||||
const running = this.refreshing.get(server);
|
||||
if (running) return running;
|
||||
const work = this.freshToken(server, force).finally(() => this.refreshing.delete(server));
|
||||
this.refreshing.set(server, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
private async freshToken(server: string, force: boolean): Promise<string | undefined> {
|
||||
const session = await this.session(server);
|
||||
if (!session) return undefined;
|
||||
if (!force && session.expiresAt - 60_000 > Date.now()) return session.accessToken;
|
||||
try {
|
||||
const tokens = await new SyncApi(server).refresh(session.refreshToken);
|
||||
return (await this.store(server, tokens, session.id)).accessToken;
|
||||
} catch (error) {
|
||||
if (error instanceof SyncApiError && error.status === 401) {
|
||||
// Expired, or signed out elsewhere: the person signs in again.
|
||||
const latest = await this.session(server);
|
||||
if (latest && latest.refreshToken !== session.refreshToken) return latest.accessToken;
|
||||
await this.removeSession(session.id);
|
||||
this.log.warn(`sync: the session for ${server} ended; sign in again`);
|
||||
return undefined;
|
||||
}
|
||||
// Offline: keep the old token; the connection waits and tries again.
|
||||
this.log.info(`sync: could not refresh the token for ${server} (${String(error)})`);
|
||||
return session.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const pending of this.pending.values()) pending.reject(new Error('Closed.'));
|
||||
for (const d of this.disposables) d.dispose();
|
||||
this.changed.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import * as awarenessProtocol from 'y-protocols/awareness';
|
||||
import * as syncProtocol from 'y-protocols/sync';
|
||||
import * as decoding from 'lib0/decoding';
|
||||
import * as encoding from 'lib0/encoding';
|
||||
|
||||
const MESSAGE_SYNC = 0;
|
||||
const MESSAGE_AWARENESS = 1;
|
||||
const MESSAGE_QUERY_AWARENESS = 3;
|
||||
|
||||
export type ConnectionState =
|
||||
| 'connecting'
|
||||
| 'online'
|
||||
| 'offline'
|
||||
/** No session for the server, or the server refused it. */
|
||||
| 'signed-out'
|
||||
/** The server no longer lets this account in (removed from the space). */
|
||||
| 'forbidden'
|
||||
/** The server speaks another protocol version. */
|
||||
| 'outdated';
|
||||
|
||||
export interface ConnectionOptions {
|
||||
url: string;
|
||||
/** A fresh access token; `force` after the server refused the last one. */
|
||||
token: (force: boolean) => Promise<string | undefined>;
|
||||
doc: Y.Doc;
|
||||
awareness: awarenessProtocol.Awareness;
|
||||
onState: (state: ConnectionState) => void;
|
||||
/** After the first full exchange of each connection. */
|
||||
onSynced: () => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One space's WebSocket to the server: the y-websocket protocol (sync step 1
|
||||
* and 2, updates, awareness), reconnecting with backoff. Edits made while
|
||||
* offline go out on reconnect through the state-vector exchange.
|
||||
*/
|
||||
export class LiveConnection {
|
||||
state: ConnectionState = 'connecting';
|
||||
synced = false;
|
||||
private socket?: WebSocket;
|
||||
private attempt = 0;
|
||||
private timer?: NodeJS.Timeout;
|
||||
private closed = false;
|
||||
private forceToken = false;
|
||||
|
||||
constructor(private readonly options: ConnectionOptions) {
|
||||
options.doc.on('update', this.onDocUpdate);
|
||||
options.awareness.on('update', this.onAwarenessUpdate);
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
/** Connects at once, e.g. after signing in. */
|
||||
reconnectNow(): void {
|
||||
if (this.closed || this.state === 'online' || this.state === 'connecting') return;
|
||||
clearTimeout(this.timer);
|
||||
this.attempt = 0;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
this.options.onState(state);
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.setState('connecting');
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this.options.token(this.forceToken);
|
||||
} catch (error) {
|
||||
this.options.log(`sync: no token (${String(error)})`);
|
||||
}
|
||||
this.forceToken = false;
|
||||
if (this.closed) return;
|
||||
if (!token) {
|
||||
this.setState('signed-out');
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(this.options.url, { headers: { authorization: `Bearer ${token}` }, handshakeTimeout: 10_000 });
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
let refused = false;
|
||||
|
||||
socket.on('open', () => {
|
||||
const step1 = encoding.createEncoder();
|
||||
encoding.writeVarUint(step1, MESSAGE_SYNC);
|
||||
syncProtocol.writeSyncStep1(step1, this.options.doc);
|
||||
this.send(encoding.toUint8Array(step1));
|
||||
if (this.options.awareness.getLocalState() !== null) this.sendAwareness([this.options.doc.clientID]);
|
||||
});
|
||||
socket.on('message', (data: ArrayBuffer) => this.receive(new Uint8Array(data)));
|
||||
socket.on('unexpected-response', (_request, response) => {
|
||||
refused = true;
|
||||
const status = response.statusCode ?? 0;
|
||||
this.options.log(`sync: the server refused the connection (${status})`);
|
||||
if (status === 401) {
|
||||
// Try once more with a refreshed token; without a session, stop.
|
||||
this.forceToken = true;
|
||||
this.setState('signed-out');
|
||||
this.schedule(true);
|
||||
} else if (status === 403 || status === 404) {
|
||||
this.setState('forbidden');
|
||||
} else if (status === 426) {
|
||||
this.setState('outdated');
|
||||
} else {
|
||||
this.setState('offline');
|
||||
this.schedule();
|
||||
}
|
||||
socket.terminate();
|
||||
});
|
||||
socket.on('error', (error) => this.options.log(`sync: ${error.message}`));
|
||||
socket.on('close', () => {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = undefined;
|
||||
this.synced = false;
|
||||
const others = [...this.options.awareness.getStates().keys()].filter((id) => id !== this.options.doc.clientID);
|
||||
awarenessProtocol.removeAwarenessStates(this.options.awareness, others, this);
|
||||
if (this.closed || refused) return;
|
||||
this.setState('offline');
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
/** Backoff: 0.5 s doubling to 30 s, with jitter. */
|
||||
private schedule(once = false): void {
|
||||
if (this.closed) return;
|
||||
clearTimeout(this.timer);
|
||||
if (once && this.attempt > 0) return;
|
||||
const delay = Math.min(30_000, 500 * 2 ** this.attempt) * (0.75 + Math.random() / 2);
|
||||
this.attempt++;
|
||||
this.timer = setTimeout(() => void this.connect(), delay);
|
||||
}
|
||||
|
||||
private receive(bytes: Uint8Array): void {
|
||||
const decoder = decoding.createDecoder(bytes);
|
||||
const type = decoding.readVarUint(decoder);
|
||||
if (type === MESSAGE_SYNC) {
|
||||
const reply = encoding.createEncoder();
|
||||
encoding.writeVarUint(reply, MESSAGE_SYNC);
|
||||
const sub = syncProtocol.readSyncMessage(decoder, reply, this.options.doc, this);
|
||||
if (encoding.length(reply) > 1) this.send(encoding.toUint8Array(reply));
|
||||
if (sub === syncProtocol.messageYjsSyncStep2 && !this.synced) {
|
||||
this.synced = true;
|
||||
this.attempt = 0;
|
||||
this.setState('online');
|
||||
this.options.onSynced();
|
||||
}
|
||||
} else if (type === MESSAGE_AWARENESS) {
|
||||
awarenessProtocol.applyAwarenessUpdate(this.options.awareness, decoding.readVarUint8Array(decoder), this);
|
||||
} else if (type === MESSAGE_QUERY_AWARENESS) {
|
||||
this.sendAwareness([...this.options.awareness.getStates().keys()]);
|
||||
}
|
||||
}
|
||||
|
||||
private send(bytes: Uint8Array): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(bytes);
|
||||
}
|
||||
|
||||
private sendAwareness(clients: number[]): void {
|
||||
const message = encoding.createEncoder();
|
||||
encoding.writeVarUint(message, MESSAGE_AWARENESS);
|
||||
encoding.writeVarUint8Array(message, awarenessProtocol.encodeAwarenessUpdate(this.options.awareness, clients));
|
||||
this.send(encoding.toUint8Array(message));
|
||||
}
|
||||
|
||||
private readonly onDocUpdate = (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === this) return;
|
||||
const message = encoding.createEncoder();
|
||||
encoding.writeVarUint(message, MESSAGE_SYNC);
|
||||
syncProtocol.writeUpdate(message, update);
|
||||
this.send(encoding.toUint8Array(message));
|
||||
};
|
||||
|
||||
private readonly onAwarenessUpdate = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: unknown,
|
||||
) => {
|
||||
if (origin === this) return;
|
||||
this.sendAwareness([...added, ...updated, ...removed]);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
this.closed = true;
|
||||
clearTimeout(this.timer);
|
||||
// Tell the others this editor left before closing.
|
||||
awarenessProtocol.removeAwarenessStates(this.options.awareness, [this.options.doc.clientID], 'dispose');
|
||||
this.options.doc.off('update', this.onDocUpdate);
|
||||
this.options.awareness.off('update', this.onAwarenessUpdate);
|
||||
this.socket?.close();
|
||||
this.socket = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||
|
||||
/** One colour per person, the same in every window. */
|
||||
const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e'];
|
||||
|
||||
export function colorFor(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
|
||||
/** What the text-editor side needs from the shared models. */
|
||||
export interface CursorSource {
|
||||
cursors(file: vscode.Uri): LiveCursor[];
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean;
|
||||
}
|
||||
|
||||
interface Styles {
|
||||
caret: vscode.TextEditorDecorationType;
|
||||
selection: vscode.TextEditorDecorationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursors in VS Code's text editors: yours go to the others as you move, and
|
||||
* theirs are drawn as a coloured caret with their name and a tinted selection.
|
||||
*/
|
||||
export class EditorCursors implements vscode.Disposable {
|
||||
private readonly styles = new Map<string, Styles>();
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pending: vscode.TextEditor | undefined;
|
||||
|
||||
constructor(private readonly source: CursorSource) {
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)),
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)),
|
||||
vscode.window.onDidChangeVisibleTextEditors(() => this.render()),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sent at most every 80 ms; the last position wins. */
|
||||
private moved(editor: vscode.TextEditor): void {
|
||||
if (editor.document.uri.scheme !== 'file') return;
|
||||
this.pending = editor;
|
||||
if (this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
const e = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!e || e.document.isClosed) return;
|
||||
const text = normalizeEol(e.document.getText());
|
||||
const { anchor, active } = e.selection;
|
||||
this.source.setCursor(e.document.uri, {
|
||||
anchor: offsetOf(text, anchor.line, anchor.character),
|
||||
head: offsetOf(text, active.line, active.character),
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private stylesFor(color: string): Styles {
|
||||
let found = this.styles.get(color);
|
||||
if (!found) {
|
||||
found = {
|
||||
caret: vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
// A zero-width caret and the name above it; the CSS rides in textDecoration,
|
||||
// the usual way to place a decoration without moving the text.
|
||||
before: {
|
||||
contentText: '',
|
||||
textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`,
|
||||
},
|
||||
}),
|
||||
selection: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: `${color}38`,
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
}),
|
||||
};
|
||||
this.styles.set(color, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Draws everyone's cursors in the visible editors of shared files. */
|
||||
render(): void {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (editor.document.uri.scheme !== 'file') continue;
|
||||
const cursors = this.source.cursors(editor.document.uri);
|
||||
const text = normalizeEol(editor.document.getText());
|
||||
const at = (offset: number) => {
|
||||
const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length)));
|
||||
return new vscode.Position(line, character);
|
||||
};
|
||||
const byColor = new Map<string, { carets: vscode.DecorationOptions[]; selections: vscode.Range[] }>();
|
||||
for (const c of cursors) {
|
||||
let group = byColor.get(c.color);
|
||||
if (!group) byColor.set(c.color, (group = { carets: [], selections: [] }));
|
||||
const head = at(c.head);
|
||||
group.carets.push({
|
||||
range: new vscode.Range(head, head),
|
||||
hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`),
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: c.name,
|
||||
color: '#fff',
|
||||
backgroundColor: c.color,
|
||||
fontWeight: '500',
|
||||
textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (c.anchor !== c.head) {
|
||||
const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor];
|
||||
group.selections.push(new vscode.Range(at(from), at(to)));
|
||||
}
|
||||
}
|
||||
for (const color of byColor.keys()) this.stylesFor(color);
|
||||
// Every colour is set, so a person who left or moved away disappears.
|
||||
for (const [color, styles] of this.styles) {
|
||||
const group = byColor.get(color);
|
||||
editor.setDecorations(styles.caret, group?.carets ?? []);
|
||||
editor.setDecorations(styles.selection, group?.selections ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
for (const styles of this.styles.values()) {
|
||||
styles.caret.dispose();
|
||||
styles.selection.dispose();
|
||||
}
|
||||
this.styles.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as Y from 'yjs';
|
||||
import { normalizeEol, positionOf } from '../util/textDiff';
|
||||
import { FILES_MAP } from './protocol';
|
||||
import { applyToYText, safeChange } from './textSync';
|
||||
|
||||
const MODEL_GLOB = '**/*.{archify,archify-registry,archify-decision}';
|
||||
const SKIP_GLOB = '{**/node_modules/**,**/.git/**}';
|
||||
const MODEL_FILE = /\.(archify|archify-registry|archify-decision)$/i;
|
||||
|
||||
/** A model-root-relative path the bridge accepts: POSIX, no `..`, a model file. */
|
||||
export function isSafeModelPath(path: string): boolean {
|
||||
if (!MODEL_FILE.test(path) || path.startsWith('/') || path.includes('\\')) return false;
|
||||
return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git' && part !== 'node_modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a model's files and the space's Y.Text per file the same.
|
||||
*
|
||||
* Local edits (any editor, the Source view, a drag on a diagram, git, an
|
||||
* agent) become minimal Y.Text edits. Remote edits go back as WorkspaceEdits
|
||||
* for open documents, so undo, dirty state and the pages stay right, and as
|
||||
* file writes for closed ones. Each side compares texts before writing, so
|
||||
* an edit coming back from the other side ends as a no-op instead of an echo.
|
||||
*/
|
||||
export class FileBridge implements vscode.Disposable {
|
||||
readonly files: Y.Map<Y.Text>;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private listening = false;
|
||||
/**
|
||||
* Text the bridge last wrote per file. Its own write comes back as a change
|
||||
* event, possibly after the document moved on; pushing it then would undo
|
||||
* the newer edit, so it is skipped once.
|
||||
*/
|
||||
private readonly written = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly root: vscode.Uri,
|
||||
private readonly doc: Y.Doc,
|
||||
/** Transaction origin of edits made here. */
|
||||
private readonly origin: object,
|
||||
private readonly log: vscode.LogOutputChannel,
|
||||
) {
|
||||
this.files = doc.getMap<Y.Text>(FILES_MAP);
|
||||
}
|
||||
|
||||
/** The model-relative path of a file in this model, else undefined. */
|
||||
relative(uri: vscode.Uri): string | undefined {
|
||||
if (uri.scheme !== this.root.scheme) return undefined;
|
||||
const base = this.root.path.endsWith('/') ? this.root.path : `${this.root.path}/`;
|
||||
if (!uri.path.startsWith(base)) return undefined;
|
||||
const rel = uri.path.slice(base.length);
|
||||
return isSafeModelPath(rel) ? rel : undefined;
|
||||
}
|
||||
|
||||
uriOf(rel: string): vscode.Uri {
|
||||
return vscode.Uri.joinPath(this.root, ...rel.split('/'));
|
||||
}
|
||||
|
||||
/** Remote edits start going to disk; with `local`, local edits start going out. */
|
||||
start(local: boolean): void {
|
||||
if (!this.disposables.length) {
|
||||
const observer = (events: Y.YEvent<Y.AbstractType<unknown>>[], txn: Y.Transaction) => {
|
||||
if (txn.origin === this.origin) return;
|
||||
const paths = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.target === this.files) for (const key of event.changes.keys.keys()) paths.add(key);
|
||||
else if (typeof event.path[0] === 'string') paths.add(event.path[0]);
|
||||
}
|
||||
for (const path of paths) this.enqueue(() => this.writeOut(path));
|
||||
};
|
||||
this.files.observeDeep(observer);
|
||||
this.disposables.push({ dispose: () => this.files.unobserveDeep(observer) });
|
||||
}
|
||||
if (local && !this.listening) {
|
||||
this.listening = true;
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.root, MODEL_GLOB));
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
const rel = this.relative(e.document.uri);
|
||||
if (rel && e.contentChanges.length) this.pushLocal(rel, normalizeEol(e.document.getText()));
|
||||
}),
|
||||
watcher,
|
||||
watcher.onDidCreate((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||
watcher.onDidChange((uri) => this.enqueue(() => this.pushFromDisk(uri))),
|
||||
watcher.onDidDelete((uri) => {
|
||||
const rel = this.relative(uri);
|
||||
if (rel && this.files.has(rel)) this.enqueue(async () => {
|
||||
// Gone for good, not an editor's save-by-rename.
|
||||
if (!(await exists(uri))) this.doc.transact(() => this.files.delete(rel), this.origin);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private pushLocal(rel: string, text: string): void {
|
||||
if (this.written.get(rel) === text) {
|
||||
this.written.delete(rel);
|
||||
return;
|
||||
}
|
||||
this.push(rel, text);
|
||||
}
|
||||
|
||||
/** One file's text into the document. */
|
||||
push(rel: string, text: string): void {
|
||||
const existing = this.files.get(rel);
|
||||
if (existing instanceof Y.Text) {
|
||||
if (existing.toString() !== text) this.doc.transact(() => applyToYText(existing, text), this.origin);
|
||||
return;
|
||||
}
|
||||
this.doc.transact(() => {
|
||||
const created = new Y.Text();
|
||||
created.insert(0, text);
|
||||
this.files.set(rel, created);
|
||||
}, this.origin);
|
||||
}
|
||||
|
||||
private async pushFromDisk(uri: vscode.Uri): Promise<void> {
|
||||
const rel = this.relative(uri);
|
||||
// Open documents report through their change events.
|
||||
if (!rel || vscode.workspace.textDocuments.some((d) => d.uri.toString() === uri.toString())) return;
|
||||
const text = await readDisk(uri);
|
||||
if (text !== undefined) this.pushLocal(rel, normalizeEol(text));
|
||||
}
|
||||
|
||||
/** The model files as they are now: open editors' text, else the disk. */
|
||||
async scan(): Promise<Map<string, string>> {
|
||||
const found = await vscode.workspace.findFiles(new vscode.RelativePattern(this.root, MODEL_GLOB), SKIP_GLOB);
|
||||
const out = new Map<string, string>();
|
||||
for (const uri of found) {
|
||||
const rel = this.relative(uri);
|
||||
if (!rel) continue;
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
const text = open ? open.getText() : await readDisk(uri);
|
||||
if (text !== undefined) out.set(rel, normalizeEol(text));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings changes made while the extension was not running into the
|
||||
* document (git pull, an edit in another tool). With `deleteMissing`,
|
||||
* files in the document but gone from disk are removed.
|
||||
*/
|
||||
async pushAll(deleteMissing: boolean): Promise<void> {
|
||||
const local = await this.scan();
|
||||
for (const [rel, text] of local) this.push(rel, text);
|
||||
if (deleteMissing) {
|
||||
const gone = [...this.files.keys()].filter((rel) => !local.has(rel));
|
||||
if (gone.length) this.doc.transact(() => gone.forEach((rel) => this.files.delete(rel)), this.origin);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes every file of the document to disk; then sends local files the document lacks. */
|
||||
async pullAll(): Promise<void> {
|
||||
for (const rel of this.files.keys()) await this.enqueue(() => this.writeOut(rel));
|
||||
const local = await this.scan();
|
||||
for (const [rel, text] of local) if (!this.files.has(rel)) this.push(rel, text);
|
||||
}
|
||||
|
||||
private enqueue(work: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(work).catch((error) => this.log.error(`sync: ${error instanceof Error ? error.message : String(error)}`));
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
/** The document's text of one file onto disk or into its open editor. */
|
||||
private async writeOut(rel: string): Promise<void> {
|
||||
if (!isSafeModelPath(rel)) {
|
||||
this.log.warn(`sync: ignored a file outside the model: ${rel}`);
|
||||
return;
|
||||
}
|
||||
const uri = this.uriOf(rel);
|
||||
const value = this.files.get(rel);
|
||||
if (!(value instanceof Y.Text)) {
|
||||
if (await exists(uri)) await vscode.workspace.fs.delete(uri, { useTrash: true });
|
||||
return;
|
||||
}
|
||||
const text = value.toString();
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
if (open) {
|
||||
const current = normalizeEol(open.getText());
|
||||
const change = safeChange(current, text);
|
||||
if (!change) return;
|
||||
const wasDirty = open.isDirty;
|
||||
const from = positionOf(current, change.from);
|
||||
const to = positionOf(current, change.to);
|
||||
this.written.set(rel, text);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(uri, new vscode.Range(from.line, from.character, to.line, to.character), change.insert);
|
||||
if (!(await vscode.workspace.applyEdit(edit))) {
|
||||
this.log.warn(`sync: could not apply a teammate's edit to ${rel}`);
|
||||
return;
|
||||
}
|
||||
// Teammates' edits land on disk, where git sees them; the person's own unsaved work stays unsaved.
|
||||
if (!wasDirty) await open.save();
|
||||
return;
|
||||
}
|
||||
const disk = await readDisk(uri);
|
||||
const eol = disk?.includes('\r\n') ? '\r\n' : '\n';
|
||||
const out = eol === '\n' ? text : text.replace(/\n/g, '\r\n');
|
||||
if (disk === out) return;
|
||||
this.written.set(rel, text);
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(uri, '..'));
|
||||
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(out));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
this.listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDisk(uri: vscode.Uri): Promise<string | undefined> {
|
||||
try {
|
||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// Archify Sync in the extension: live, shared models. Bundled on its own
|
||||
// (dist/sync.js) and loaded only when a model is shared or a sync command
|
||||
// runs, so the extension without a server works exactly as before.
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import type { ModelService } from '../model/service';
|
||||
import { SyncApi, SyncApiError, checkServerUrl, parseInviteLink } from './api';
|
||||
import { SyncAuth } from './auth';
|
||||
import { LiveSpace, StartMode } from './liveSpace';
|
||||
import { LINK_FILE, Link, PROTOCOL_VERSION, Version, formatLink, parseLink, versionLabel } from './protocol';
|
||||
import { EditorCursors } from './cursors';
|
||||
import type { LiveAction, LiveBridge, LiveCursor, LivePanel, LiveProvider, LiveSelection } from './liveBridge';
|
||||
import { VERSION_SCHEME, VersionContent, compareVersion, restoreVersion, showVersions } from './versions';
|
||||
|
||||
export const SYNC_COMMANDS = [
|
||||
'archify.sync.signIn',
|
||||
'archify.sync.signOut',
|
||||
'archify.sync.share',
|
||||
'archify.sync.invite',
|
||||
'archify.sync.join',
|
||||
'archify.sync.publish',
|
||||
'archify.sync.versions',
|
||||
'archify.sync.stop',
|
||||
'archify.sync.menu',
|
||||
'archify.sync.setServer',
|
||||
] as const;
|
||||
|
||||
const SKIP = '{**/node_modules/**,**/.git/**}';
|
||||
/** How long the model page's list of versions is reused before it is asked for again. */
|
||||
const VERSIONS_TTL_MS = 20_000;
|
||||
|
||||
export class SyncFeature implements vscode.Disposable, LiveProvider {
|
||||
private readonly auth: SyncAuth;
|
||||
private readonly spaces = new Map<string, LiveSpace>();
|
||||
private readonly status = vscode.window.createStatusBarItem('archify.sync', vscode.StatusBarAlignment.Left, 50);
|
||||
private readonly content: VersionContent;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly versionCache = new Map<string, { at: number; versions: Promise<Version[]> }>();
|
||||
private readonly editorCursors: EditorCursors;
|
||||
private cursorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** Servers with a session on this machine, for the status bar before any model is shared. */
|
||||
private signedIn: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
private readonly log: vscode.LogOutputChannel,
|
||||
private readonly bridge?: LiveBridge,
|
||||
) {
|
||||
this.auth = new SyncAuth(context, log);
|
||||
this.content = new VersionContent((space) => {
|
||||
const live = [...this.spaces.values()].find((s) => s.link.space === space);
|
||||
return live ? this.api(live.link.server) : undefined;
|
||||
});
|
||||
this.status.name = 'Archify Sync';
|
||||
this.status.command = 'archify.sync.menu';
|
||||
const links = vscode.workspace.createFileSystemWatcher(`**/${LINK_FILE}`);
|
||||
this.disposables.push(
|
||||
this.auth,
|
||||
this.status,
|
||||
links,
|
||||
links.onDidCreate((uri) => void this.open(uri, 'open')),
|
||||
links.onDidDelete((uri) => this.close(vscode.Uri.joinPath(uri, '..'), false)),
|
||||
vscode.workspace.registerTextDocumentContentProvider(VERSION_SCHEME, this.content),
|
||||
this.auth.onDidChangeSessions(() => {
|
||||
for (const space of this.spaces.values()) space.reconnect();
|
||||
void this.refreshSignedIn();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((e) => e.affectsConfiguration('archify.sync') && this.render()),
|
||||
);
|
||||
this.editorCursors = new EditorCursors({ cursors: (file) => this.cursors(file), setCursor: (file, selection) => this.setCursorIn(file, selection) });
|
||||
this.disposables.push(this.editorCursors);
|
||||
bridge?.setProvider(this);
|
||||
}
|
||||
|
||||
/** Opens every shared model in the workspace. */
|
||||
async start(): Promise<void> {
|
||||
const found = await vscode.workspace.findFiles(`**/${LINK_FILE}`, SKIP, 50);
|
||||
await Promise.all(found.map((uri) => this.open(uri, 'open')));
|
||||
await this.refreshSignedIn();
|
||||
}
|
||||
|
||||
private async refreshSignedIn(): Promise<void> {
|
||||
this.signedIn = await this.auth.servers();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private api(server: string): SyncApi {
|
||||
return new SyncApi(server, (force) => this.auth.accessToken(server, force));
|
||||
}
|
||||
|
||||
private async open(linkUri: vscode.Uri, mode: StartMode): Promise<LiveSpace | undefined> {
|
||||
const root = vscode.Uri.joinPath(linkUri, '..');
|
||||
if (this.spaces.has(root.toString())) return this.spaces.get(root.toString());
|
||||
let link: Link | undefined;
|
||||
try {
|
||||
link = parseLink(new TextDecoder().decode(await vscode.workspace.fs.readFile(linkUri)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!link) {
|
||||
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} is not a valid link file`);
|
||||
return undefined;
|
||||
}
|
||||
if (!checkServerUrl(link.server).url) {
|
||||
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} names a server that is not https: ${link.server}`);
|
||||
return undefined;
|
||||
}
|
||||
const space = new LiveSpace(root, link, mode, {
|
||||
context: this.context,
|
||||
auth: this.auth,
|
||||
log: this.log,
|
||||
onChange: () => this.render(),
|
||||
onCursors: () => this.cursorsMoved(),
|
||||
});
|
||||
this.spaces.set(root.toString(), space);
|
||||
try {
|
||||
await space.start();
|
||||
} catch (error) {
|
||||
this.log.error(`sync: ${link.name ?? root.fsPath} did not start: ${String(error)}`);
|
||||
}
|
||||
this.render();
|
||||
return space;
|
||||
}
|
||||
|
||||
private close(root: vscode.Uri, forget: boolean): void {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return;
|
||||
this.spaces.delete(root.toString());
|
||||
if (forget) space.forget();
|
||||
else space.dispose();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const spaces = [...this.spaces.values()];
|
||||
if (!spaces.length) {
|
||||
this.status.hide();
|
||||
void vscode.commands.executeCommand('setContext', 'archify.sync.active', false);
|
||||
this.bridge?.changed();
|
||||
return;
|
||||
}
|
||||
void vscode.commands.executeCommand('setContext', 'archify.sync.active', true);
|
||||
const signedOut = spaces.filter((s) => s.state === 'signed-out');
|
||||
const offline = spaces.filter((s) => s.state !== 'online' && s.state !== 'signed-out');
|
||||
const people = new Set(spaces.flatMap((s) => s.people().map((p) => p.user.id)));
|
||||
if (signedOut.length) {
|
||||
this.status.text = '$(account) Archify: sign in';
|
||||
this.status.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
} else if (offline.length) {
|
||||
this.status.text = offline.some((s) => s.state === 'connecting') ? '$(sync~spin) Archify connecting' : '$(cloud-offline) Archify offline';
|
||||
this.status.backgroundColor = undefined;
|
||||
} else {
|
||||
this.status.text = `$(broadcast) Archify live · ${people.size} here`;
|
||||
this.status.backgroundColor = undefined;
|
||||
}
|
||||
const tooltip = new vscode.MarkdownString(undefined, true);
|
||||
for (const s of spaces) {
|
||||
const who = s.people().map((p) => `${p.user.name}${p.file ? ` (${p.file})` : ''}`).join(', ');
|
||||
tooltip.appendMarkdown(`**${s.name}**: ${s.state}${who ? ` \n${who}` : ''}\n\n`);
|
||||
}
|
||||
tooltip.appendMarkdown('Edits are kept on this machine first and sent when online.');
|
||||
this.status.tooltip = tooltip;
|
||||
this.status.show();
|
||||
this.bridge?.changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the model page's Live section
|
||||
|
||||
private versionsOf(space: LiveSpace, fresh = false): Promise<Version[]> {
|
||||
const cached = this.versionCache.get(space.link.space);
|
||||
if (!fresh && cached && Date.now() - cached.at < VERSIONS_TTL_MS) return cached.versions;
|
||||
const versions = this.api(space.link.server).versions(space.link.space);
|
||||
this.versionCache.set(space.link.space, { at: Date.now(), versions });
|
||||
versions.catch(() => this.versionCache.delete(space.link.space));
|
||||
return versions;
|
||||
}
|
||||
|
||||
/** After a publish or a restore: the page shows the new version. */
|
||||
private versionsChanged(space: LiveSpace): void {
|
||||
this.versionCache.delete(space.link.space);
|
||||
this.bridge?.changed(space.root);
|
||||
}
|
||||
|
||||
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return undefined;
|
||||
const me = (space.awareness.getLocalState() as { user?: { id?: string } } | null)?.user?.id;
|
||||
const panel: LivePanel = {
|
||||
name: space.name,
|
||||
host: new URL(space.link.server).host,
|
||||
state: space.state,
|
||||
people: space.people().map((p) => ({ name: p.user.name, file: p.file, me: p.user.id === me })),
|
||||
};
|
||||
if (space.state === 'signed-out' || space.state === 'forbidden') return panel;
|
||||
try {
|
||||
const versions = await this.versionsOf(space);
|
||||
const label = new Map(versions.map((v) => [v.id, versionLabel(v)]));
|
||||
panel.versions = versions.map((v) => ({
|
||||
id: v.id,
|
||||
label: versionLabel(v),
|
||||
kind: v.kind,
|
||||
name: v.name ?? undefined,
|
||||
at: v.created_at,
|
||||
restoredFrom: v.restored_from !== null ? label.get(v.restored_from) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
panel.versionsProblem = space.state === 'online' ? `Versions could not be loaded: ${error instanceof Error ? error.message : String(error)}` : 'Versions show when the server can be reached.';
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Cursors drawn at most every 50 ms, however many people move. */
|
||||
private cursorsMoved(): void {
|
||||
if (this.cursorTimer) return;
|
||||
this.cursorTimer = setTimeout(() => {
|
||||
this.cursorTimer = undefined;
|
||||
this.bridge?.cursorsChanged();
|
||||
this.editorCursors.render();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private spaceOf(file: vscode.Uri): LiveSpace | undefined {
|
||||
for (const space of this.spaces.values()) {
|
||||
const root = space.root.path.endsWith('/') ? space.root.path : `${space.root.path}/`;
|
||||
if (file.scheme === space.root.scheme && file.path.startsWith(root)) return space;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
cursors(file: vscode.Uri): LiveCursor[] {
|
||||
return this.spaceOf(file)?.cursors(file) ?? [];
|
||||
}
|
||||
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||
this.setCursorIn(file, selection);
|
||||
}
|
||||
|
||||
private setCursorIn(file: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||
return this.spaceOf(file)?.setCursor(file, selection) ?? false;
|
||||
}
|
||||
|
||||
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||
const space = this.spaces.get(root.toString());
|
||||
if (!space) return;
|
||||
try {
|
||||
switch (action.kind) {
|
||||
case 'publish':
|
||||
await this.publish(space);
|
||||
break;
|
||||
case 'invite':
|
||||
await this.invite(space);
|
||||
break;
|
||||
case 'signIn':
|
||||
if (await this.checkServer(space.link.server)) await this.auth.signIn(space.link.server);
|
||||
break;
|
||||
case 'versions':
|
||||
await showVersions(space, this.api(space.link.server), this.content);
|
||||
this.versionsChanged(space);
|
||||
break;
|
||||
case 'compare':
|
||||
case 'restore': {
|
||||
const version = (await this.versionsOf(space)).find((v) => v.id === action.version);
|
||||
if (!version) throw new Error('That version is no longer listed.');
|
||||
if (action.kind === 'compare') await compareVersion(space, version, this.content);
|
||||
else if (await restoreVersion(space, this.api(space.link.server), version)) this.versionsChanged(space);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- commands
|
||||
|
||||
async run(command: (typeof SYNC_COMMANDS)[number], ...args: unknown[]): Promise<void> {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'archify.sync.signIn':
|
||||
await this.signInCommand();
|
||||
break;
|
||||
case 'archify.sync.signOut':
|
||||
await this.signOut();
|
||||
break;
|
||||
case 'archify.sync.share':
|
||||
await this.share(args[0]);
|
||||
break;
|
||||
case 'archify.sync.invite':
|
||||
await this.invite(args[0]);
|
||||
break;
|
||||
case 'archify.sync.join':
|
||||
await this.join(typeof args[0] === 'string' ? args[0] : undefined);
|
||||
break;
|
||||
case 'archify.sync.publish':
|
||||
await this.publish(args[0]);
|
||||
break;
|
||||
case 'archify.sync.versions': {
|
||||
const space = await this.pickSpace(args[0]);
|
||||
if (space) {
|
||||
await showVersions(space, this.api(space.link.server), this.content);
|
||||
this.versionsChanged(space);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'archify.sync.stop':
|
||||
await this.stop(args[0]);
|
||||
break;
|
||||
case 'archify.sync.menu':
|
||||
await this.menu();
|
||||
break;
|
||||
case 'archify.sync.setServer':
|
||||
await this.setServer();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/cancelled/i.test(message)) return;
|
||||
this.log.error(`sync: ${message}`);
|
||||
void vscode.window.showErrorMessage(`Archify Sync: ${message}`);
|
||||
}
|
||||
|
||||
/** The invite and sign-in links: `…/auth?code=&state=`, `…/join?server=&invite=`. */
|
||||
async handleUri(uri: vscode.Uri): Promise<void> {
|
||||
if (uri.path === '/auth') this.auth.handleAuthUri(uri);
|
||||
else if (uri.path === '/join') await this.run('archify.sync.join', uri.toString(true));
|
||||
}
|
||||
|
||||
/** The server from the archify.sync.server setting, or asked for (and remembered). */
|
||||
private async askServer(): Promise<string | undefined> {
|
||||
return configuredServer() ?? this.promptServer();
|
||||
}
|
||||
|
||||
private async promptServer(): Promise<string | undefined> {
|
||||
const last = configuredServer() ?? this.context.globalState.get<string>('archifySync.lastServer');
|
||||
const value = await vscode.window.showInputBox({
|
||||
title: 'Archify | Sync server',
|
||||
prompt: 'The address of the Archify Sync server',
|
||||
placeHolder: 'https://archify.example.com',
|
||||
value: last,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => checkServerUrl(v).problem,
|
||||
});
|
||||
return value === undefined ? undefined : checkServerUrl(value).url;
|
||||
}
|
||||
|
||||
/** Checks the server answers and speaks this protocol; offers its setup page when it is new. */
|
||||
private async checkServer(server: string): Promise<boolean> {
|
||||
const info = await new SyncApi(server).info();
|
||||
if (info.protocol !== PROTOCOL_VERSION) {
|
||||
throw new Error(`${info.name} speaks protocol ${info.protocol}; this extension speaks ${PROTOCOL_VERSION}. Update ${info.protocol > PROTOCOL_VERSION ? 'the extension' : 'the server'}.`);
|
||||
}
|
||||
if (!info.set_up) {
|
||||
const open = await vscode.window.showInformationMessage(
|
||||
`${info.name} is new: its first admin sets it up with the first-start code from the server's log.`,
|
||||
'Open setup page',
|
||||
);
|
||||
if (open) await vscode.env.openExternal(vscode.Uri.parse(`${server}/setup`));
|
||||
return false;
|
||||
}
|
||||
await this.context.globalState.update('archifySync.lastServer', server);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Writes archify.sync.server: in the workspace when the workspace sets it, otherwise for the user. */
|
||||
private async setServer(): Promise<void> {
|
||||
const server = await this.promptServer();
|
||||
if (!server) return;
|
||||
const config = vscode.workspace.getConfiguration('archify.sync');
|
||||
const inWorkspace = config.inspect<string>('server')?.workspaceValue !== undefined;
|
||||
await config.update('server', server, inWorkspace ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global);
|
||||
vscode.window.showInformationMessage(`Archify Sync: server set to ${new URL(server).host}${inWorkspace ? ' for this workspace' : ''}.`);
|
||||
}
|
||||
|
||||
private async signInCommand(): Promise<void> {
|
||||
const server = await this.askServer();
|
||||
if (!server || !(await this.checkServer(server))) return;
|
||||
const session = await this.auth.signIn(server);
|
||||
vscode.window.showInformationMessage(`Archify: signed in to ${new URL(server).host} as ${session.account.display_name}.`);
|
||||
}
|
||||
|
||||
private async signOut(): Promise<void> {
|
||||
const servers = await this.auth.servers();
|
||||
if (!servers.length) {
|
||||
vscode.window.showInformationMessage('Archify: not signed in to a sync server.');
|
||||
return;
|
||||
}
|
||||
const server = servers.length === 1 ? servers[0] : await vscode.window.showQuickPick(servers, { title: 'Archify | Sign out of' });
|
||||
const session = server ? await this.auth.session(server) : undefined;
|
||||
if (session) await this.auth.removeSession(session.id);
|
||||
}
|
||||
|
||||
/** A server with a session: the only one, a picked one, or a new sign-in. */
|
||||
private async serverWithSession(): Promise<string | undefined> {
|
||||
const servers = await this.auth.servers();
|
||||
if (servers.length === 1) return servers[0];
|
||||
if (servers.length > 1) {
|
||||
const pick = await vscode.window.showQuickPick([...servers, '$(add) Another server…'], { title: 'Archify | Share to which server?' });
|
||||
if (pick && !pick.startsWith('$(add)')) return pick;
|
||||
if (!pick) return undefined;
|
||||
}
|
||||
const server = await this.askServer();
|
||||
if (!server || !(await this.checkServer(server))) return undefined;
|
||||
await this.auth.signIn(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
/** The model root a command applies to: the argument, the active file, or a pick. */
|
||||
private async modelRoot(arg: unknown): Promise<vscode.Uri | undefined> {
|
||||
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||
if (root) return root;
|
||||
const mains = await vscode.workspace.findFiles(`**/${MAIN_FILE}`, SKIP, 50);
|
||||
if (mains.length === 1) return vscode.Uri.joinPath(mains[0], '..');
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
mains.map((m) => ({ label: vscode.workspace.asRelativePath(vscode.Uri.joinPath(m, '..')), root: vscode.Uri.joinPath(m, '..') })),
|
||||
{ title: 'Archify | Which model?' },
|
||||
);
|
||||
return pick?.root;
|
||||
}
|
||||
|
||||
private async pickSpace(arg: unknown): Promise<LiveSpace | undefined> {
|
||||
if (arg instanceof LiveSpace) return arg;
|
||||
const spaces = [...this.spaces.values()];
|
||||
if (!spaces.length) {
|
||||
vscode.window.showInformationMessage('Archify: no model in this workspace is shared. Share one with "Archify: Share Model…".');
|
||||
return undefined;
|
||||
}
|
||||
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
|
||||
const root = uri ? await this.models.rootOf(uri) : undefined;
|
||||
const here = root && this.spaces.get(root.toString());
|
||||
if (here) return here;
|
||||
if (spaces.length === 1) return spaces[0];
|
||||
const pick = await vscode.window.showQuickPick(spaces.map((s) => ({ label: s.name, description: new URL(s.link.server).host, space: s })), {
|
||||
title: 'Archify | Which shared model?',
|
||||
});
|
||||
return pick?.space;
|
||||
}
|
||||
|
||||
private async share(arg: unknown): Promise<void> {
|
||||
const root = await this.modelRoot(arg);
|
||||
if (!root) return;
|
||||
if (this.spaces.has(root.toString())) {
|
||||
await this.invite(root);
|
||||
return;
|
||||
}
|
||||
const server = await this.serverWithSession();
|
||||
if (!server) return;
|
||||
const index = await this.models.index(root).catch(() => undefined);
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | Share Model',
|
||||
prompt: `Name of the shared model on ${new URL(server).host}`,
|
||||
value: index?.meta.name ?? root.path.split('/').pop(),
|
||||
validateInput: (v) => (v.trim() ? undefined : 'Give it a name.'),
|
||||
});
|
||||
if (!name) return;
|
||||
const space = await this.api(server).createSpace(name.trim());
|
||||
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: space.id, name: space.name })));
|
||||
await this.open(linkUri, 'share');
|
||||
const next = await vscode.window.showInformationMessage(
|
||||
`Archify: ${space.name} is shared. Edits now go to everyone you invite.`,
|
||||
'Copy invite link',
|
||||
);
|
||||
if (next) await this.invite(root);
|
||||
}
|
||||
|
||||
private async invite(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const invite = await this.api(space.link.server).invite(space.link.space);
|
||||
await vscode.env.clipboard.writeText(invite.url);
|
||||
vscode.window.showInformationMessage(
|
||||
`Archify: invite link for ${space.name} copied. It works until ${new Date(invite.expires_at).toLocaleDateString()}; send it through any channel.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async join(raw?: string): Promise<void> {
|
||||
const text =
|
||||
raw ??
|
||||
(await vscode.window.showInputBox({
|
||||
title: 'Archify | Join Shared Model',
|
||||
prompt: 'Paste the invite link',
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => (parseInviteLink(v) ? undefined : 'This is not an Archify invite link.'),
|
||||
}));
|
||||
if (!text) return;
|
||||
const parsed = parseInviteLink(text);
|
||||
const server = parsed && checkServerUrl(parsed.server).url;
|
||||
if (!parsed || !server) throw new Error('This is not an Archify invite link.');
|
||||
if (!(await this.checkServer(server))) return;
|
||||
|
||||
const folder = await vscode.window.showOpenDialog({
|
||||
title: 'Archify | Folder for the shared model',
|
||||
openLabel: 'Put the model here',
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||
});
|
||||
const root = folder?.[0];
|
||||
if (!root) return;
|
||||
const existing = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.{archify,archify-registry,archify-decision}'), SKIP, 1);
|
||||
if (existing.length) {
|
||||
throw new Error('That folder already holds Archify files. Pick an empty folder for the shared model.');
|
||||
}
|
||||
|
||||
if (!(await this.auth.session(server))) await this.auth.signIn(server, parsed.invite);
|
||||
const joined = await this.api(server).acceptInvite(parsed.invite);
|
||||
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
|
||||
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: joined.space.id, name: joined.space.name })));
|
||||
|
||||
const inWorkspace = vscode.workspace.getWorkspaceFolder(root);
|
||||
if (inWorkspace) {
|
||||
await this.open(linkUri, 'join');
|
||||
vscode.window.showInformationMessage(`Archify: you joined ${joined.space.name}. Its files are arriving in ${vscode.workspace.asRelativePath(root)}.`);
|
||||
return;
|
||||
}
|
||||
const open = await vscode.window.showInformationMessage(
|
||||
`Archify: you joined ${joined.space.name}. Open its folder to start working together.`,
|
||||
'Open folder',
|
||||
'Open in new window',
|
||||
);
|
||||
if (open) await vscode.commands.executeCommand('vscode.openFolder', root, { forceNewWindow: open === 'Open in new window' });
|
||||
}
|
||||
|
||||
private async publish(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: `Archify | Publish ${space.name}`,
|
||||
prompt: 'Name this version, for example "Q4 baseline"',
|
||||
validateInput: (v) => (v.trim() ? undefined : 'Give the version a name.'),
|
||||
});
|
||||
if (!name) return;
|
||||
const version = await this.api(space.link.server).publish(space.link.space, name.trim());
|
||||
this.versionsChanged(space);
|
||||
vscode.window.showInformationMessage(`Archify: published ${version.major}.${version.minor} "${version.name}" of ${space.name}.`);
|
||||
}
|
||||
|
||||
private async stop(arg: unknown): Promise<void> {
|
||||
const space = await this.pickSpace(arg);
|
||||
if (!space) return;
|
||||
const ok = await vscode.window.showWarningMessage(
|
||||
`Stop syncing ${space.name} here?`,
|
||||
{ modal: true, detail: `The files stay as they are. ${LINK_FILE} is removed, and edits here no longer go to the others. The model stays on the server.` },
|
||||
'Stop syncing',
|
||||
);
|
||||
if (ok !== 'Stop syncing') return;
|
||||
this.close(space.root, true);
|
||||
await vscode.workspace.fs.delete(vscode.Uri.joinPath(space.root, LINK_FILE)).then(undefined, () => undefined);
|
||||
}
|
||||
|
||||
private async menu(): Promise<void> {
|
||||
const spaces = [...this.spaces.values()];
|
||||
const signedOut = spaces.find((s) => s.state === 'signed-out');
|
||||
const servers = await this.auth.servers();
|
||||
const configured = configuredServer();
|
||||
type Item = vscode.QuickPickItem & { run?: () => Promise<void> };
|
||||
const items: Item[] = [];
|
||||
if (signedOut) {
|
||||
items.push({
|
||||
label: `$(account) Sign in to ${new URL(signedOut.link.server).host}`,
|
||||
run: async () => {
|
||||
if (await this.checkServer(signedOut.link.server)) await this.auth.signIn(signedOut.link.server);
|
||||
},
|
||||
});
|
||||
} else if (!servers.length) {
|
||||
items.push({
|
||||
label: '$(account) Sign in…',
|
||||
description: configured ? new URL(configured).host : 'asks for the server address',
|
||||
run: () => this.signInCommand(),
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ label: '$(live-share) Share a model…', run: () => this.share(undefined) },
|
||||
{ label: '$(link) Join a shared model…', description: 'with an invite link', run: () => this.join(undefined) },
|
||||
);
|
||||
if (spaces.length) {
|
||||
items.push(
|
||||
{ label: 'Shared models', kind: vscode.QuickPickItemKind.Separator },
|
||||
{ label: '$(person-add) Copy invite link…', run: () => this.invite(undefined) },
|
||||
{ label: '$(tag) Publish version…', run: () => this.publish(undefined) },
|
||||
{ label: '$(history) Versions…', run: () => this.run('archify.sync.versions') },
|
||||
{ label: '$(debug-disconnect) Stop syncing a model…', run: () => this.stop(undefined) },
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
{ label: 'Settings', kind: vscode.QuickPickItemKind.Separator },
|
||||
{ label: '$(server) Server address…', description: configured ? new URL(configured).host : 'not set', run: () => this.setServer() },
|
||||
{ label: '$(settings-gear) Open Sync settings', run: async () => void vscode.commands.executeCommand('workbench.action.openSettings', 'archify.sync') },
|
||||
{ label: '$(output) Show log', run: async () => this.log.show() },
|
||||
);
|
||||
if (servers.length) items.push({ label: '$(sign-out) Sign out…', description: servers.map((s) => new URL(s).host).join(', '), run: () => this.signOut() });
|
||||
const pick = await vscode.window.showQuickPick(items, { title: 'Archify Sync' });
|
||||
await pick?.run?.();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.cursorTimer);
|
||||
this.bridge?.setProvider(undefined);
|
||||
for (const space of this.spaces.values()) space.dispose();
|
||||
this.spaces.clear();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** archify.sync.server when it holds a usable address. */
|
||||
function configuredServer(): string | undefined {
|
||||
const value = vscode.workspace.getConfiguration('archify.sync').get<string>('server', '').trim();
|
||||
return value ? checkServerUrl(value).url : undefined;
|
||||
}
|
||||
|
||||
/** The file behind the active tab when it is a custom editor (the Archify pages). */
|
||||
function activeTabUri(): vscode.Uri | undefined {
|
||||
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||
return input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||
}
|
||||
|
||||
export { SyncApiError };
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// What the model page shows of a shared model. Lives in the main bundle; the
|
||||
// sync bundle plugs in as the provider once it loads. Without it, the model
|
||||
// page has no Live section and nothing else changes.
|
||||
|
||||
export interface LivePerson {
|
||||
name: string;
|
||||
/** Model-relative path of the file the person has open. */
|
||||
file?: string;
|
||||
me?: boolean;
|
||||
}
|
||||
|
||||
export interface LiveVersion {
|
||||
id: number;
|
||||
label: string;
|
||||
kind: 'snapshot' | 'publish' | 'restore';
|
||||
name?: string;
|
||||
/** ISO time. */
|
||||
at: string;
|
||||
restoredFrom?: string;
|
||||
}
|
||||
|
||||
export interface LivePanel {
|
||||
name: string;
|
||||
host: string;
|
||||
state: 'connecting' | 'online' | 'offline' | 'signed-out' | 'forbidden' | 'outdated';
|
||||
people: LivePerson[];
|
||||
versions?: LiveVersion[];
|
||||
versionsProblem?: string;
|
||||
}
|
||||
|
||||
/** Someone else's cursor in a file, as offsets into its "\n" text. */
|
||||
export interface LiveCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
/** A selection as offsets into a file's "\n" text. */
|
||||
export interface LiveSelection {
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
export type LiveAction =
|
||||
| { kind: 'publish' }
|
||||
| { kind: 'invite' }
|
||||
| { kind: 'signIn' }
|
||||
| { kind: 'versions' }
|
||||
| { kind: 'compare'; version: number }
|
||||
| { kind: 'restore'; version: number };
|
||||
|
||||
export interface LiveProvider {
|
||||
panel(root: vscode.Uri): Promise<LivePanel | undefined>;
|
||||
act(root: vscode.Uri, action: LiveAction): Promise<void>;
|
||||
/** Others' cursors in a shared file; empty when the file is not shared. */
|
||||
cursors(file: vscode.Uri): LiveCursor[];
|
||||
/** This person's cursor in a page's Source view; undefined when it closes. */
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void;
|
||||
}
|
||||
|
||||
class LiveBridge implements vscode.Disposable {
|
||||
private provider: LiveProvider | undefined;
|
||||
private readonly emitter = new vscode.EventEmitter<vscode.Uri | undefined>();
|
||||
/** A model root whose Live section changed; undefined for all of them. */
|
||||
readonly onDidChange = this.emitter.event;
|
||||
private readonly cursorEmitter = new vscode.EventEmitter<void>();
|
||||
/** Someone's cursor moved somewhere; ask `cursors` for the file you show. */
|
||||
readonly onDidChangeCursors = this.cursorEmitter.event;
|
||||
|
||||
setProvider(provider: LiveProvider | undefined): void {
|
||||
this.provider = provider;
|
||||
this.emitter.fire(undefined);
|
||||
this.cursorEmitter.fire();
|
||||
}
|
||||
|
||||
changed(root?: vscode.Uri): void {
|
||||
this.emitter.fire(root);
|
||||
}
|
||||
|
||||
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
|
||||
return this.provider?.panel(root);
|
||||
}
|
||||
|
||||
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
|
||||
await this.provider?.act(root, action);
|
||||
}
|
||||
|
||||
cursorsChanged(): void {
|
||||
this.cursorEmitter.fire();
|
||||
}
|
||||
|
||||
cursors(file: vscode.Uri): LiveCursor[] {
|
||||
return this.provider?.cursors(file) ?? [];
|
||||
}
|
||||
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
|
||||
this.provider?.setCursor(file, selection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.emitter.dispose();
|
||||
this.cursorEmitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export const liveBridge = new LiveBridge();
|
||||
export type { LiveBridge };
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as Y from 'yjs';
|
||||
import { Awareness } from 'y-protocols/awareness';
|
||||
import { syncSocketUrl } from './api';
|
||||
import { SyncAuth } from './auth';
|
||||
import { ConnectionState, LiveConnection } from './connection';
|
||||
import { FileBridge } from './fileBridge';
|
||||
import { OfflineStore } from './offlineStore';
|
||||
import { colorFor } from './cursors';
|
||||
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||
import { Link } from './protocol';
|
||||
|
||||
/** How a space starts: as found in the workspace, just shared from here, or just joined. */
|
||||
export type StartMode = 'open' | 'share' | 'join';
|
||||
|
||||
/** Edits loaded from the offline copy; not stored again. */
|
||||
const FROM_COPY = Symbol('offline copy');
|
||||
const COMPACT_AFTER = 200;
|
||||
|
||||
export interface Presence {
|
||||
user: { id: string; name: string };
|
||||
/** Model-relative path of the file the person has in front of them. */
|
||||
file?: string;
|
||||
/** Where the person's cursor is: Yjs relative positions, so it stays put while others type. */
|
||||
cursor?: { file: string; anchor: unknown; head: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared model in this window: the Yjs document, its offline copy, the
|
||||
* connection to the server, the bridge to the files, and presence.
|
||||
*/
|
||||
export class LiveSpace implements vscode.Disposable {
|
||||
readonly doc = new Y.Doc();
|
||||
readonly awareness = new Awareness(this.doc);
|
||||
private readonly store: OfflineStore;
|
||||
private readonly local = { origin: 'local' };
|
||||
private readonly bridge: FileBridge;
|
||||
private connection?: LiveConnection;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private hadCopy = false;
|
||||
private firstSync = true;
|
||||
|
||||
constructor(
|
||||
readonly root: vscode.Uri,
|
||||
readonly link: Link,
|
||||
private readonly mode: StartMode,
|
||||
private readonly services: {
|
||||
context: vscode.ExtensionContext;
|
||||
auth: SyncAuth;
|
||||
log: vscode.LogOutputChannel;
|
||||
onChange: () => void;
|
||||
/** Someone's cursor moved. */
|
||||
onCursors?: () => void;
|
||||
},
|
||||
) {
|
||||
const dir = path.join(services.context.globalStorageUri.fsPath, 'sync');
|
||||
this.store = new OfflineStore(dir, link.space);
|
||||
this.bridge = new FileBridge(root, this.doc, this.local, services.log);
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this.connection?.state ?? 'connecting';
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.link.name ?? path.basename(this.root.fsPath);
|
||||
}
|
||||
|
||||
/** Distinct people in the space, this one included. */
|
||||
people(): Presence[] {
|
||||
const seen = new Map<string, Presence>();
|
||||
for (const state of this.awareness.getStates().values()) {
|
||||
const p = state as Partial<Presence>;
|
||||
if (p.user?.id) seen.set(p.user.id, { user: p.user, file: p.file });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/** Puts this person's cursor in a shared file; false when the file is not part of this model. */
|
||||
setCursor(uri: vscode.Uri, selection: LiveSelection | undefined): boolean {
|
||||
const rel = this.bridge.relative(uri);
|
||||
if (!rel) return false;
|
||||
const current = this.awareness.getLocalState() as Presence | null;
|
||||
if (!current) return true;
|
||||
if (!selection) {
|
||||
// Only the file the cursor is in can take it away.
|
||||
if (current.cursor?.file === rel) this.awareness.setLocalStateField('cursor', undefined);
|
||||
return true;
|
||||
}
|
||||
const text = this.doc.getMap<Y.Text>('files').get(rel);
|
||||
if (!(text instanceof Y.Text)) return true;
|
||||
const at = (offset: number) => Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(text, Math.max(0, Math.min(offset, text.length))));
|
||||
this.awareness.setLocalStateField('cursor', { file: rel, anchor: at(selection.anchor), head: at(selection.head) });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Other people's cursors in a file, as offsets into its text here. */
|
||||
cursors(uri: vscode.Uri): LiveCursor[] {
|
||||
const rel = this.bridge.relative(uri);
|
||||
if (!rel) return [];
|
||||
const found: LiveCursor[] = [];
|
||||
for (const [client, state] of this.awareness.getStates()) {
|
||||
if (client === this.doc.clientID) continue;
|
||||
const p = state as Partial<Presence>;
|
||||
if (!p.user?.id || p.cursor?.file !== rel) continue;
|
||||
const index = (json: unknown) => {
|
||||
try {
|
||||
return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), this.doc)?.index;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const anchor = index(p.cursor.anchor);
|
||||
const head = index(p.cursor.head);
|
||||
if (anchor === undefined || head === undefined) continue;
|
||||
found.push({ name: p.user.name, color: colorFor(p.user.id), anchor, head });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const { log } = this.services;
|
||||
this.hadCopy = this.store.exists();
|
||||
for (const update of this.store.load()) Y.applyUpdate(this.doc, update, FROM_COPY);
|
||||
// Registered before the connection's listener, so every edit is on disk before it is sent.
|
||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === FROM_COPY) return;
|
||||
this.store.append(update);
|
||||
if (this.store.pending >= COMPACT_AFTER) this.compact();
|
||||
});
|
||||
|
||||
if (this.hadCopy) {
|
||||
// Edits made while the extension was not running (git pull, another tool).
|
||||
await this.bridge.pushAll(true);
|
||||
} else if (this.mode === 'share') {
|
||||
await this.bridge.pushAll(false);
|
||||
}
|
||||
// Without a copy, local edits wait for the server's files, so a file
|
||||
// does not start twice (once here, once there) and lose one side.
|
||||
this.bridge.start(this.hadCopy || this.mode === 'share');
|
||||
|
||||
const session = await this.services.auth.session(this.link.server);
|
||||
if (session) this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(() => this.updateFile()),
|
||||
vscode.window.tabGroups.onDidChangeTabs(() => this.updateFile()),
|
||||
{ dispose: () => this.awareness.destroy() },
|
||||
);
|
||||
this.updateFile();
|
||||
let people = '';
|
||||
this.awareness.on('change', () => {
|
||||
// Cursors move all the time; the status bar and pages only care who is where.
|
||||
const now = JSON.stringify(this.people());
|
||||
if (now !== people) {
|
||||
people = now;
|
||||
this.services.onChange();
|
||||
}
|
||||
this.services.onCursors?.();
|
||||
});
|
||||
|
||||
this.connection = new LiveConnection({
|
||||
url: syncSocketUrl(this.link.server, this.link.space),
|
||||
token: (force) => this.services.auth.accessToken(this.link.server, force),
|
||||
doc: this.doc,
|
||||
awareness: this.awareness,
|
||||
onState: (state) => {
|
||||
log.info(`sync: ${this.name} is ${state}`);
|
||||
this.services.onChange();
|
||||
},
|
||||
onSynced: () => void this.synced(),
|
||||
log: (message) => log.info(message),
|
||||
});
|
||||
}
|
||||
|
||||
private async synced(): Promise<void> {
|
||||
if (!this.firstSync) return;
|
||||
this.firstSync = false;
|
||||
if (!this.hadCopy && this.mode !== 'share') {
|
||||
// First time here: the server's files come to disk, then local-only files go up.
|
||||
await this.bridge.pullAll();
|
||||
this.bridge.start(true);
|
||||
this.hadCopy = true;
|
||||
}
|
||||
this.compact();
|
||||
}
|
||||
|
||||
private compact(): void {
|
||||
try {
|
||||
this.store.compact(Y.encodeStateAsUpdate(this.doc));
|
||||
} catch (error) {
|
||||
this.services.log.warn(`sync: could not compact the offline copy (${String(error)})`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateFile(): void {
|
||||
const current = this.awareness.getLocalState() as Presence | null;
|
||||
if (!current) return;
|
||||
// The text editor, or the tab of an Archify page (the model page, a diagram, a registry).
|
||||
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
|
||||
const tab = input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
|
||||
const uri = vscode.window.activeTextEditor?.document.uri ?? tab;
|
||||
const file = uri ? this.bridge.relative(uri) : undefined;
|
||||
if (current.file !== file) this.awareness.setLocalState({ ...current, file });
|
||||
}
|
||||
|
||||
/** After signing in again. */
|
||||
reconnect(): void {
|
||||
void this.services.auth.session(this.link.server).then((session) => {
|
||||
if (session && !this.awareness.getLocalState()) {
|
||||
this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
|
||||
}
|
||||
this.connection?.reconnectNow();
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops syncing this model here and forgets the offline copy. */
|
||||
forget(): void {
|
||||
this.dispose();
|
||||
this.store.remove();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.connection?.dispose();
|
||||
this.connection = undefined;
|
||||
this.bridge.dispose();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
if (this.store.pending) this.compact();
|
||||
this.doc.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import type { ModelService } from '../model/service';
|
||||
import { log } from '../util/log';
|
||||
import type { SyncFeature } from './index';
|
||||
import { liveBridge } from './liveBridge';
|
||||
|
||||
// Kept in step with SYNC_COMMANDS in ./index (which lives in the other bundle).
|
||||
const COMMANDS = [
|
||||
'archify.sync.signIn',
|
||||
'archify.sync.signOut',
|
||||
'archify.sync.share',
|
||||
'archify.sync.invite',
|
||||
'archify.sync.join',
|
||||
'archify.sync.publish',
|
||||
'archify.sync.versions',
|
||||
'archify.sync.stop',
|
||||
'archify.sync.menu',
|
||||
'archify.sync.setServer',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Archify Sync is optional. This registers its commands and the URI handler,
|
||||
* and loads dist/sync.js (Yjs, the WebSocket) only when it is needed: a model
|
||||
* in the workspace is shared, someone signed in on this machine, or a sync
|
||||
* command or link runs. Without that, nothing else happens.
|
||||
*/
|
||||
export function registerSync(context: vscode.ExtensionContext, models: ModelService): vscode.Disposable[] {
|
||||
let feature: Promise<SyncFeature> | undefined;
|
||||
const load = (): Promise<SyncFeature> =>
|
||||
(feature ??= (async () => {
|
||||
// A runtime path, so esbuild leaves the sync bundle out of this one.
|
||||
const bundle = path.join(__dirname, 'sync.js');
|
||||
const mod = require(bundle) as typeof import('./index');
|
||||
const sync = new mod.SyncFeature(context, models, log(), liveBridge);
|
||||
context.subscriptions.push(sync);
|
||||
await sync.start();
|
||||
return sync;
|
||||
})());
|
||||
|
||||
void (async () => {
|
||||
const used = context.globalState.get<boolean>('archifySync.used') === true;
|
||||
const shared = used || (await vscode.workspace.findFiles('**/.archify-sync', '{**/node_modules/**,**/.git/**}', 1)).length > 0;
|
||||
if (shared) load().catch((error) => log().error(`sync: did not start: ${String(error)}`));
|
||||
})();
|
||||
|
||||
return [
|
||||
liveBridge,
|
||||
...COMMANDS.map((id) =>
|
||||
vscode.commands.registerCommand(id, async (...args: unknown[]) => (await load()).run(id, ...args)),
|
||||
),
|
||||
vscode.window.registerUriHandler({ handleUri: async (uri) => (await load()).handleUri(uri) }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* The offline copy of one shared model: the last compacted Yjs state and a
|
||||
* log of every update since, each written before it is sent. Lets people
|
||||
* keep working while the server is away and catch up afterwards.
|
||||
*/
|
||||
export class OfflineStore {
|
||||
private readonly statePath: string;
|
||||
private readonly logPath: string;
|
||||
private logged = 0;
|
||||
|
||||
constructor(dir: string, id: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
this.statePath = path.join(dir, `${id}.state`);
|
||||
this.logPath = path.join(dir, `${id}.log`);
|
||||
}
|
||||
|
||||
exists(): boolean {
|
||||
return fs.existsSync(this.statePath) || fs.existsSync(this.logPath);
|
||||
}
|
||||
|
||||
/** Updates to apply in order: the state, then the log. A torn last entry is skipped. */
|
||||
load(): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
if (fs.existsSync(this.statePath)) out.push(new Uint8Array(fs.readFileSync(this.statePath)));
|
||||
if (fs.existsSync(this.logPath)) {
|
||||
const log = fs.readFileSync(this.logPath);
|
||||
let at = 0;
|
||||
while (at + 4 <= log.length) {
|
||||
const length = log.readUInt32LE(at);
|
||||
if (at + 4 + length > log.length) break;
|
||||
out.push(new Uint8Array(log.subarray(at + 4, at + 4 + length)));
|
||||
at += 4 + length;
|
||||
this.logged++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Appends one update; synchronous, so it is on disk before the caller sends it. */
|
||||
append(update: Uint8Array): void {
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt32LE(update.length);
|
||||
fs.appendFileSync(this.logPath, Buffer.concat([head, update]));
|
||||
this.logged++;
|
||||
}
|
||||
|
||||
/** Updates in the log since the last compaction. */
|
||||
get pending(): number {
|
||||
return this.logged;
|
||||
}
|
||||
|
||||
/** Replaces state and log with one encoded state. */
|
||||
compact(state: Uint8Array): void {
|
||||
const tmp = `${this.statePath}.tmp`;
|
||||
fs.writeFileSync(tmp, state);
|
||||
fs.renameSync(tmp, this.statePath);
|
||||
fs.rmSync(this.logPath, { force: true });
|
||||
this.logged = 0;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
fs.rmSync(this.statePath, { force: true });
|
||||
fs.rmSync(this.logPath, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// What the extension and the Archify Sync server agree on. Mirrors
|
||||
// `packages/protocol` (generated from Rust) in archify-vscode-ext-sync; keep
|
||||
// both in step when the protocol changes.
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
/** Root map of a space's Yjs document: model-root-relative POSIX path → Y.Text. */
|
||||
export const FILES_MAP = 'files';
|
||||
/** The link file in a model root: names server and space, holds no secrets. */
|
||||
export const LINK_FILE = '.archify-sync';
|
||||
|
||||
export type OrgRole = 'admin' | 'member';
|
||||
export type SpaceRole = 'owner' | 'editor';
|
||||
export type VersionKind = 'snapshot' | 'publish' | 'restore';
|
||||
|
||||
export interface ApiError { error: string; message: string }
|
||||
export interface Account { id: string; organisation_id: string; name: string; display_name: string; role: OrgRole }
|
||||
export interface TokenResponse { access_token: string; expires_in: number; refresh_token: string; account: Account }
|
||||
export interface ServerInfo { name: string; version: string; protocol: number; set_up: boolean }
|
||||
export interface Space { id: string; name: string; role: SpaceRole; created_at: string }
|
||||
export interface Member { account_id: string; name: string; display_name: string; role: SpaceRole }
|
||||
export interface Invite { token: string; url: string; expires_at: string }
|
||||
export interface JoinResponse { space: Space }
|
||||
export interface Version {
|
||||
id: number;
|
||||
major: number;
|
||||
minor: number;
|
||||
kind: VersionKind;
|
||||
name: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
restored_from: number | null;
|
||||
}
|
||||
export interface VersionFiles { version: Version; files: { path: string; text: string }[] }
|
||||
|
||||
/** The contents of `.archify-sync`. */
|
||||
export interface Link {
|
||||
server: string;
|
||||
space: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function parseLink(text: string): Link | undefined {
|
||||
try {
|
||||
const value = JSON.parse(text) as Partial<Link>;
|
||||
if (typeof value.server === 'string' && typeof value.space === 'string' && /^[0-9a-f-]{36}$/i.test(value.space)) {
|
||||
return { server: value.server.replace(/\/+$/, ''), space: value.space, name: typeof value.name === 'string' ? value.name : undefined };
|
||||
}
|
||||
} catch {
|
||||
// not a link
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatLink(link: Link): string {
|
||||
return `${JSON.stringify({ server: link.server, space: link.space, name: link.name }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function versionLabel(v: Version): string {
|
||||
return `${v.major}.${v.minor}`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TextChange, minimalChange } from '../util/textDiff';
|
||||
|
||||
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
||||
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/**
|
||||
* `minimalChange`, widened so it never splits a surrogate pair: Yjs would
|
||||
* replace half a pair with U+FFFD, and every copy would get the broken text.
|
||||
*/
|
||||
export function safeChange(before: string, after: string): TextChange | undefined {
|
||||
const change = minimalChange(before, after);
|
||||
if (!change) return undefined;
|
||||
let { from, to } = change;
|
||||
let endAfter = after.length - (before.length - to);
|
||||
// A common start ending in a high surrogate, or a common end starting with
|
||||
// a low one, would split a pair: take the whole character into the change.
|
||||
if (from > 0 && isHigh(before.charCodeAt(from - 1))) from--;
|
||||
if (to < before.length && isLow(before.charCodeAt(to))) {
|
||||
to++;
|
||||
endAfter++;
|
||||
}
|
||||
return { from, to, insert: after.slice(from, endAfter) };
|
||||
}
|
||||
|
||||
/** Makes the Y.Text read `after` with one minimal edit; false when it already does. */
|
||||
export function applyToYText(text: Y.Text, after: string): boolean {
|
||||
const change = safeChange(text.toString(), after);
|
||||
if (!change) return false;
|
||||
if (change.to > change.from) text.delete(change.from, change.to - change.from);
|
||||
if (change.insert) text.insert(change.from, change.insert);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SyncApi } from './api';
|
||||
import { LiveSpace } from './liveSpace';
|
||||
import { Version, VersionFiles, versionLabel } from './protocol';
|
||||
|
||||
export const VERSION_SCHEME = 'archify-sync-version';
|
||||
|
||||
/** Read-only documents of a version's files: `archify-sync-version:/<space>/<version>/<path>`. */
|
||||
export class VersionContent implements vscode.TextDocumentContentProvider {
|
||||
private readonly cache = new Map<string, Promise<VersionFiles>>();
|
||||
|
||||
constructor(private readonly api: (space: string) => SyncApi | undefined) {}
|
||||
|
||||
files(space: string, version: number): Promise<VersionFiles> {
|
||||
const key = `${space}/${version}`;
|
||||
let found = this.cache.get(key);
|
||||
if (!found) {
|
||||
const api = this.api(space);
|
||||
if (!api) return Promise.reject(new Error('This model is not shared from here.'));
|
||||
found = api.versionFiles(space, version);
|
||||
found.catch(() => this.cache.delete(key));
|
||||
this.cache.set(key, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
|
||||
const [, space, version, ...rest] = uri.path.split('/');
|
||||
const files = await this.files(space, Number(version));
|
||||
return files.files.find((f) => f.path === rest.join('/'))?.text ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function describe(v: Version): vscode.QuickPickItem & { version: Version } {
|
||||
const when = new Date(v.created_at).toLocaleString();
|
||||
const kind = v.kind === 'publish' ? 'published' : v.kind === 'restore' ? `restored ${v.restored_from ?? ''}`.trim() : 'snapshot';
|
||||
return {
|
||||
label: `${v.kind === 'publish' ? '$(tag)' : v.kind === 'restore' ? '$(history)' : '$(circle-small)'} ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}`,
|
||||
description: kind,
|
||||
detail: when,
|
||||
version: v,
|
||||
};
|
||||
}
|
||||
|
||||
/** Versions of a space: pick one, then compare with the files here or restore it. */
|
||||
export async function showVersions(space: LiveSpace, api: SyncApi, content: VersionContent): Promise<void> {
|
||||
const versions = await api.versions(space.link.space);
|
||||
if (!versions.length) {
|
||||
vscode.window.showInformationMessage(`Archify: ${space.name} has no versions yet. One is taken on its own after a pause in editing.`);
|
||||
return;
|
||||
}
|
||||
const picked = await vscode.window.showQuickPick(versions.map(describe), { title: `Archify | Versions of ${space.name}`, placeHolder: 'Pick a version' });
|
||||
if (!picked) return;
|
||||
const v = picked.version;
|
||||
const action = await vscode.window.showQuickPick(
|
||||
[
|
||||
{ label: '$(diff) Compare with the files here', id: 'compare' },
|
||||
{ label: '$(history) Restore this version', id: 'restore', detail: 'Brings these files back for everyone, as a new version. Nothing is lost.' },
|
||||
],
|
||||
{ title: `Archify | ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}` },
|
||||
);
|
||||
if (action?.id === 'compare') await compareVersion(space, v, content);
|
||||
if (action?.id === 'restore') await restoreVersion(space, api, v);
|
||||
}
|
||||
|
||||
/** Asks, then brings back a version's files for everyone as a new version. */
|
||||
export async function restoreVersion(space: LiveSpace, api: SyncApi, v: Version): Promise<boolean> {
|
||||
const ok = await vscode.window.showWarningMessage(
|
||||
`Restore ${versionLabel(v)} of ${space.name} for everyone?`,
|
||||
{ modal: true, detail: 'The files go back to this version for everyone in the space. It becomes a new version; the history stays.' },
|
||||
'Restore',
|
||||
);
|
||||
if (ok !== 'Restore') return false;
|
||||
const restored = await api.restore(space.link.space, v.id);
|
||||
vscode.window.showInformationMessage(`Archify: restored ${versionLabel(v)} as ${versionLabel(restored)}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Lists the files that differ from a version and opens the diff of the one picked. */
|
||||
export async function compareVersion(space: LiveSpace, v: Version, content: VersionContent): Promise<void> {
|
||||
const files = await content.files(space.link.space, v.id);
|
||||
const here = new Map<string, string>();
|
||||
for (const [rel, text] of space.doc.getMap<{ toString(): string }>('files').entries()) here.set(rel, text.toString());
|
||||
const paths = [...new Set([...files.files.map((f) => f.path), ...here.keys()])].sort();
|
||||
const changed = paths
|
||||
.map((p) => ({ path: p, then: files.files.find((f) => f.path === p)?.text, now: here.get(p) }))
|
||||
.filter((f) => f.then !== f.now);
|
||||
if (!changed.length) {
|
||||
vscode.window.showInformationMessage(`Archify: the files here are the same as ${versionLabel(v)}.`);
|
||||
return;
|
||||
}
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
changed.map((f) => ({ label: f.path, description: f.then === undefined ? 'added since' : f.now === undefined ? 'removed since' : 'changed', path: f.path })),
|
||||
{ title: `Archify | Changes since ${versionLabel(v)}`, placeHolder: `${changed.length} file(s) differ` },
|
||||
);
|
||||
if (!pick) return;
|
||||
const left = vscode.Uri.from({ scheme: VERSION_SCHEME, path: `/${space.link.space}/${v.id}/${pick.path}` });
|
||||
const right = vscode.Uri.joinPath(space.root, ...pick.path.split('/'));
|
||||
await vscode.commands.executeCommand('vscode.diff', left, right, `${pick.path} (${versionLabel(v)} ↔ here)`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/** Current text of a document: the open editor buffer, else the file on disk. */
|
||||
export async function readText(uri: vscode.Uri): Promise<string> {
|
||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||
if (open) return open.getText();
|
||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
let output: vscode.LogOutputChannel | undefined;
|
||||
|
||||
export function log(): vscode.LogOutputChannel {
|
||||
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ViewerTheme } from '../webview/page';
|
||||
|
||||
/** Diagram theme from archify.theme: forced, VS Code's, or the diagram's own. */
|
||||
export function viewerTheme(): ViewerTheme {
|
||||
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
||||
if (setting === 'dark' || setting === 'light') return setting;
|
||||
if (setting === 'diagram') return undefined;
|
||||
const kind = vscode.window.activeColorTheme.kind;
|
||||
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
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
|
||||
* toolbar and, when on, replaces the 2D canvas with a three.js stage built
|
||||
* from the live SVG. Archify keeps owning the diagram: clicks in 3D go to the SVG
|
||||
* nodes, so focus, Semantic Passport, reach and lens work as in 2D; the
|
||||
* stage redraws whenever Archify changes the SVG, theme or preset, and
|
||||
* follows Archify's animations as they play.
|
||||
*/
|
||||
|
||||
type Host = ArchifyHost;
|
||||
|
||||
interface SavedView {
|
||||
active?: boolean;
|
||||
layered?: boolean;
|
||||
camera?: CameraState;
|
||||
}
|
||||
|
||||
const REDRAW_MS = 220;
|
||||
const MARGIN = 80;
|
||||
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
|
||||
const TEXTURE_SCALE = 2;
|
||||
|
||||
|
||||
class Viewer3D {
|
||||
private stage: Stage | undefined;
|
||||
private model: DiagramModel | undefined;
|
||||
private active = false;
|
||||
private redrawTimer: number | undefined;
|
||||
private drawing: Promise<void> | undefined;
|
||||
private pendingRedraw = false;
|
||||
private readonly observer = new MutationObserver((records) => this.onMutation(records));
|
||||
/** Keeps Archify's zoom buttons usable in 3D (see holdZoomButtons). */
|
||||
private readonly zoomObserver = new MutationObserver(() => this.enableZoomButtons());
|
||||
private zoomDisabled = new Map<HTMLButtonElement, boolean>();
|
||||
private readonly toggle: HTMLButtonElement;
|
||||
private readonly stackButton: HTMLButtonElement;
|
||||
private readonly topButton: HTMLButtonElement;
|
||||
private readonly resetLabel: HTMLSpanElement;
|
||||
private ambientReplayed = false;
|
||||
private exportItem: HTMLButtonElement | undefined;
|
||||
private saved: SavedView;
|
||||
|
||||
constructor(
|
||||
private readonly host: Host,
|
||||
private readonly container: HTMLElement,
|
||||
private readonly svg: SVGSVGElement,
|
||||
) {
|
||||
this.saved = (host.getState().view3d as SavedView | undefined) ?? {};
|
||||
this.toggle = this.buildSwitch();
|
||||
({ stack: this.stackButton, top: this.topButton, reset: this.resetLabel } = this.buildNavControls());
|
||||
this.addExportItem();
|
||||
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 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');
|
||||
button.id = 'btn-3d';
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'switch');
|
||||
button.setAttribute('aria-checked', 'false');
|
||||
button.innerHTML =
|
||||
'<span class="archify-3d-seg" data-seg="2d" aria-hidden="true">2D</span>' +
|
||||
'<span class="archify-3d-seg" data-seg="3d" aria-hidden="true">3D</span>';
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
this.setActive(!this.active);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D-only controls in Archify's bottom-right dock, where its own view
|
||||
* controls live: Stacked (toggle) and From above, before the zoom stepper;
|
||||
* the stepper's "100%" (a 2D zoom level) reads "Reset" while 3D is on.
|
||||
*/
|
||||
private buildNavControls(): { stack: HTMLButtonElement; top: HTMLButtonElement; reset: HTMLSpanElement } {
|
||||
const nav = this.container.querySelector<HTMLElement>('.diagram-nav');
|
||||
const make = (id: string, label: string, title: string, pressed: boolean, action: () => void) => {
|
||||
const button = document.createElement('button');
|
||||
button.id = id;
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
if (pressed) button.setAttribute('aria-pressed', 'false');
|
||||
button.textContent = label;
|
||||
button.hidden = true;
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
action();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
const stack = make('btn-3d-stack', 'STACK', 'Stacked: clients on top, data at the bottom', true, () => {
|
||||
this.stage?.setLayered(!this.stage.layered);
|
||||
this.syncButtons();
|
||||
this.save();
|
||||
});
|
||||
const top = make('btn-3d-top', 'TOP', 'From above: look straight down, like 2D', false, () => this.stage?.top());
|
||||
const reset = document.createElement('span');
|
||||
reset.className = 'archify-3d-reset';
|
||||
reset.textContent = 'RESET';
|
||||
const zoomOut = nav?.querySelector('[data-view="out"]') ?? null;
|
||||
nav?.insertBefore(stack, zoomOut);
|
||||
nav?.insertBefore(top, zoomOut);
|
||||
const resetButton = nav?.querySelector<HTMLButtonElement>('[data-view="reset"]');
|
||||
if (resetButton) {
|
||||
resetButton.appendChild(reset);
|
||||
resetButton.dataset.archify3dTitle = resetButton.title;
|
||||
}
|
||||
return { stack, top, reset };
|
||||
}
|
||||
|
||||
private addExportItem(): void {
|
||||
const png = document.querySelector('.export-menu button[data-format="png"]');
|
||||
if (!png?.parentElement) return;
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
item.tabIndex = -1;
|
||||
item.hidden = true;
|
||||
item.dataset.archify3dExport = '';
|
||||
item.innerHTML = '<span class="export-item-copy"><strong>3D view</strong><small class="hint">PNG of the current 3D view</small></span>';
|
||||
item.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
document.getElementById('btn-export')?.click();
|
||||
this.exportPng();
|
||||
});
|
||||
png.parentElement.insertBefore(item, png);
|
||||
this.exportItem = item;
|
||||
}
|
||||
|
||||
/** Archify's zoom buttons and keys drive the 3D camera while 3D is on. */
|
||||
private interceptViewerControls(): void {
|
||||
this.container.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (!this.active || !this.stage) return;
|
||||
const button = (event.target as Element | null)?.closest?.('[data-view]');
|
||||
const view = button?.getAttribute('data-view');
|
||||
if (!view) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (view === 'in') this.stage.zoom(1.25);
|
||||
else if (view === 'out') this.stage.zoom(0.8);
|
||||
else if (view === 'reset') this.stage.fit();
|
||||
},
|
||||
true,
|
||||
);
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (!this.active || !this.stage || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
||||
const actions: Record<string, () => void> = {
|
||||
'0': () => this.stage!.fit(),
|
||||
'+': () => this.stage!.zoom(1.25),
|
||||
'=': () => this.stage!.zoom(1.25),
|
||||
'-': () => this.stage!.zoom(0.8),
|
||||
};
|
||||
const action = actions[event.key];
|
||||
if (!action) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
action();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private syncButtons(): void {
|
||||
const on = this.active;
|
||||
this.toggle.setAttribute('aria-checked', String(on));
|
||||
this.toggle.setAttribute('aria-label', on ? '3D view on; switch to 2D' : '2D view; switch to 3D');
|
||||
this.toggle.title = on ? 'Switch to 2D' : 'Switch to 3D';
|
||||
for (const seg of this.toggle.querySelectorAll<HTMLElement>('.archify-3d-seg')) seg.classList.toggle('on', (seg.dataset.seg === '3d') === on);
|
||||
this.stackButton.hidden = !on;
|
||||
this.stackButton.setAttribute('aria-pressed', String(!!this.stage?.layered));
|
||||
this.topButton.hidden = !on;
|
||||
const resetButton = this.resetLabel.parentElement as HTMLButtonElement | null;
|
||||
if (resetButton) resetButton.title = on ? 'Reset view: show the whole diagram (0)' : resetButton.dataset.archify3dTitle ?? resetButton.title;
|
||||
if (this.exportItem) this.exportItem.hidden = !on;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mode
|
||||
|
||||
setActive(on: boolean): void {
|
||||
this.active = on;
|
||||
document.documentElement.classList.toggle('archify-3d-on', on);
|
||||
if (on) {
|
||||
if (!this.stage) this.createStage();
|
||||
this.container.appendChild(this.stage!.element);
|
||||
this.stage!.start();
|
||||
this.observer.observe(this.svg, { subtree: true, attributes: true, childList: true, characterData: true });
|
||||
this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-preset'] });
|
||||
this.holdZoomButtons(true);
|
||||
void this.redraw().then(() => this.replayAmbient());
|
||||
} else {
|
||||
this.holdZoomButtons(false);
|
||||
this.observer.disconnect();
|
||||
this.stage?.stop();
|
||||
this.stage?.element.remove();
|
||||
}
|
||||
this.syncButtons();
|
||||
this.save();
|
||||
}
|
||||
|
||||
private zoomButtons(): HTMLButtonElement[] {
|
||||
return [...this.container.querySelectorAll<HTMLButtonElement>('button[data-view="in"], button[data-view="out"]')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Archify disables its zoom buttons at the 2D limits (100% and 300%). The
|
||||
* 3D camera has its own range, so the buttons stay enabled while 3D is on,
|
||||
* and get Archify's state back when 3D is turned off.
|
||||
*/
|
||||
private holdZoomButtons(on: boolean): void {
|
||||
if (on) {
|
||||
this.zoomDisabled = new Map(this.zoomButtons().map((b) => [b, b.disabled]));
|
||||
for (const button of this.zoomButtons()) this.zoomObserver.observe(button, { attributes: true, attributeFilter: ['disabled'] });
|
||||
this.enableZoomButtons();
|
||||
} else {
|
||||
this.zoomObserver.disconnect();
|
||||
for (const [button, disabled] of this.zoomDisabled) button.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
private enableZoomButtons(): void {
|
||||
for (const button of this.zoomButtons()) if (button.disabled) button.disabled = false;
|
||||
}
|
||||
|
||||
private createStage(): void {
|
||||
const stage = new Stage();
|
||||
stage.onPick = (id) => this.pick(id);
|
||||
stage.onHover = (id) => this.hover(id);
|
||||
stage.onCameraChange = () => this.save();
|
||||
if (this.saved.layered) stage.setLayered(true, false);
|
||||
if (this.saved.camera) stage.restoreCamera(this.saved.camera);
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archify plays its ambient trace once, when the page opens. Opening 3D
|
||||
* later replays it once, so the 3D view starts the way 2D did. Readers who
|
||||
* paused motion (Live off) or prefer reduced motion get no replay.
|
||||
*/
|
||||
private replayAmbient(): void {
|
||||
const html = document.documentElement;
|
||||
if (this.ambientReplayed || !this.active) return;
|
||||
// The load-time pass may still be playing (usually under the not-yet-built
|
||||
// 3D view): wait for it to settle, then replay it in 3D.
|
||||
if (html.getAttribute('data-ambient-motion') === 'running') {
|
||||
const wait = new MutationObserver(() => {
|
||||
if (html.getAttribute('data-ambient-motion') === 'running') return;
|
||||
wait.disconnect();
|
||||
this.replayAmbient();
|
||||
});
|
||||
wait.observe(html, { attributes: true, attributeFilter: ['data-ambient-motion'] });
|
||||
return;
|
||||
}
|
||||
this.ambientReplayed = true;
|
||||
if (html.getAttribute('data-ambient-motion') !== 'settled' || html.getAttribute('data-motion') === 'still') return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
if (!this.svg.querySelector('[data-animate]')) return;
|
||||
// Restart Archify's CSS trace: the "settled" state must be applied
|
||||
// (style flushed) before "running" again, or the animations do not restart.
|
||||
html.setAttribute('data-ambient-motion', 'settled');
|
||||
void getComputedStyle(this.svg.querySelector('[data-animate]')!).animationName;
|
||||
html.setAttribute('data-ambient-motion', 'running');
|
||||
const settle = () => html.setAttribute('data-ambient-motion', 'settled');
|
||||
const done = () => {
|
||||
if (this.svg.getAnimations({ subtree: true }).some((anim) => anim.playState === 'running')) return;
|
||||
this.svg.removeEventListener('animationend', done, true);
|
||||
this.svg.removeEventListener('animationcancel', done, true);
|
||||
settle();
|
||||
};
|
||||
this.svg.addEventListener('animationend', done, true);
|
||||
this.svg.addEventListener('animationcancel', done, true);
|
||||
// Safety net if an animation never reports its end.
|
||||
window.setTimeout(settle, 12000);
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
this.saved = { active: this.active, layered: this.stage?.layered ?? this.saved.layered, camera: this.stage?.cameraState() ?? this.saved.camera };
|
||||
this.host.setState({ view3d: this.saved });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- picking
|
||||
|
||||
/** Hands a 3D click to Archify, as if the reader had clicked the 2D node. */
|
||||
private pick(id: string | undefined): void {
|
||||
const target = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : this.svg;
|
||||
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
|
||||
private hovered: Element | undefined;
|
||||
|
||||
/**
|
||||
* Hands 3D hover to Archify as pointer events on the SVG node, so its
|
||||
* intent trace (one-hop flows, fading the rest) plays as in 2D.
|
||||
*/
|
||||
private hover(id: string | undefined): void {
|
||||
const next = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) ?? undefined : undefined;
|
||||
if (next === this.hovered) return;
|
||||
const init = { bubbles: true, cancelable: true, pointerType: 'mouse', isPrimary: true, view: window };
|
||||
this.hovered?.dispatchEvent(new PointerEvent('pointerout', init));
|
||||
next?.dispatchEvent(new PointerEvent('pointerover', init));
|
||||
this.hovered = next;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- redraw
|
||||
|
||||
private onMutation(records: MutationRecord[]): void {
|
||||
if (!records.some((r) => this.changesDrawing(r))) return;
|
||||
window.clearTimeout(this.redrawTimer);
|
||||
// Let Archify's 140–200ms state transitions finish before sampling styles.
|
||||
this.redrawTimer = window.setTimeout(() => void this.redraw(), REDRAW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mutation changes what the textures show. Archify's camera
|
||||
* (the SVG root's style), its temporary overlays and the ambient-motion
|
||||
* flag only change motion, which the stage follows live.
|
||||
*/
|
||||
private changesDrawing(record: MutationRecord): boolean {
|
||||
const target = record.target as Element;
|
||||
if (target === this.svg && record.attributeName === 'style') return false;
|
||||
if (target === document.documentElement) return record.attributeName === 'data-theme' || record.attributeName === 'data-preset';
|
||||
// Intent trace only fades and draws flows; the stage mirrors both live.
|
||||
if (record.attributeName?.startsWith('data-intent-trace')) return false;
|
||||
const inOverlay = (node: Node) => node instanceof Element && !!node.closest('[data-relationship-pulse-overlay], [data-intent-trace-overlay], [data-route-probe-overlay], [data-route-journey-overlay], [data-semantic-lens-overlay], [data-story-overlay], [data-story-carrier-overlay], [data-chapter-handoff-overlay], [data-relationship-hit-overlay]');
|
||||
if (record.type === 'childList') {
|
||||
const nodes = [...record.addedNodes, ...record.removedNodes].filter((n) => n.nodeType === Node.ELEMENT_NODE);
|
||||
if (nodes.length && nodes.every((n) => inOverlay(n) || [...(n as Element).getAttributeNames()].some((a) => a.endsWith('-overlay')))) return false;
|
||||
return nodes.length > 0 || !inOverlay(target);
|
||||
}
|
||||
return !inOverlay(target);
|
||||
}
|
||||
|
||||
private async redraw(): Promise<void> {
|
||||
if (this.drawing) {
|
||||
this.pendingRedraw = true;
|
||||
return this.drawing;
|
||||
}
|
||||
this.drawing = this.draw().finally(() => {
|
||||
this.drawing = undefined;
|
||||
if (this.pendingRedraw) {
|
||||
this.pendingRedraw = false;
|
||||
void this.redraw();
|
||||
}
|
||||
});
|
||||
return this.drawing;
|
||||
}
|
||||
|
||||
private async draw(): Promise<void> {
|
||||
if (!this.active || !this.stage) return;
|
||||
await document.fonts?.ready;
|
||||
const model = readModel(this.svg);
|
||||
this.model = model;
|
||||
const { viewBox } = model;
|
||||
const area = { x: viewBox.x - MARGIN, y: viewBox.y - MARGIN, w: viewBox.w + MARGIN * 2, h: viewBox.h + MARGIN * 2 };
|
||||
const scale = Math.min(TEXTURE_SCALE, 8192 / Math.max(area.w, area.h));
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const containerStyle = getComputedStyle(this.container);
|
||||
const background = opaque(containerStyle.backgroundColor) ?? (root.getPropertyValue('--bg').trim() || '#020617');
|
||||
const dot = root.getPropertyValue('--canvas-dot').trim();
|
||||
|
||||
const paintFloor = (ctx: CanvasRenderingContext2D) => {
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(area.x, area.y, area.w, area.h);
|
||||
if (dot) {
|
||||
ctx.fillStyle = dot;
|
||||
for (let x = Math.ceil(area.x / 20) * 20; x < area.x + area.w; x += 20) {
|
||||
for (let y = Math.ceil(area.y / 20) * 20; y < area.y + area.h; y += 20) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [floor, nodes, labels] = await Promise.all([
|
||||
rasterize(layerMarkup(this.svg, viewBox, { keep: ['floor'] }, scale), viewBox, area, scale, paintFloor),
|
||||
rasterize(layerMarkup(this.svg, viewBox, { keep: ['node'], opaqueNodes: true }, scale), viewBox, area, scale),
|
||||
rasterize(layerMarkup(this.svg, viewBox, { keep: ['label'] }, scale), viewBox, area, scale),
|
||||
]);
|
||||
if (!this.active || this.model !== model) return;
|
||||
const layers: Layers = { floor, nodes, labels };
|
||||
const mask = root.getPropertyValue('--mask').trim() || '#0f172a';
|
||||
this.stage.build(model, layers, { mask, dark: document.documentElement.getAttribute('data-theme') !== 'light' });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- export
|
||||
|
||||
private exportPng(): void {
|
||||
if (!this.stage) return;
|
||||
const shot = new Image();
|
||||
shot.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = shot.width;
|
||||
canvas.height = shot.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = opaque(getComputedStyle(this.container).backgroundColor) ?? getComputedStyle(document.documentElement).getPropertyValue('--bg');
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(shot, 0, 0);
|
||||
const data = canvas.toDataURL('image/png');
|
||||
const title = (document.querySelector('.header h1')?.textContent ?? 'archify').trim().replace(/[^\w.-]+/g, '-').toLowerCase();
|
||||
this.host.post({ type: 'save', filename: `${title || 'archify'}-3d.png`, base64: data.slice(data.indexOf(',') + 1) });
|
||||
};
|
||||
shot.src = this.stage.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/** A CSS colour if it is not fully transparent. */
|
||||
function opaque(value: string): string | undefined {
|
||||
if (!value || value === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
const host = window.__archifyHost;
|
||||
const container = document.querySelector<HTMLElement>('.diagram-container');
|
||||
const svg = container?.querySelector<SVGSVGElement>(':scope > svg');
|
||||
if (!host || !container || !svg) return;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = STYLE;
|
||||
document.head.appendChild(style);
|
||||
new Viewer3D(host, container, svg);
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') start();
|
||||
else window.addEventListener('load', start, { once: true });
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Box, Role, roleOf } from './svgModel';
|
||||
|
||||
/**
|
||||
* Turns parts of the live Archify SVG into images. Each element's computed
|
||||
* style is written inline into a standalone copy, so the image carries the
|
||||
* current theme, preset, focus and dimming exactly as the 2D view shows them.
|
||||
*/
|
||||
|
||||
/** Presentation properties copied from computed style. */
|
||||
const PROPS = [
|
||||
'fill', 'fill-opacity', 'fill-rule',
|
||||
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
|
||||
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
||||
// visibility is left out: the 3D view hides the 2D SVG with it.
|
||||
'opacity', 'display', 'filter', 'paint-order',
|
||||
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor',
|
||||
'dominant-baseline', 'text-transform',
|
||||
];
|
||||
|
||||
let fontCss: string | undefined;
|
||||
|
||||
/** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */
|
||||
function fontFaces(): string {
|
||||
if (fontCss !== undefined) return fontCss;
|
||||
const rules: string[] = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
let list: CSSRuleList;
|
||||
try {
|
||||
list = sheet.cssRules;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText);
|
||||
}
|
||||
fontCss = rules.join('\n');
|
||||
return fontCss;
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
/** Roles whose elements are drawn; everything else is hidden. */
|
||||
keep: Role[];
|
||||
/** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */
|
||||
opaqueNodes?: boolean;
|
||||
}
|
||||
|
||||
/** Copies computed styles from the live tree onto a clone with the same shape. */
|
||||
function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void {
|
||||
const role = inherited ?? roleOf(live);
|
||||
const isDefs = live.tagName === 'defs' || !!live.closest('defs');
|
||||
const style = getComputedStyle(live);
|
||||
const parts: string[] = [];
|
||||
for (const prop of PROPS) {
|
||||
const value = style.getPropertyValue(prop);
|
||||
if (value) parts.push(`${prop}:${value}`);
|
||||
}
|
||||
let hidden = false;
|
||||
if (root) {
|
||||
// The root carries viewer state (camera transform, our fade); keep only
|
||||
// what its children inherit.
|
||||
copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';'));
|
||||
} else {
|
||||
if (!isDefs) {
|
||||
if (role) hidden = !options.keep.includes(role);
|
||||
else if (live.children.length === 0) hidden = !options.keep.includes('floor');
|
||||
}
|
||||
if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1');
|
||||
if (hidden) parts.push('display:none');
|
||||
copy.setAttribute('style', parts.join(';'));
|
||||
}
|
||||
copy.removeAttribute('class');
|
||||
if (hidden) return;
|
||||
const liveKids = live.children;
|
||||
const copyKids = copy.children;
|
||||
for (let i = 0; i < liveKids.length && i < copyKids.length; i++) {
|
||||
inlineStyles(liveKids[i], copyKids[i], options, role, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone SVG markup for one layer of the diagram, sized at `scale`
|
||||
* pixels per user unit so the browser rasterizes it sharply.
|
||||
*/
|
||||
export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string {
|
||||
const copy = svg.cloneNode(true) as SVGSVGElement;
|
||||
inlineStyles(svg, copy, options, undefined, true);
|
||||
// Viewer-only children (focus overlays, hit areas) carry no data roles but
|
||||
// are not part of the authored drawing.
|
||||
copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove());
|
||||
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
|
||||
copy.setAttribute('width', String(Math.round(viewBox.w * scale)));
|
||||
copy.setAttribute('height', String(Math.round(viewBox.h * scale)));
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = fontFaces();
|
||||
copy.insertBefore(style, copy.firstChild);
|
||||
return new XMLSerializer().serializeToString(copy);
|
||||
}
|
||||
|
||||
export interface Raster {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Pixels per SVG user unit. */
|
||||
scale: number;
|
||||
/** SVG area the canvas covers (the view box plus a margin). */
|
||||
area: Box;
|
||||
}
|
||||
|
||||
/** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */
|
||||
export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise<Raster> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(area.w * scale));
|
||||
canvas.height = Math.max(1, Math.round(area.h * scale));
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-area.x, -area.y);
|
||||
paint?.(ctx);
|
||||
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
return { canvas, scale, area };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
/** Styles for the 3D mode; they reuse Archify's tokens so every preset fits. */
|
||||
export const STYLE = `
|
||||
.diagram-container { position: relative; }
|
||||
/* The 2D SVG stays in the page (its styles and animations drive the 3D
|
||||
view) but is not painted while 3D is on. */
|
||||
html.archify-3d-on .diagram-container > svg { visibility: hidden !important; pointer-events: none !important; }
|
||||
html.archify-3d-on .diagram-container { min-height: max(420px, 62vh); }
|
||||
.archify-3d-stage { position: absolute; inset: 0; z-index: 1; border-radius: inherit; overflow: hidden; }
|
||||
.archify-3d-stage canvas { display: block; touch-action: none; cursor: grab; }
|
||||
.archify-3d-stage canvas:active { cursor: grabbing; }
|
||||
html.archify-3d-on .diagram-container > :not(svg):not(.archify-3d-stage) { z-index: 2; }
|
||||
|
||||
/* 2D | 3D switch: Export's twin. Both states visible, the filled half is current. */
|
||||
.toolbar #btn-3d { padding: 0 0.25rem; gap: 0; color: var(--text-muted); }
|
||||
.toolbar #btn-3d::before { 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 #btn-3d: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); }
|
||||
.toolbar #btn-3d[aria-checked] { color: var(--text-muted); }
|
||||
.archify-3d-seg { display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; transition: background 0.15s, color 0.15s; }
|
||||
.archify-3d-seg.on { color: var(--bg); background: var(--text); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
.toolbar #btn-3d:hover .archify-3d-seg:not(.on) { color: var(--text); }
|
||||
|
||||
/* 3D-only controls in the bottom-right dock, in Archify's mode-button style. */
|
||||
.diagram-nav #btn-3d-stack,
|
||||
.diagram-nav #btn-3d-top { display: inline-flex; align-items: center; gap: 0.375rem; min-width: auto; padding-inline: 0.55rem 0.65rem; color: var(--text-muted); font-weight: 500; }
|
||||
.diagram-nav #btn-3d-stack[hidden],
|
||||
.diagram-nav #btn-3d-top[hidden] { display: none; }
|
||||
.diagram-nav #btn-3d-stack { position: relative; margin-left: 0.375rem; }
|
||||
.diagram-nav #btn-3d-stack::after { position: absolute; top: 0.55rem; bottom: 0.55rem; left: -0.19rem; width: 1px; background: color-mix(in srgb, var(--panel-border) 90%, transparent); content: ""; pointer-events: none; }
|
||||
.diagram-nav #btn-3d-stack::before,
|
||||
.diagram-nav #btn-3d-top::before { flex: none; width: 0.875rem; height: 0.875rem; background: currentColor; content: ""; opacity: 0.85; -webkit-mask: var(--nav-glyph) center / contain no-repeat; mask: var(--nav-glyph) center / contain no-repeat; }
|
||||
.diagram-nav #btn-3d-stack { --nav-glyph: 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.6' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.diagram-nav #btn-3d-top { --nav-glyph: 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.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.diagram-nav #btn-3d-stack[aria-pressed="true"] { color: var(--text); background: color-mix(in srgb, var(--frontend-stroke) 14%, transparent); }
|
||||
/* The stepper's middle button shows the 2D zoom level; in 3D it is the reset. */
|
||||
.archify-3d-reset { display: none; color: var(--text-muted); font-size: 0.75rem; font-weight: 500; }
|
||||
html.archify-3d-on .diagram-nav [data-view="reset"] > :not(.archify-3d-reset) { display: none !important; }
|
||||
html.archify-3d-on .archify-3d-reset { display: inline; }
|
||||
.toolbar .export-menu button[data-archify3d-export]::before { -webkit-mask-image: 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.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%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%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
|
||||
* relationships, their labels and structural frames. Archify marks them
|
||||
* with the same data attributes in all five diagram types, so the 3D view
|
||||
* needs no per-type code.
|
||||
*/
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface NodeShape extends Box {
|
||||
id: string;
|
||||
kind: string;
|
||||
rx: number;
|
||||
el: SVGGElement;
|
||||
}
|
||||
|
||||
export interface EdgeShape {
|
||||
key: string;
|
||||
from: string;
|
||||
to: string;
|
||||
path: SVGPathElement;
|
||||
/** Points along the route, every SAMPLE_STEP user units. */
|
||||
points: [number, number][];
|
||||
length: number;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export interface LabelShape extends Box {
|
||||
key: string;
|
||||
el: SVGGElement;
|
||||
}
|
||||
|
||||
export interface FrameShape extends Box {
|
||||
kind: string;
|
||||
rx: number;
|
||||
el: SVGRectElement;
|
||||
}
|
||||
|
||||
export interface DiagramModel {
|
||||
svg: SVGSVGElement;
|
||||
viewBox: Box;
|
||||
nodes: NodeShape[];
|
||||
edges: EdgeShape[];
|
||||
labels: LabelShape[];
|
||||
frames: FrameShape[];
|
||||
}
|
||||
|
||||
const SAMPLE_STEP = 3;
|
||||
|
||||
/** Role of an element for the 3D view; descendants share their ancestor's role. */
|
||||
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
|
||||
|
||||
/**
|
||||
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
|
||||
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
|
||||
* from the live SVG and never baked into the textures.
|
||||
*/
|
||||
function isOverlay(el: Element): boolean {
|
||||
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
|
||||
export const FLOW_SELECTOR = [
|
||||
'relationship-pulse',
|
||||
'intent-trace',
|
||||
'route-probe',
|
||||
'route-journey',
|
||||
'semantic-lens',
|
||||
'story',
|
||||
'story-carrier',
|
||||
'chapter-handoff',
|
||||
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
|
||||
|
||||
export function roleOf(el: Element): Role | undefined {
|
||||
if (isOverlay(el)) return 'overlay';
|
||||
if (el.hasAttribute('data-node-id')) return 'node';
|
||||
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
|
||||
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
|
||||
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
|
||||
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function num(el: Element, name: string): number {
|
||||
return Number(el.getAttribute(name) ?? 0) || 0;
|
||||
}
|
||||
|
||||
function rectBox(rect: SVGRectElement): Box & { rx: number } {
|
||||
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
|
||||
}
|
||||
|
||||
function nodeShape(el: SVGGElement): NodeShape | undefined {
|
||||
const id = el.getAttribute('data-node-id');
|
||||
if (!id) return undefined;
|
||||
const kind = el.getAttribute('data-node-kind') ?? 'backend';
|
||||
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
|
||||
const rect =
|
||||
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
|
||||
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
|
||||
el.querySelector<SVGRectElement>(':scope > rect');
|
||||
if (rect) return { id, kind, el, ...rectBox(rect) };
|
||||
const bbox = el.getBBox();
|
||||
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
|
||||
}
|
||||
|
||||
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
|
||||
const length = path.getTotalLength();
|
||||
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
|
||||
const points: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const p = path.getPointAtLength((length * i) / steps);
|
||||
points.push([p.x, p.y]);
|
||||
}
|
||||
return { points, length };
|
||||
}
|
||||
|
||||
export function readModel(svg: SVGSVGElement): DiagramModel {
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
|
||||
|
||||
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
|
||||
.map(nodeShape)
|
||||
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
|
||||
|
||||
const edges: EdgeShape[] = [];
|
||||
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
|
||||
const from = path.getAttribute('data-edge-from') ?? '';
|
||||
const to = path.getAttribute('data-edge-to') ?? '';
|
||||
const group = path.parentElement;
|
||||
const { points, length } = samplePath(path);
|
||||
if (length <= 0) return;
|
||||
edges.push({
|
||||
key: path.getAttribute('data-edge-key') ?? `e${index}`,
|
||||
from,
|
||||
to,
|
||||
path,
|
||||
points,
|
||||
length,
|
||||
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
|
||||
});
|
||||
});
|
||||
|
||||
const labels: LabelShape[] = [];
|
||||
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
|
||||
if (roleOf(el) !== 'label') return;
|
||||
const rect = el.querySelector<SVGRectElement>(':scope > rect');
|
||||
const box = rect ? rectBox(rect) : (() => {
|
||||
const b = el.getBBox();
|
||||
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
|
||||
})();
|
||||
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
|
||||
});
|
||||
|
||||
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
|
||||
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
|
||||
el,
|
||||
...rectBox(el),
|
||||
}));
|
||||
|
||||
return { svg, viewBox, nodes, edges, labels, frames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity of an element including its ancestors below the SVG root. The
|
||||
* root is left out: the 3D view hides the 2D SVG by fading the root.
|
||||
*/
|
||||
export function effectiveOpacity(el: Element, root: Element): number {
|
||||
let value = 1;
|
||||
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
|
||||
const style = getComputedStyle(cur);
|
||||
if (style.display === 'none') return 0;
|
||||
value *= Number(style.opacity || 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isDecisionFile, parseDecision } from '../model/decisions';
|
||||
import { isMainFile, isRegistryFile } from '../model/format';
|
||||
import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } from '../model/panel';
|
||||
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 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);
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
private async openInModel(relativePath: string, viewType: string): Promise<void> {
|
||||
if (!this.root) return;
|
||||
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
|
||||
try {
|
||||
await vscode.workspace.fs.stat(target);
|
||||
} catch {
|
||||
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
|
||||
return;
|
||||
}
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
|
||||
}
|
||||
|
||||
private async openRegistryLine(file: string, line?: number): Promise<void> {
|
||||
if (!this.root) return;
|
||||
const target = vscode.Uri.joinPath(this.root, ...file.split('/'));
|
||||
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
const at = Math.max(line ?? 0, 0);
|
||||
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), 300);
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
const seq = ++this.seq;
|
||||
let decision: DecisionPanel;
|
||||
try {
|
||||
this.root = await this.models.rootOf(this.uri);
|
||||
if (this.root) {
|
||||
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
|
||||
decision = decisionPanelFromIndex(await this.models.index(this.root), rel);
|
||||
} else {
|
||||
const name = path.basename(this.uri.path);
|
||||
decision = decisionPanelStandalone(name, parseDecision(await readText(this.uri), name));
|
||||
}
|
||||
} catch (error) {
|
||||
log().warn(`Decision view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
if (seq !== this.seq) return;
|
||||
const fingerprint = JSON.stringify(decision);
|
||||
if (fingerprint === this.last) return;
|
||||
this.last = fingerprint;
|
||||
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 {
|
||||
clearTimeout(this.timer);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
models.onDidChangeModel(({ root }) => {
|
||||
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!isDecisionFile(event.document.fileName) && !isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
|
||||
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** File shown in the focused decision view, if any. */
|
||||
get activeUri(): vscode.Uri | undefined {
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
/** The view of the focused page, if any. */
|
||||
get activeView(): { source: SourceSession } | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
// 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.context, this.models, panel, document.uri);
|
||||
this.views.add(view);
|
||||
const onViewState = () => {
|
||||
if (panel.active) this.active = view;
|
||||
else if (this.active === view) this.active = undefined;
|
||||
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
|
||||
};
|
||||
panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
panel.onDidDispose(() => {
|
||||
this.views.delete(view);
|
||||
if (this.active === view) this.active = undefined;
|
||||
view.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const view of this.views) view.dispose();
|
||||
this.views.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { renderDiagram } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
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';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
import { SourceSession } from './sourceSession';
|
||||
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
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;
|
||||
private lastHtml: string | undefined;
|
||||
private rendered = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||
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;
|
||||
/** main.archify: the model page has its Live slot and takes `live` messages. */
|
||||
private liveShown = 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,
|
||||
/** True when this is the custom editor of the file, not a side preview. */
|
||||
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.liveShown = false;
|
||||
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;
|
||||
}
|
||||
if (message.type === 'openDiagram') {
|
||||
void this.openLinked(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openRegistry') {
|
||||
void this.openLinked(message.file, REGISTRY_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openDecision') {
|
||||
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;
|
||||
}
|
||||
if (message.type === 'liveReady') {
|
||||
this.liveShown = true;
|
||||
void this.postLive();
|
||||
return;
|
||||
}
|
||||
if (message.type === 'liveAction') {
|
||||
if (this.root) void liveBridge.act(this.root, message.action);
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
if (isMainFile(uri.path)) {
|
||||
this.disposables.push(liveBridge.onDidChange((root) => {
|
||||
if (!root || (this.root && root.toString() === this.root.toString())) void this.postLive();
|
||||
}));
|
||||
}
|
||||
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
);
|
||||
const onDisk = () => {
|
||||
// Open documents are tracked through text change events instead.
|
||||
if (!vscode.workspace.textDocuments.some((doc) => doc.uri.toString() === uri.toString())) this.scheduleUpdate();
|
||||
};
|
||||
this.disposables.push(watcher, watcher.onDidChange(onDisk), watcher.onDidCreate(onDisk));
|
||||
}
|
||||
|
||||
this.showMessage('Rendering…', `Rendering ${path.basename(uri.path)} with Archify.`);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
/** The Live section of a shared model's page; nothing when the model is not shared. */
|
||||
private async postLive(): Promise<void> {
|
||||
if (!this.liveShown || !this.root || this.disposed) return;
|
||||
const live = await liveBridge.panel(this.root).catch(() => undefined);
|
||||
if (this.liveShown) this.post({ type: 'live', live });
|
||||
}
|
||||
|
||||
/** 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.liveShown = false;
|
||||
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, 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 {
|
||||
// Leaving Source for another page: drop what it held, or that page would
|
||||
// wait for a sourceClose that never comes and show up later instead.
|
||||
this.heldHtml = undefined;
|
||||
this.source?.leave();
|
||||
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.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. */
|
||||
private async openLinked(relativePath: string, viewType: string): Promise<void> {
|
||||
if (!this.root) return;
|
||||
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
|
||||
try {
|
||||
await vscode.workspace.fs.stat(target);
|
||||
} catch {
|
||||
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
|
||||
return;
|
||||
}
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
|
||||
}
|
||||
|
||||
private showModelPage(model: ModelPanel): void {
|
||||
const fingerprint = `model\u0000${JSON.stringify(model)}`;
|
||||
if (fingerprint === this.lastHtml) return;
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = false;
|
||||
this.graphShown = false;
|
||||
this.setHtml(modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state(), source: this.sourceOption('Model') }, model));
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), delay);
|
||||
}
|
||||
|
||||
async update(force = false): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
if (this.disposed) return;
|
||||
const seq = ++this.seq;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readText(this.uri);
|
||||
} catch (error) {
|
||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
// main.archify: the model page wraps the diagram, or stands alone.
|
||||
const main = isMainFile(this.uri.path) && this.uri.scheme === 'file';
|
||||
let model: ModelPanel | undefined;
|
||||
if (main) {
|
||||
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
|
||||
try {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await prepareDiagram(this.models, this.uri, text);
|
||||
} catch (error) {
|
||||
log().warn(`Could not resolve ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (seq !== this.seq) return;
|
||||
if (!main) this.root = prepared?.root;
|
||||
this.graphShown = false;
|
||||
if (!prepared) {
|
||||
if (model) {
|
||||
this.showModelPage(model);
|
||||
return;
|
||||
}
|
||||
this.showMessage(
|
||||
'Nothing to show yet',
|
||||
`Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid ${isArchifyFile(this.uri.path) ? 'YAML' : 'JSON'}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const source = prepared.source;
|
||||
|
||||
let result;
|
||||
try {
|
||||
const runtime = await runtimeFor(this.context, this.uri);
|
||||
result = await renderDiagram(runtime, source, evidenceMode(this.uri));
|
||||
} catch (error) {
|
||||
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
if (seq !== this.seq) return; // a newer render superseded this one
|
||||
|
||||
if (!result.ok || !result.html) {
|
||||
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
|
||||
if (this.rendered && this.lastHtml) {
|
||||
this.post({
|
||||
type: 'notice',
|
||||
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
|
||||
});
|
||||
} else {
|
||||
this.showMessage('Archify could not render this diagram', 'Fix the problems below (also listed in the Problems panel) and save.', result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const html = prepareDiagramHtml(result.html, {
|
||||
cspSource: this.panel.webview.cspSource,
|
||||
nonce: makeNonce(),
|
||||
theme: viewerTheme(),
|
||||
hash: this.hash,
|
||||
state: this.state(),
|
||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||
links: Object.fromEntries(prepared.links),
|
||||
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) {
|
||||
this.post({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
}
|
||||
this.lastHtml = fingerprint;
|
||||
this.rendered = true;
|
||||
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();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function firstLines(text: string | undefined, count = 6): string {
|
||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||
}
|
||||
|
||||
export class DiagramViewManager
|
||||
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
||||
{
|
||||
/** Side previews, one per source file. */
|
||||
private readonly previews = new Map<string, DiagramPreview>();
|
||||
/** Custom-editor tabs of .archify files (several per file are allowed). */
|
||||
private readonly editors = new Set<DiagramPreview>();
|
||||
private active: DiagramPreview | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// A registry or main.archify change re-renders every diagram of the model;
|
||||
// any other file change only refreshes the model page's list.
|
||||
models.onDidChangeModel(({ root, file }) => {
|
||||
const whole = isRegistryFile(file.path) || isMainFile(file.path);
|
||||
for (const preview of [...this.previews.values(), ...this.editors]) {
|
||||
if (preview.root?.toString() !== root.toString()) continue;
|
||||
if (whole || isMainFile(preview.uri.path)) preview.scheduleUpdate();
|
||||
}
|
||||
}),
|
||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
|
||||
for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
for (const preview of this.forUri(doc.uri)) void preview.update();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('archify')) this.refreshAll();
|
||||
}),
|
||||
vscode.window.onDidChangeActiveColorTheme(() => this.refreshAll()),
|
||||
);
|
||||
}
|
||||
|
||||
private liveUpdate(uri: vscode.Uri): boolean {
|
||||
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('preview.liveUpdate', true);
|
||||
}
|
||||
|
||||
get activePreview(): DiagramPreview | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private forUri(uri: vscode.Uri): DiagramPreview[] {
|
||||
const key = uri.toString();
|
||||
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, 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 {
|
||||
const existing = this.previews.get(uri.toString());
|
||||
if (existing) {
|
||||
existing.panel.reveal(viewColumn, preserveFocus);
|
||||
return;
|
||||
}
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
PREVIEW_VIEW_TYPE,
|
||||
`Preview ${path.basename(uri.path)}`,
|
||||
{ viewColumn, preserveFocus },
|
||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||
);
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri));
|
||||
}
|
||||
|
||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||
if (!state?.uri) {
|
||||
panel.dispose();
|
||||
return;
|
||||
}
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, vscode.Uri.parse(state.uri), state));
|
||||
}
|
||||
|
||||
private track(preview: DiagramPreview): void {
|
||||
const key = preview.uri.toString();
|
||||
if (preview.isEditor) this.editors.add(preview);
|
||||
else {
|
||||
this.previews.get(key)?.panel.dispose();
|
||||
this.previews.set(key, preview);
|
||||
}
|
||||
const onViewState = () => {
|
||||
if (preview.panel.active) this.active = preview;
|
||||
else if (this.active === preview) this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
||||
if (preview.panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(preview.root));
|
||||
};
|
||||
preview.panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
preview.onDidDispose(() => {
|
||||
this.editors.delete(preview);
|
||||
if (this.previews.get(key) === preview) this.previews.delete(key);
|
||||
if (this.active === preview) {
|
||||
this.active = undefined;
|
||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshAll(force = false): void {
|
||||
for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const preview of this.previews.values()) preview.panel.dispose();
|
||||
this.previews.clear();
|
||||
this.editors.clear();
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyHtml } from './detect';
|
||||
import { viewerTheme } from './runtime';
|
||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
||||
import { isArchifyHtml } from '../sources/detect';
|
||||
import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
|
||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
|
||||
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
||||
|
||||
@@ -18,7 +19,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
||||
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly extensionUri: vscode.Uri) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
@@ -47,7 +48,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
||||
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
const uri = document.uri;
|
||||
let hash: string | undefined;
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.extensionUri) };
|
||||
|
||||
const reload = async () => {
|
||||
let text: string;
|
||||
@@ -70,6 +71,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
||||
notice: isArchifyHtml(text)
|
||||
? undefined
|
||||
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
||||
viewer3d: isArchifyHtml(text) ? viewer3dOption(panel.webview, this.extensionUri) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isMainFile, isRegistryFile, parseRegistry } from '../model/format';
|
||||
import { RegistryPanel, registryPanelFromIndex, registryPanelStandalone } from '../model/panel';
|
||||
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 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);
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
private async openInModel(relativePath: string, viewType: string): Promise<void> {
|
||||
if (!this.root) return;
|
||||
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
|
||||
try {
|
||||
await vscode.workspace.fs.stat(target);
|
||||
} catch {
|
||||
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
|
||||
return;
|
||||
}
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
|
||||
}
|
||||
|
||||
private async openRegistryLine(file: string, line?: number): Promise<void> {
|
||||
const target = this.root ? vscode.Uri.joinPath(this.root, ...file.split('/')) : this.uri;
|
||||
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
const at = Math.max(line ?? 0, 0);
|
||||
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), 300);
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
const seq = ++this.seq;
|
||||
let registry: RegistryPanel;
|
||||
try {
|
||||
this.root = await this.models.rootOf(this.uri);
|
||||
if (this.root) {
|
||||
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
|
||||
registry = registryPanelFromIndex(await this.models.index(this.root), rel);
|
||||
} else {
|
||||
const name = path.basename(this.uri.path);
|
||||
registry = registryPanelStandalone(name, parseRegistry(await readText(this.uri), name));
|
||||
}
|
||||
} catch (error) {
|
||||
log().warn(`Registry view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
if (seq !== this.seq) return;
|
||||
const fingerprint = JSON.stringify(registry);
|
||||
if (fingerprint === this.last) return;
|
||||
this.last = fingerprint;
|
||||
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 {
|
||||
clearTimeout(this.timer);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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 context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(REGISTRY_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
// Any change in the model (a diagram starts or stops using an element, a registry edit) refreshes the page.
|
||||
models.onDidChangeModel(({ root }) => {
|
||||
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
|
||||
// Outside a model the model service stays quiet; refresh the page of the edited file itself.
|
||||
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** File shown in the focused registry view, if any. */
|
||||
get activeUri(): vscode.Uri | undefined {
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
/** The view of the focused page, if any. */
|
||||
get activeView(): { source: SourceSession } | undefined {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
// 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.context, this.models, panel, document.uri);
|
||||
this.views.add(view);
|
||||
const onViewState = () => {
|
||||
if (panel.active) this.active = view;
|
||||
else if (this.active === view) this.active = undefined;
|
||||
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
|
||||
};
|
||||
panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
panel.onDidDispose(() => {
|
||||
this.views.delete(view);
|
||||
if (this.active === view) this.active = undefined;
|
||||
view.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const view of this.views) view.dispose();
|
||||
this.views.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EditorSchemas } from '../language/editorSchemas';
|
||||
import { readSchemas } from '../language/schemaCheck';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
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[] }
|
||||
| { type: 'sourceSelection'; anchor: number; head: number };
|
||||
|
||||
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' ||
|
||||
message.type === 'sourceSelection'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}),
|
||||
// Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise.
|
||||
liveBridge.onDidChangeCursors(() => this.postCursors()),
|
||||
];
|
||||
}
|
||||
|
||||
/** Asks the page to show its Source view (Archify | Show Source). */
|
||||
reveal(): void {
|
||||
this.post({ type: 'setView', view: 'source' });
|
||||
}
|
||||
|
||||
/** The page is being replaced by another one (the Graph page): Source is no longer shown. */
|
||||
leave(): void {
|
||||
this.setShown(false);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
liveBridge.setCursor(this.uri, undefined);
|
||||
break;
|
||||
case 'sourceEdit':
|
||||
this.edit(message);
|
||||
break;
|
||||
case 'sourceSelection':
|
||||
liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head });
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private setShown(shown: boolean): void {
|
||||
if (this.shown === shown) return;
|
||||
this.shown = shown;
|
||||
this.onShown(shown);
|
||||
}
|
||||
|
||||
private lastCursors = '[]';
|
||||
|
||||
private postCursors(): void {
|
||||
if (!this.shown) return;
|
||||
const cursors = liveBridge.cursors(this.uri);
|
||||
const key = JSON.stringify(cursors);
|
||||
if (key === this.lastCursors) return;
|
||||
this.lastCursors = key;
|
||||
this.post({ type: 'sourceCursors', cursors });
|
||||
}
|
||||
|
||||
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 });
|
||||
this.lastCursors = '';
|
||||
this.postCursors();
|
||||
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 {
|
||||
if (this.shown) liveBridge.setCursor(this.uri, undefined);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Custom editor view types, in one place so views can refer to each other without importing each other. */
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
/** Default editor for .archify-registry files: the elements as cards. */
|
||||
export const REGISTRY_VIEW_TYPE = 'archify.registry';
|
||||
/** Default editor for .archify-decision files: the decision as a page. */
|
||||
export const DECISION_VIEW_TYPE = 'archify.decision';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { log, repoRootFor } from './runtime';
|
||||
import { repoRootFor } from '../archify/runtime';
|
||||
import { log } from '../util/log';
|
||||
import type { LiveAction } from '../sync/liveBridge';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
@@ -8,6 +10,15 @@ export type WebviewMessage =
|
||||
| { type: 'openExternal'; href: string }
|
||||
| { type: 'openRelative'; href: string }
|
||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||
| { type: 'openDiagram'; path: string }
|
||||
| { type: 'openRegistry'; file: string; line?: number }
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'liveReady' }
|
||||
| { type: 'liveAction'; action: LiveAction }
|
||||
| { type: 'syncMenu' }
|
||||
| { type: 'syncReady' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -57,6 +68,28 @@ async function openSource(
|
||||
vscode.window.showWarningMessage(`Archify: source file "${message.path}" was not found in the workspace.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file's model is shared: an .archify-sync file next to its
|
||||
* main.archify. Read from disk, so the sync bundle stays unloaded.
|
||||
*/
|
||||
async function isShared(file: vscode.Uri): Promise<boolean> {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(file);
|
||||
let dir = vscode.Uri.joinPath(file, '..');
|
||||
for (let depth = 0; depth < 16; depth++) {
|
||||
if (await exists(vscode.Uri.joinPath(dir, '.archify-sync'))) return true;
|
||||
if (await exists(vscode.Uri.joinPath(dir, 'main.archify'))) return false;
|
||||
const parent = vscode.Uri.joinPath(dir, '..');
|
||||
if (!folder || dir.path === folder.uri.path || parent.path === dir.path) return false;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function postSyncState(file: vscode.Uri, panel: vscode.WebviewPanel): Promise<void> {
|
||||
const shared = file.scheme === 'file' && (await isShared(file));
|
||||
await Promise.resolve(panel.webview.postMessage({ type: 'syncState', shared })).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function handleWebviewMessage(
|
||||
message: WebviewMessage,
|
||||
context: vscode.Uri,
|
||||
@@ -90,7 +123,21 @@ export async function handleWebviewMessage(
|
||||
case 'error':
|
||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||
break;
|
||||
case 'syncReady':
|
||||
await postSyncState(context, panel);
|
||||
break;
|
||||
case 'syncMenu':
|
||||
await vscode.commands.executeCommand('archify.sync.menu');
|
||||
// Share or Stop may have changed it.
|
||||
await postSyncState(context, panel);
|
||||
break;
|
||||
case 'hash':
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
case 'liveReady':
|
||||
case 'liveAction':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
+1289
File diff suppressed because it is too large.
Load diff
@@ -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,258 @@
|
||||
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, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
WidgetType,
|
||||
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;
|
||||
}
|
||||
|
||||
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
|
||||
export interface RemoteCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
|
||||
|
||||
class RemoteCaret extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly color: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: RemoteCaret): boolean {
|
||||
return other.name === this.name && other.color === this.color;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'cm-remote-caret';
|
||||
caret.style.borderLeftColor = this.color;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-remote-label';
|
||||
label.textContent = this.name;
|
||||
label.style.backgroundColor = this.color;
|
||||
caret.appendChild(label);
|
||||
return caret;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
const clamp = (n: number) => Math.max(0, Math.min(n, length));
|
||||
for (const c of cursors) {
|
||||
const anchor = clamp(c.anchor);
|
||||
const head = clamp(c.head);
|
||||
if (anchor !== head) {
|
||||
ranges.push(
|
||||
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
|
||||
);
|
||||
}
|
||||
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
/** Teammates' carets and selections; they move with the text as anyone types. */
|
||||
const remoteCursors = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/** 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-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
|
||||
'.cm-remote-label': {
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '-2px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '3px 3px 3px 0',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: '5',
|
||||
opacity: '0.9',
|
||||
},
|
||||
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
|
||||
'.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,
|
||||
/** The main selection moved (or the editor got focus); offsets into the text. */
|
||||
onSelection: (anchor: number, head: number) => void = () => undefined,
|
||||
) {
|
||||
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()),
|
||||
remoteCursors,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
|
||||
const main = update.state.selection.main;
|
||||
onSelection(main.anchor, main.head);
|
||||
}
|
||||
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)] });
|
||||
}
|
||||
|
||||
/** Teammates' cursors, from the host; they replace the ones shown. */
|
||||
setRemoteCursors(cursors: RemoteCursor[]): void {
|
||||
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
|
||||
}
|
||||
|
||||
/** 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,220 @@
|
||||
import type { ArchifyHost } from '../host';
|
||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||
import { RemoteCursor, 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 }
|
||||
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
|
||||
|
||||
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(#archify-sync-button):not(script):not(style):not(.archify-modal-backdrop),
|
||||
html.archify-source-on #btn-3d,
|
||||
html.archify-source-on .archify-vscode-notice { display: none !important; }
|
||||
html:not(.archify-source-on) #archify-source { 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 cursors: RemoteCursor[] = [];
|
||||
private shown = false;
|
||||
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private selection: { anchor: number; head: number } | undefined;
|
||||
|
||||
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),
|
||||
(anchor, head) => this.onSelection(anchor, head),
|
||||
);
|
||||
this.editor.setDiagnostics(this.diagnostics);
|
||||
this.editor.setRemoteCursors(this.cursors);
|
||||
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;
|
||||
case 'sourceCursors':
|
||||
this.cursors = message.cursors;
|
||||
this.editor?.setRemoteCursors(message.cursors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
|
||||
private onSelection(anchor: number, head: number): void {
|
||||
this.selection = { anchor, head };
|
||||
if (this.selectionTimer) return;
|
||||
this.selectionTimer = setTimeout(() => {
|
||||
this.selectionTimer = undefined;
|
||||
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
|
||||
}, 80);
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/** Folder of the bundled webview scripts; webviews may load files from it. */
|
||||
export function viewerRoots(extensionUri: vscode.Uri): vscode.Uri[] {
|
||||
return [vscode.Uri.joinPath(extensionUri, 'dist')];
|
||||
}
|
||||
|
||||
/** Page option that adds the 3D viewer to an Archify page. */
|
||||
export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri): { scriptUri: string; defaultView: '2d' | '3d' } {
|
||||
const defaultView = vscode.workspace.getConfiguration('archify').get<string>('defaultView', '2d') === '3d' ? '3d' : '2d';
|
||||
return {
|
||||
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'viewer3d.js')).toString(),
|
||||
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();
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
export type ViewerTheme = 'dark' | 'light' | undefined;
|
||||
|
||||
export interface PageOptions {
|
||||
/** webview.cspSource */
|
||||
cspSource: string;
|
||||
nonce: string;
|
||||
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
||||
theme: ViewerTheme;
|
||||
/** Location hash to restore (Archify keeps focus/route state there). */
|
||||
hash?: string;
|
||||
/** Opaque state handed to vscode.setState for panel restore. */
|
||||
state?: unknown;
|
||||
/** Banner shown on top of the diagram. */
|
||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||
}
|
||||
|
||||
export function makeNonce(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let nonce = '';
|
||||
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return nonce;
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** JSON that is safe to embed inside an inline <script>. */
|
||||
function scriptJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? null)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
||||
return [
|
||||
"default-src 'none'",
|
||||
`img-src ${cspSource} https: data: blob:`,
|
||||
`media-src ${cspSource} data: blob:`,
|
||||
`font-src ${cspSource} data:`,
|
||||
`style-src ${cspSource} 'unsafe-inline'`,
|
||||
`script-src 'nonce-${nonce}'`,
|
||||
'connect-src data: blob:',
|
||||
'worker-src blob:',
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs before any page script: pins the theme, restores the hash, and
|
||||
* routes links, window.open and export downloads to the extension, since a
|
||||
* webview cannot navigate or download on its own.
|
||||
*/
|
||||
function bridgeScript(options: PageOptions): string {
|
||||
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
var vscode = acquireVsCodeApi();
|
||||
if (config.state) vscode.setState(config.state);
|
||||
if (config.theme) {
|
||||
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
||||
}
|
||||
if (config.hash && !location.hash) {
|
||||
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
||||
}
|
||||
window.addEventListener('hashchange', function () {
|
||||
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
|
||||
vscode.postMessage({ type: 'hash', hash: location.hash });
|
||||
});
|
||||
|
||||
function post(message) { vscode.postMessage(message); }
|
||||
|
||||
function saveBlobUrl(url, filename) {
|
||||
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var data = String(reader.result);
|
||||
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}).catch(function (error) {
|
||||
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
||||
if (!anchor) return;
|
||||
var href = anchor.getAttribute('href') || '';
|
||||
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
||||
event.preventDefault();
|
||||
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
||||
return;
|
||||
}
|
||||
if (href.charAt(0) === '#') return;
|
||||
event.preventDefault();
|
||||
if (anchor.classList.contains('semantic-passport-source')) {
|
||||
var pathNode = anchor.querySelector('small');
|
||||
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
||||
post({
|
||||
type: 'openSource',
|
||||
path: pathNode ? pathNode.textContent : '',
|
||||
line: match ? Number(match[1]) : undefined,
|
||||
endLine: match && match[2] ? Number(match[2]) : undefined,
|
||||
href: anchor.href
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
||||
else post({ type: 'openRelative', href: href });
|
||||
}, true);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
window.open = function (url) {
|
||||
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
||||
post({ type: 'openExternal', href: url });
|
||||
return null;
|
||||
}
|
||||
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
||||
};
|
||||
|
||||
function showNotice(notice) {
|
||||
var existing = document.getElementById('archify-vscode-notice');
|
||||
if (existing) existing.remove();
|
||||
if (!notice || !notice.text) return;
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'archify-vscode-notice';
|
||||
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
||||
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
||||
var text = document.createElement('span');
|
||||
text.textContent = notice.text;
|
||||
var close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '\\u00d7';
|
||||
close.title = 'Dismiss';
|
||||
close.addEventListener('click', function () { bar.remove(); });
|
||||
bar.appendChild(text);
|
||||
bar.appendChild(close);
|
||||
(document.body || document.documentElement).appendChild(bar);
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
});
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
} else {
|
||||
showNotice(config.notice);
|
||||
}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
const NOTICE_STYLE = `
|
||||
.archify-vscode-notice {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
||||
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
||||
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
||||
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
||||
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
||||
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
||||
.archify-vscode-notice button {
|
||||
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
||||
}`;
|
||||
|
||||
/** Prepares Archify HTML for a VS Code webview. */
|
||||
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}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// Drop any CSP the file brings along; ours is the one that applies.
|
||||
.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '')
|
||||
// Allow the page's own inline scripts to run under the nonce policy.
|
||||
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(page);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
||||
} else {
|
||||
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
title: string,
|
||||
body: string,
|
||||
detail?: string,
|
||||
): string {
|
||||
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>
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
||||
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;
|
||||
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
||||
border-left: 3px solid var(--vscode-editorError-foreground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# An architecture decision of this model. The id is unique in the model;
|
||||
# elements are registry ids, diagrams are paths from the model root.
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: __ID__
|
||||
title: __TITLE__
|
||||
status: proposed # proposed | accepted | superseded | rejected
|
||||
date: __DATE__
|
||||
elements: []
|
||||
diagrams: []
|
||||
supersedes: []
|
||||
context: >
|
||||
What was the situation, and what forced a decision.
|
||||
decision: >
|
||||
What was decided, in one or two sentences.
|
||||
consequences: >
|
||||
What becomes easier, what becomes harder, what must change.
|
||||
@@ -0,0 +1,27 @@
|
||||
# main.archify: the entry point of a model, a folder of diagrams that belong
|
||||
# together. The folder that holds this file is the model's root. Diagrams
|
||||
# below it may refer to shared elements from *.archify-registry files.
|
||||
schema_version: 1
|
||||
model:
|
||||
id: my-model
|
||||
name: My Model
|
||||
description: >
|
||||
What this model covers, in a sentence or two.
|
||||
# Optional: a diagram of your own for the model page (an overview).
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Overview
|
||||
output: main.html
|
||||
components:
|
||||
- id: users
|
||||
type: external
|
||||
label: Users
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- id: system
|
||||
type: cloud
|
||||
label: The system
|
||||
pos: [250, 160]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: users, to: system, label: use }
|
||||
@@ -0,0 +1,26 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: System context
|
||||
output: context.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: support
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: shop
|
||||
pos: [280, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [520, 60]
|
||||
size: [150, 60]
|
||||
- ref: email
|
||||
pos: [520, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||
- { from: support, to: shop, label: manages orders }
|
||||
- { from: shop, to: psp, label: charges, variant: security }
|
||||
- { from: shop, to: email, label: sends mail }
|
||||
@@ -0,0 +1,41 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Containers
|
||||
output: containers.html
|
||||
decisions: [postgres-as-source-of-truth]
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: web
|
||||
pos: [250, 160]
|
||||
size: [130, 60]
|
||||
- ref: api
|
||||
pos: [460, 160]
|
||||
size: [130, 60]
|
||||
- ref: cache
|
||||
pos: [670, 60]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [670, 260]
|
||||
size: [130, 60]
|
||||
- ref: queue
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [queue-between-api-and-worker]
|
||||
- ref: worker
|
||||
pos: [670, 460]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Online Shop
|
||||
wraps: [web, api, cache, db, queue, worker]
|
||||
connections:
|
||||
- { from: customer, to: web, label: HTTPS, variant: emphasis }
|
||||
- { from: web, to: api, label: JSON }
|
||||
- { from: api, to: cache, label: read }
|
||||
- { from: api, to: db, label: SQL }
|
||||
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
|
||||
- { from: queue, to: worker, label: poll }
|
||||
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user