Models: main.archify, registries, refs, links and the model page; 0.7.0

A folder with main.archify at its top is a model, a set of diagrams
that belong together. Opening main.archify shows the model page: the
model's name and description, its own overview diagram when the file
has one, the diagrams grouped by folder (click to open), and the
registry with where each element is used. The page is built by
traversing the folder tree and refreshes when files change.

*.archify-registry files define shared elements once; diagrams draw
them with `ref: <id>` plus position and may override label, sublabel
and tag. References are resolved before Archify runs, so Archify is
unchanged. 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. `diagram:` links on
elements and components open the linked diagram on double-click.

New command Archify | Open Model, a Model template for New Diagram…,
editor schemas for the new keys and for registries, docs/model.md and
examples/online-shop. Single diagrams behave exactly as before.
This commit is contained in:
2026-10-19 11:37:20 +03:00
parent 701ffc7607
commit 7a2b661afe
50 changed files with 2154 additions and 47 deletions
+79 -9
View File
@@ -2,12 +2,16 @@ import path from 'node:path';
import * as vscode from 'vscode';
import { renderDiagram } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime';
import { isArchifyFile, readDiagramSource } from '../sources/detect';
import { isMainFile, isRegistryFile } from '../model/format';
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 { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
export const PREVIEW_VIEW_TYPE = 'archify.preview';
@@ -28,9 +32,12 @@ class DiagramPreview implements vscode.Disposable {
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;
constructor(
private readonly context: vscode.ExtensionContext,
private readonly models: ModelService,
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
state?: PreviewState,
@@ -51,6 +58,10 @@ class DiagramPreview implements vscode.Disposable {
this.hash = message.hash;
return;
}
if (message.type === 'openDiagram') {
void this.openLinked(message.path);
return;
}
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
@@ -84,6 +95,27 @@ class DiagramPreview implements vscode.Disposable {
);
}
/** Opens a diagram of the model, given its path relative to the model root. */
private async openLinked(relativePath: 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, DIAGRAM_EDITOR_VIEW_TYPE, 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.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model);
}
scheduleUpdate(): void {
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
clearTimeout(this.timer);
@@ -100,14 +132,37 @@ class DiagramPreview implements vscode.Disposable {
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) {
// 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 {
model = panelFromIndex(await this.models.index(this.root));
} 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;
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 {
@@ -139,9 +194,11 @@ class DiagramPreview implements vscode.Disposable {
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),
model,
});
// Nonces differ per render, so compare the renderer output instead.
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links]])}\u0000${result.html}`;
if (!force && this.rendered && fingerprint === this.lastHtml) {
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
return;
@@ -173,8 +230,20 @@ export class DiagramViewManager
private active: DiagramPreview | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(private readonly context: vscode.ExtensionContext) {
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 },
@@ -210,7 +279,7 @@ export class DiagramViewManager
/** 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, panel, document.uri, undefined, true));
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true));
}
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
@@ -225,7 +294,7 @@ export class DiagramViewManager
{ viewColumn, preserveFocus },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
);
this.track(new DiagramPreview(this.context, panel, uri));
this.track(new DiagramPreview(this.context, this.models, panel, uri));
}
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
@@ -233,7 +302,7 @@ export class DiagramViewManager
panel.dispose();
return;
}
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state));
}
private track(preview: DiagramPreview): void {
@@ -247,6 +316,7 @@ export class DiagramViewManager
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();