Files
archify-vscode-ext/src/views/diagramView.ts
T
root-at-skic c8645b228b Model graph: the whole model as one graph with queries; 0.9.0
- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram)
- union graph of every element and connection, registry and inline, built from the model index
- query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared
- side panel per element and connection with diagrams, registry line and decisions one click away
- legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js
- command Archify | Open Model Graph; integration check; unit tests for graph and queries
2026-10-21 15:47:00 +03:00

452 lines
18 KiB
TypeScript

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, viewer3dOption, viewerRoots } from '../webview/viewer3d';
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;
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)}`;
}
this.disposables.push(
panel.onDidDispose(() => this.dispose()),
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
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;
}
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();
}
/** Posts to the webview; a panel closed mid-update is not an error. */
private post(message: unknown): void {
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
}
private state(): PreviewState {
return { uri: this.uri.toString(), hash: this.hash, graph: this.graph };
}
private graphModes(): Record<string, boolean> {
return this.context.workspaceState.get<Record<string, boolean>>(GRAPH_MODE_KEY, {});
}
/** Switches a main.archify editor between its model page and the Graph page. */
setGraph(on: boolean, query?: string): void {
this.graph = on;
this.graphQuery = query;
const modes = this.graphModes();
if (on) modes[this.uri.toString()] = true;
else delete modes[this.uri.toString()];
void this.context.workspaceState.update(GRAPH_MODE_KEY, modes);
this.lastHtml = undefined;
this.rendered = false;
if (!on) this.graphShown = false;
else if (this.graphShown && query !== undefined) this.post({ type: 'query', query });
void this.update(true);
}
/** The Graph page of the model, or an update of it when it is already showing. */
private showGraphPage(index: Awaited<ReturnType<ModelService['index']>>): void {
const graph = buildGraph(index);
const fingerprint = `graph\u0000${JSON.stringify(graph)}`;
if (fingerprint === this.lastHtml && this.graphShown) return;
this.lastHtml = fingerprint;
this.rendered = false;
if (this.graphShown) {
this.post({ type: 'graph', graph });
return;
}
this.graphShown = true;
this.panel.webview.html = graphPage(
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
{ graph, query: this.graphQuery, scriptUri: graphScriptUri(this.panel.webview, this.context.extensionUri) },
);
this.graphQuery = undefined;
}
private showMessage(title: string, body: string, detail?: string): void {
this.lastHtml = undefined;
this.panel.webview.html = messagePage(
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
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.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);
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;
}
// 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,
});
// 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.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 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();
}
}