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 { sourceScriptUri } from '../webview/viewer3d'; import { SourceSession } from './sourceSession'; 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 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[] = []; /** The Source view; while it shows, a new page waits in `heldHtml`. */ readonly source: SourceSession; private heldHtml: string | undefined; constructor( private readonly context: vscode.ExtensionContext, private readonly models: ModelService, readonly panel: vscode.WebviewPanel, readonly uri: vscode.Uri, ) { this.source = new SourceSession(context, panel, uri, 'decision', (shown) => { if (!shown && this.heldHtml !== undefined) { const html = this.heldHtml; this.heldHtml = undefined; this.panel.webview.html = html; } }); this.disposables.push( this.source, panel.webview.onDidReceiveMessage((message: WebviewMessage) => { if (this.source.handle(message)) return; 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 { 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 { 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 { 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; const html = decisionPage( { cspSource: this.panel.webview.cspSource, nonce: makeNonce(), source: { scriptUri: sourceScriptUri(this.panel.webview, this.context.extensionUri), left: 'View' } }, decision, ); if (this.source.shown) this.heldHtml = html; else this.panel.webview.html = html; } dispose(): void { clearTimeout(this.timer); for (const d of this.disposables.splice(0)) d.dispose(); } } /** Custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */ export class DecisionViewProvider implements vscode.CustomTextEditorProvider, vscode.Disposable { private readonly views = new Set(); private active: DecisionView | undefined; private readonly disposables: vscode.Disposable[]; constructor( private readonly context: vscode.ExtensionContext, 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; } /** The view of the focused page, if any. */ get activeView(): { source: SourceSession } | undefined { return this.active; } // A text editor provider: the page edits the file's own TextDocument (in // its Source view), so dirty state, save and undo are VS Code's. async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise { panel.webview.options = { enableScripts: true }; const view = new DecisionView(this.context, 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(); } }