Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js scene built from the live Archify SVG. Nodes become 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. Clicks and hover go to the SVG nodes, so focus, the Semantic Passport and the intent trace work as in 2D; Archify's own animations (trace, node pulse, passport/path/lens flows) are mirrored live. The menu also offers flat or stacked levels, reset view and a top view; Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the mouse wheel zoom, and the zoom buttons work beyond the 2D limits. The scene draws only when something moves. New setting archify.defaultView. Source: src/ is split by job (commands, views, webview, archify, sources, language, util, viewer3d); behaviour unchanged.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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 { 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 { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
|
||||
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: 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;
|
||||
}
|
||||
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 ${isArchifyFile(this.uri.path) ? 'YAML' : '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,
|
||||
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||
});
|
||||
// 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 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) {
|
||||
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: viewerRoots(this.context.extensionUri) };
|
||||
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: viewerRoots(this.context.extensionUri) },
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user