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; } 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(); 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 { 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 { return this.context.workspaceState.get>(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>): 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 { 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('preview.debounceMs', 400); clearTimeout(this.timer); this.timer = setTimeout(() => void this.update(), delay); } async update(force = false): Promise { 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, vscode.CustomTextEditorProvider { /** Side previews, one per source file. */ private readonly previews = new Map(); /** Custom-editor tabs of .archify files (several per file are allowed). */ private readonly editors = new Set(); 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('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 { 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 { 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 { 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(); } }