import path from 'node:path'; import * as vscode from 'vscode'; import { isMainFile, isRegistryFile, parseRegistry } from '../model/format'; import { RegistryPanel, registryPanelFromIndex, registryPanelStandalone } 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 { makeNonce, registryPage } from '../webview/page'; import { DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; export { REGISTRY_VIEW_TYPE }; class RegistryDocument implements vscode.CustomDocument { constructor(readonly uri: vscode.Uri) {} dispose(): void {} } class RegistryView 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); 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 { const target = this.root ? vscode.Uri.joinPath(this.root, ...file.split('/')) : this.uri; 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 registry: RegistryPanel; 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('/'); registry = registryPanelFromIndex(await this.models.index(this.root), rel); } else { const name = path.basename(this.uri.path); registry = registryPanelStandalone(name, parseRegistry(await readText(this.uri), name)); } } catch (error) { log().warn(`Registry view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`); return; } if (seq !== this.seq) return; const fingerprint = JSON.stringify(registry); if (fingerprint === this.last) return; this.last = fingerprint; this.panel.webview.html = registryPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, registry); } dispose(): void { clearTimeout(this.timer); for (const d of this.disposables.splice(0)) d.dispose(); } } export class RegistryViewProvider implements vscode.CustomReadonlyEditorProvider, vscode.Disposable { private readonly views = new Set(); private active: RegistryView | undefined; private readonly disposables: vscode.Disposable[]; constructor(private readonly models: ModelService) { this.disposables = [ vscode.window.registerCustomEditorProvider(REGISTRY_VIEW_TYPE, this, { webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true }, supportsMultipleEditorsPerDocument: true, }), // Any change in the model (a diagram starts or stops using an element, a registry edit) refreshes the page. models.onDidChangeModel(({ root }) => { for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate(); }), vscode.workspace.onDidChangeTextDocument((event) => { if (!isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return; // Outside a model the model service stays quiet; refresh the page of the edited file itself. for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate(); }), ]; } /** File shown in the focused registry view, if any. */ get activeUri(): vscode.Uri | undefined { return this.active?.uri; } openCustomDocument(uri: vscode.Uri): RegistryDocument { return new RegistryDocument(uri); } async resolveCustomEditor(document: RegistryDocument, panel: vscode.WebviewPanel): Promise { panel.webview.options = { enableScripts: true }; const view = new RegistryView(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(); } }