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:
1 parent
33ebdd6328
commit
ebf320a023
49 files changed
+1637
-55
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user