Architecture decisions: files, diagram references, views and panels

- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
This commit is contained in:
root-at-skic committed 2026-10-19 23:13:05 +03:00
1 parent 33ebdd6328
commit ebf320a023
49 files changed
+1637 -55

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isDecisionFile, parseDecision } from '../model/decisions';
import { isMainFile, isRegistryFile } from '../model/format';
import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } from '../model/panel';
import { ModelService } from '../model/service';
import { readText } from '../util/files';
import { log } from '../util/log';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { decisionPage, makeNonce } from '../webview/page';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
export { DECISION_VIEW_TYPE };
class DecisionDocument implements vscode.CustomDocument {
constructor(readonly uri: vscode.Uri) {}
dispose(): void {}
}
class DecisionView implements vscode.Disposable {
private seq = 0;
private timer: NodeJS.Timeout | undefined;
private last: string | undefined;
root: vscode.Uri | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(
private readonly models: ModelService,
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
) {
this.disposables.push(
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
void this.update();
}
private async openInModel(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 async openRegistryLine(file: string, line?: number): Promise<void> {
if (!this.root) return;
const target = vscode.Uri.joinPath(this.root, ...file.split('/'));
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
const at = Math.max(line ?? 0, 0);
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
}
scheduleUpdate(): void {
clearTimeout(this.timer);
this.timer = setTimeout(() => void this.update(), 300);
}
async update(): Promise<void> {
clearTimeout(this.timer);
const seq = ++this.seq;
let decision: DecisionPanel;
try {
this.root = await this.models.rootOf(this.uri);
if (this.root) {
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
decision = decisionPanelFromIndex(await this.models.index(this.root), rel);
} else {
const name = path.basename(this.uri.path);
decision = decisionPanelStandalone(name, parseDecision(await readText(this.uri), name));
}
} catch (error) {
log().warn(`Decision view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (seq !== this.seq) return;
const fingerprint = JSON.stringify(decision);
if (fingerprint === this.last) return;
this.last = fingerprint;
this.panel.webview.html = decisionPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, decision);
}
dispose(): void {
clearTimeout(this.timer);
for (const d of this.disposables.splice(0)) d.dispose();
}
}
/** Read-only custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */
export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider<DecisionDocument>, vscode.Disposable {
private readonly views = new Set<DecisionView>();
private active: DecisionView | undefined;
private readonly disposables: vscode.Disposable[];
constructor(private readonly models: ModelService) {
this.disposables = [
vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
models.onDidChangeModel(({ root }) => {
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
}),
vscode.workspace.onDidChangeTextDocument((event) => {
if (!isDecisionFile(event.document.fileName) && !isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
}),
];
}
/** File shown in the focused decision view, if any. */
get activeUri(): vscode.Uri | undefined {
return this.active?.uri;
}
openCustomDocument(uri: vscode.Uri): DecisionDocument {
return new DecisionDocument(uri);
}
async resolveCustomEditor(document: DecisionDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true };
const view = new DecisionView(this.models, panel, document.uri);
this.views.add(view);
const onViewState = () => {
if (panel.active) this.active = view;
else if (this.active === view) this.active = undefined;
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
};
panel.onDidChangeViewState(onViewState);
onViewState();
panel.onDidDispose(() => {
this.views.delete(view);
if (this.active === view) this.active = undefined;
view.dispose();
});
}
dispose(): void {
for (const view of this.views) view.dispose();
this.views.clear();
for (const d of this.disposables.splice(0)) d.dispose();
}
}
+7 -2
View File
@@ -14,7 +14,7 @@ import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
import { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
@@ -66,6 +66,10 @@ class DiagramPreview implements vscode.Disposable {
void this.openLinked(message.file, REGISTRY_VIEW_TYPE);
return;
}
if (message.type === 'openDecision') {
void this.openLinked(message.file, DECISION_VIEW_TYPE);
return;
}
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
@@ -200,10 +204,11 @@ class DiagramPreview implements vscode.Disposable {
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
links: Object.fromEntries(prepared.links),
descriptions: Object.fromEntries(prepared.descriptions),
decisions: prepared.decisions,
model,
});
// 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]])}\u0000${result.html}`;
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 });
return;
+2 -1
View File
@@ -7,7 +7,7 @@ import { readText } from '../util/files';
import { log } from '../util/log';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, registryPage } from '../webview/page';
import { DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
export { REGISTRY_VIEW_TYPE };
@@ -32,6 +32,7 @@ class RegistryView implements vscode.Disposable {
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
+2
View File
@@ -4,3 +4,5 @@ export const PREVIEW_VIEW_TYPE = 'archify.preview';
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
/** Default editor for .archify-registry files: the elements as cards. */
export const REGISTRY_VIEW_TYPE = 'archify.registry';
/** Default editor for .archify-decision files: the decision as a page. */
export const DECISION_VIEW_TYPE = 'archify.decision';