Several people edit one model at once through a server you run yourself (archify-vscode-ext-sync); the files stay ordinary files on each disk. Sign-in through the browser, offline-first with later merge, teammates' cursors in the Source view and text editors, and a Shared section on the model page with people, versions, Compare and Restore. Without a server nothing changes: the sync code is a separate bundle that loads only when a model is shared, after a sign-in, or when a sync command or invite link is used.
230 lines
9.0 KiB
TypeScript
230 lines
9.0 KiB
TypeScript
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<SourceKind, string> = {
|
|
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<void> = 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<vscode.TextDocument> {
|
|
return Promise.resolve(vscode.workspace.openTextDocument(this.uri));
|
|
}
|
|
|
|
private async sendText(withDiagnostics: boolean): Promise<void> {
|
|
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<SourceMessage, { type: 'sourceEdit' }>): 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<void> {
|
|
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();
|
|
}
|
|
}
|