import * as vscode from 'vscode'; import { EditorSchemas } from '../language/editorSchemas'; import { readSchemas } from '../language/schemaCheck'; import { liveBridge } from '../sync/liveBridge'; import { log } from '../util/log'; import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff'; /** Page → host messages of the Source view. */ export type SourceMessage = | { type: 'sourceReady' } | { type: 'sourceOpen' } | { type: 'sourceClose' } | { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] } | { type: 'sourceSelection'; anchor: number; head: number }; export type SourceKind = 'diagram' | 'registry' | 'decision'; const CHECKS: Record = { diagram: 'YAML · checked against the Archify schema and the model', registry: 'YAML · checked against the registry schema and the model', decision: 'YAML · checked as a decision of the model', }; let editorSchemas: EditorSchemas | undefined; function schemasFor(context: vscode.ExtensionContext): EditorSchemas { editorSchemas ??= new EditorSchemas(readSchemas(vscode.Uri.joinPath(context.extensionUri, 'schemas').fsPath)); return editorSchemas; } export function isSourceMessage(message: { type?: string }): message is SourceMessage { return ( message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit' || message.type === 'sourceSelection' ); } /** * The host half of a page's Source view. The document stays VS Code's: page * edits become WorkspaceEdits (so dirty state, save, undo and Git work as * for any file), and changes from anywhere else (undo, the text editor, git) * go back to the page. Problems are whatever the Problems panel holds for the * file, sent as offsets; the page shows their count on the Source switch even * while the picture is shown. */ export class SourceSession implements vscode.Disposable { /** True while the page shows Source; the view must not replace its HTML then. */ shown = false; /** Texts our own edits will produce, oldest first; their change events are not echoed. */ private readonly expected: string[] = []; private queue: Promise = Promise.resolve(); private schemaKey: string | undefined; private readonly disposables: vscode.Disposable[]; constructor( private readonly context: vscode.ExtensionContext, private readonly panel: vscode.WebviewPanel, readonly uri: vscode.Uri, readonly kind: SourceKind, /** Called when Source opens or closes, so the view can hold or apply its HTML. */ private readonly onShown: (shown: boolean) => void, ) { const same = (uri: vscode.Uri) => uri.toString() === this.uri.toString(); this.disposables = [ vscode.languages.onDidChangeDiagnostics((event) => { if (event.uris.some(same)) void this.postDiagnostics(); }), vscode.workspace.onDidChangeTextDocument((event) => { if (!same(event.document.uri) || !event.contentChanges.length) return; this.post({ type: 'sourceDirty', dirty: event.document.isDirty }); if (this.shown) this.syncFrom(event.document); }), vscode.workspace.onDidSaveTextDocument((doc) => { if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false }); }), // Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise. liveBridge.onDidChangeCursors(() => this.postCursors()), ]; } /** Asks the page to show its Source view (Archify | Show Source). */ reveal(): void { this.post({ type: 'setView', view: 'source' }); } /** The page is being replaced by another one (the Graph page): Source is no longer shown. */ leave(): void { this.setShown(false); } /** Handles a Source message from the page; false for any other message. */ handle(message: { type?: string }): boolean { if (!isSourceMessage(message)) return false; switch (message.type) { case 'sourceReady': void this.postDiagnostics(); break; case 'sourceOpen': this.setShown(true); void this.sendText(true); break; case 'sourceClose': this.setShown(false); liveBridge.setCursor(this.uri, undefined); break; case 'sourceEdit': this.edit(message); break; case 'sourceSelection': liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head }); break; } return true; } private setShown(shown: boolean): void { if (this.shown === shown) return; this.shown = shown; this.onShown(shown); } private lastCursors = '[]'; private postCursors(): void { if (!this.shown) return; const cursors = liveBridge.cursors(this.uri); const key = JSON.stringify(cursors); if (key === this.lastCursors) return; this.lastCursors = key; this.post({ type: 'sourceCursors', cursors }); } private post(message: unknown): void { Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined); } private document(): Promise { return Promise.resolve(vscode.workspace.openTextDocument(this.uri)); } private async sendText(withDiagnostics: boolean): Promise { const doc = await this.document(); const text = normalizeEol(doc.getText()); this.expected.length = 0; this.schemaKey = undefined; this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] }); this.post({ type: 'sourceDirty', dirty: doc.isDirty }); this.lastCursors = ''; this.postCursors(); if (withDiagnostics) await this.postDiagnostics(); } /** The editor schema when it differs from the one the page has (a diagram's type may change), else undefined. */ private schemaChange(text: string): { key: string; schema: object } | null | undefined { const next = schemasFor(this.context).forSource(this.kind, text); const key = next?.key ?? ''; if (key === this.schemaKey) return undefined; this.schemaKey = key; return next ?? null; } private syncFrom(doc: vscode.TextDocument): void { const text = normalizeEol(doc.getText()); const index = this.expected.indexOf(text); if (index !== -1) { // Our own edit coming back; anything older is settled too. this.expected.splice(0, index + 1); return; } this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] }); } private edit(message: Extract): void { this.expected.push(message.text); const schema = this.schemaChange(message.text); if (schema !== undefined) this.post({ type: 'sourceSchema', schema }); this.queue = this.queue.then(async () => { try { const doc = await this.document(); const current = normalizeEol(doc.getText()); if (current === message.text) return; const edit = new vscode.WorkspaceEdit(); // Positions come from "\n" text; VS Code turns inserted text into the document's line endings. const at = (text: string, offset: number) => { const { line, character } = positionOf(text, offset); return new vscode.Position(line, character); }; if (current === message.base) { for (const change of message.changes) edit.replace(this.uri, new vscode.Range(at(current, change.from), at(current, change.to)), change.insert); } else { // The document moved on under the page (rare): the page's text wins. edit.replace(this.uri, new vscode.Range(at(current, 0), at(current, current.length)), message.text); } if (!(await vscode.workspace.applyEdit(edit))) { log().warn(`Source edit was not applied to ${this.uri.fsPath}`); void this.sendText(false); } } catch (error) { log().error(`Source edit failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`); } }); } /** Problems of the file as offsets into its "\n" text, and their count for the switch. */ async postDiagnostics(): Promise { const diagnostics = vscode.languages.getDiagnostics(this.uri); let items: unknown[] = []; if (this.shown && diagnostics.length) { const text = normalizeEol((await this.document()).getText()); items = diagnostics.map((d) => ({ from: offsetOf(text, d.range.start.line, d.range.start.character), to: offsetOf(text, d.range.end.line, d.range.end.character), severity: d.severity === vscode.DiagnosticSeverity.Error ? 'error' : d.severity === vscode.DiagnosticSeverity.Warning ? 'warning' : 'info', message: d.message, source: d.source, })); } const count = diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning).length; this.post({ type: 'sourceDiagnostics', items, count }); } dispose(): void { if (this.shown) liveBridge.setCursor(this.uri, undefined); for (const d of this.disposables.splice(0)) d.dispose(); } }