0.10.0: Archify Sync, optional live collaboration on a model

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.
This commit is contained in:
root-at-skic committed 2026-11-06 22:55:01 +02:00
1 parent a9021312b0
commit c99f3e5fe8
27 files changed
+2899 -14

No files matched your search

+5
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import * as vscode from 'vscode';
import { repoRootFor } from '../archify/runtime';
import { log } from '../util/log';
import type { LiveAction } from '../sync/liveBridge';
export type WebviewMessage =
| { type: 'hash'; hash: string }
@@ -14,6 +15,8 @@ export type WebviewMessage =
| { type: 'openDecision'; file: string }
| { type: 'showGraph'; query?: string }
| { type: 'showModel' }
| { type: 'liveReady' }
| { type: 'liveAction'; action: LiveAction }
| { type: 'error'; message: string };
/** Column for opening files next to a webview without covering it. */
@@ -100,6 +103,8 @@ export async function handleWebviewMessage(
case 'openDiagram':
case 'showGraph':
case 'showModel':
case 'liveReady':
case 'liveAction':
// Handled by the view, which knows the model root.
break;
}
+120
View File
@@ -618,6 +618,11 @@ function bridgeScript(options: PageOptions): string {
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
var body = el('section', 'archify-model archify-model-body');
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
var live = el('div', 'archify-live');
live.id = 'archify-live';
live.hidden = true;
body.appendChild(live);
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
body.appendChild(diagramsPanel);
@@ -635,6 +640,7 @@ function bridgeScript(options: PageOptions): string {
var button = el('button', 'archify-model-open');
button.type = 'button';
button.title = diagram.path;
button.setAttribute('data-path', diagram.path);
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
@@ -677,6 +683,99 @@ function bridgeScript(options: PageOptions): string {
decisionsPanel.appendChild(decisionList);
}
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
post({ type: 'liveReady' });
}
var LIVE_STATE = {
online: 'Live',
connecting: 'Connecting\u2026',
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
forbidden: 'No access \u00b7 you are no longer a member of this model',
outdated: 'The server needs a newer extension',
};
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
function liveButton(label, title, action) {
var button = el('button', 'archify-model-used-link archify-live-action', label);
button.type = 'button';
button.title = title;
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
return button;
}
function when(iso) {
var date = new Date(iso);
if (isNaN(date.getTime())) return '';
var seconds = (Date.now() - date.getTime()) / 1000;
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
return date.toLocaleDateString();
}
// The Live section of a shared model: who is here, the state, and versions.
function renderLive(info) {
var holder = document.getElementById('archify-live');
if (!holder) return;
holder.textContent = '';
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
if (!info) { holder.hidden = true; return; }
holder.hidden = false;
var others = info.people.filter(function (p) { return !p.me; });
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, true);
var status = el('div', 'archify-live-status');
status.appendChild(el('span', 'archify-live-dot'));
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
var actions = el('span', 'archify-live-actions');
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
status.appendChild(actions);
livePanel.appendChild(status);
var people = el('ul', 'archify-live-people');
info.people.forEach(function (person) {
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
if (person.file) item.appendChild(el('small', '', person.file));
people.appendChild(item);
});
livePanel.appendChild(people);
// Who is in which diagram, on the diagram rows.
others.forEach(function (person) {
if (!person.file) return;
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
if (button.getAttribute('data-path') !== person.file) return;
var meta = button.querySelector('.archify-live-here');
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
});
});
var versions = info.versions || [];
var head = el('div', 'archify-live-versions-head');
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
livePanel.appendChild(head);
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
var list = el('ul', 'archify-live-versions');
versions.slice(0, 8).forEach(function (v) {
var item = el('li', 'archify-live-version is-' + v.kind);
item.appendChild(el('strong', 'archify-live-version-label', v.label));
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
var buttons = el('span', 'archify-live-actions');
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
item.appendChild(buttons);
list.appendChild(item);
});
livePanel.appendChild(list);
holder.appendChild(livePanel);
}
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
@@ -775,6 +874,7 @@ function bridgeScript(options: PageOptions): string {
if (message.type === 'notice') showNotice(message.notice);
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
if (message.type === 'setView') setSource(message.view === 'source');
if (message.type === 'live') renderLive(message.live);
});
function onReady(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
@@ -854,6 +954,26 @@ const MODEL_STYLE = `
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
/* Live: a shared model's state, people and versions. */
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
.archify-live-person.is-me { opacity: 0.75; }
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
.archify-live-version-name { font-weight: 600; }
.archify-live-version-when { color: var(--m-muted); }
/* Decisions: rows, chips and status badges. */
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
.archify-decision-open { padding: 0.55rem 0.85rem; }
+103 -2
View File
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
import { Annotation, EditorState, Transaction } from '@codemirror/state';
import { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
drawSelection,
dropCursor,
highlightActiveLine,
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
source?: string;
}
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
export interface RemoteCursor {
name: string;
color: string;
anchor: number;
head: number;
}
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
class RemoteCaret extends WidgetType {
constructor(
readonly name: string,
readonly color: string,
) {
super();
}
eq(other: RemoteCaret): boolean {
return other.name === this.name && other.color === this.color;
}
toDOM(): HTMLElement {
const caret = document.createElement('span');
caret.className = 'cm-remote-caret';
caret.style.borderLeftColor = this.color;
const label = document.createElement('span');
label.className = 'cm-remote-label';
label.textContent = this.name;
label.style.backgroundColor = this.color;
caret.appendChild(label);
return caret;
}
ignoreEvent(): boolean {
return true;
}
}
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
const ranges: Range<Decoration>[] = [];
const clamp = (n: number) => Math.max(0, Math.min(n, length));
for (const c of cursors) {
const anchor = clamp(c.anchor);
const head = clamp(c.head);
if (anchor !== head) {
ranges.push(
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
);
}
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
}
return Decoration.set(ranges, true);
}
/** Teammates' carets and selections; they move with the text as anyone types. */
const remoteCursors = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
/** Marks transactions that carry text from the host, so they are not sent back. */
const fromHost = Annotation.define<boolean>();
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
'.cm-diagnostic': { padding: '4px 8px' },
'.cm-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
'.cm-remote-label': {
position: 'absolute',
bottom: '100%',
left: '-2px',
padding: '0 4px',
borderRadius: '3px 3px 3px 0',
color: '#fff',
fontSize: '10px',
lineHeight: '1.5',
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
whiteSpace: 'nowrap',
zIndex: '5',
opacity: '0.9',
},
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
});
@@ -71,7 +156,13 @@ export class SourceEditor {
readonly view: EditorView;
private schemaKey: string | undefined;
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
constructor(
parent: HTMLElement,
text: string,
onEdit: (changes: TextChange[], text: string) => void,
/** The main selection moved (or the editor got focus); offsets into the text. */
onSelection: (anchor: number, head: number) => void = () => undefined,
) {
this.view = new EditorView({
parent,
state: EditorState.create({
@@ -105,7 +196,12 @@ export class SourceEditor {
stateExtensions(undefined as never),
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
hoverTooltip(yamlSchemaHover()),
remoteCursors,
EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
const main = update.state.selection.main;
onSelection(main.anchor, main.head);
}
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
const changes: TextChange[] = [];
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
@@ -125,6 +221,11 @@ export class SourceEditor {
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
}
/** Teammates' cursors, from the host; they replace the ones shown. */
setRemoteCursors(cursors: RemoteCursor[]): void {
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
}
/** The schema that completion and hover read; `undefined` turns them off. */
setSchema(key: string | undefined, schema: object | undefined): void {
if (key === this.schemaKey) return;
+27 -3
View File
@@ -1,6 +1,6 @@
import type { ArchifyHost } from '../host';
import { TextChange, minimalChange } from '../../util/textDiff';
import { SourceDiagnostic, SourceEditor } from './editor';
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
/**
* The Source view of an Archify page: the file's YAML in an editor, in the
@@ -15,7 +15,8 @@ type HostMessage =
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
| { type: 'sourceDirty'; dirty: boolean };
| { type: 'sourceDirty'; dirty: boolean }
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
const STYLE = `
html.archify-source-on .container > :not(.header):not(#archify-source),
@@ -54,7 +55,10 @@ class SourcePane {
/** The text the host last knew the page to have; edits are sent against it. */
private text = '';
private diagnostics: SourceDiagnostic[] = [];
private cursors: RemoteCursor[] = [];
private shown = false;
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
private selection: { anchor: number; head: number } | undefined;
constructor(private readonly host: ArchifyHost) {
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
@@ -139,8 +143,14 @@ class SourcePane {
this.build();
if (!this.editor) {
this.text = message.text;
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
this.editor = new SourceEditor(
this.editorHost,
message.text,
(changes, text) => this.onEdit(changes, text),
(anchor, head) => this.onSelection(anchor, head),
);
this.editor.setDiagnostics(this.diagnostics);
this.editor.setRemoteCursors(this.cursors);
this.fit();
if (this.shown) this.editor.focus();
} else {
@@ -162,9 +172,23 @@ class SourcePane {
case 'sourceDirty':
this.renderState(message.dirty);
return;
case 'sourceCursors':
this.cursors = message.cursors;
this.editor?.setRemoteCursors(message.cursors);
return;
}
}
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
private onSelection(anchor: number, head: number): void {
this.selection = { anchor, head };
if (this.selectionTimer) return;
this.selectionTimer = setTimeout(() => {
this.selectionTimer = undefined;
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
}, 80);
}
private onEdit(changes: TextChange[], text: string): void {
const base = this.text;
this.text = text;