On a main.archify page in Source, Graph built the Graph page but the host held it back until Source closed, so nothing happened, and Model then showed the held Graph page. Switching between the model and its graph now leaves Source first, and the page forgets Source when Graph replaces it, so Model on the Graph page comes back to the model.
496 lines
20 KiB
TypeScript
496 lines
20 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, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
|
import { SourceSession } from './sourceSession';
|
|
|
|
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
|
|
|
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;
|
|
|
|
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.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;
|
|
}
|
|
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);
|
|
}
|
|
|
|
/** Replaces the page, unless Source is open in it: then the page waits until it closes. */
|
|
private setHtml(html: string): void {
|
|
if (this.disposed) return;
|
|
if (this.source?.shown) {
|
|
this.heldHtml = html;
|
|
return;
|
|
}
|
|
this.heldHtml = undefined;
|
|
this.panel.webview.html = html;
|
|
}
|
|
|
|
/** Page option for the Source view, on .archify files. */
|
|
private sourceOption(left: 'Model' | 'Diagram'): { scriptUri: string; left: 'Model' | 'Diagram' } | undefined {
|
|
return this.source ? { scriptUri: sourceScriptUri(this.panel.webview, this.context.extensionUri), left } : undefined;
|
|
}
|
|
|
|
private state(): PreviewState {
|
|
return { uri: this.uri.toString(), hash: this.hash, 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();
|
|
}
|
|
}
|