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
This commit is contained in:
1 parent
265f8c57f6
commit
c8645b228b
20 files changed
+1551
-30
No files matched your search
+108
-10
@@ -2,7 +2,8 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { renderDiagram } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
import { isMainFile, isRegistryFile } from '../model/format';
|
||||
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';
|
||||
@@ -11,8 +12,8 @@ import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
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';
|
||||
|
||||
@@ -21,8 +22,18 @@ 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;
|
||||
@@ -34,10 +45,16 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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,
|
||||
@@ -45,6 +62,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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');
|
||||
@@ -70,6 +88,15 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
@@ -89,8 +116,51 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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 };
|
||||
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 {
|
||||
@@ -121,6 +191,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -146,7 +217,13 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (main) {
|
||||
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
|
||||
try {
|
||||
model = panelFromIndex(await this.models.index(this.root));
|
||||
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)}`);
|
||||
}
|
||||
@@ -159,6 +236,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
}
|
||||
if (seq !== this.seq) return;
|
||||
if (!main) this.root = prepared?.root;
|
||||
this.graphShown = false;
|
||||
if (!prepared) {
|
||||
if (model) {
|
||||
this.showModelPage(model);
|
||||
@@ -184,7 +262,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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({
|
||||
this.post({
|
||||
type: 'notice',
|
||||
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
|
||||
});
|
||||
@@ -206,11 +284,12 @@ class DiagramPreview implements vscode.Disposable {
|
||||
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) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
this.post({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
}
|
||||
this.lastHtml = fingerprint;
|
||||
@@ -286,10 +365,29 @@ export class DiagramViewManager
|
||||
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, panel, document.uri, undefined, true));
|
||||
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 {
|
||||
@@ -304,7 +402,7 @@ export class DiagramViewManager
|
||||
{ viewColumn, preserveFocus },
|
||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||
);
|
||||
this.track(new DiagramPreview(this.context, this.models, panel, uri));
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri));
|
||||
}
|
||||
|
||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||
@@ -312,7 +410,7 @@ export class DiagramViewManager
|
||||
panel.dispose();
|
||||
return;
|
||||
}
|
||||
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state));
|
||||
this.track(new DiagramPreview(this.context, this.models, this.host, panel, vscode.Uri.parse(state.uri), state));
|
||||
}
|
||||
|
||||
private track(preview: DiagramPreview): void {
|
||||
|
||||
Reference in new issue
Block a user