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:
1 parent
a9021312b0
commit
c99f3e5fe8
27 files changed
+2899
-14
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
import type { LiveCursor, LiveSelection } from './liveBridge';
|
||||
|
||||
/** One colour per person, the same in every window. */
|
||||
const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e'];
|
||||
|
||||
export function colorFor(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
|
||||
/** What the text-editor side needs from the shared models. */
|
||||
export interface CursorSource {
|
||||
cursors(file: vscode.Uri): LiveCursor[];
|
||||
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean;
|
||||
}
|
||||
|
||||
interface Styles {
|
||||
caret: vscode.TextEditorDecorationType;
|
||||
selection: vscode.TextEditorDecorationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursors in VS Code's text editors: yours go to the others as you move, and
|
||||
* theirs are drawn as a coloured caret with their name and a tinted selection.
|
||||
*/
|
||||
export class EditorCursors implements vscode.Disposable {
|
||||
private readonly styles = new Map<string, Styles>();
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pending: vscode.TextEditor | undefined;
|
||||
|
||||
constructor(private readonly source: CursorSource) {
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)),
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)),
|
||||
vscode.window.onDidChangeVisibleTextEditors(() => this.render()),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sent at most every 80 ms; the last position wins. */
|
||||
private moved(editor: vscode.TextEditor): void {
|
||||
if (editor.document.uri.scheme !== 'file') return;
|
||||
this.pending = editor;
|
||||
if (this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
const e = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!e || e.document.isClosed) return;
|
||||
const text = normalizeEol(e.document.getText());
|
||||
const { anchor, active } = e.selection;
|
||||
this.source.setCursor(e.document.uri, {
|
||||
anchor: offsetOf(text, anchor.line, anchor.character),
|
||||
head: offsetOf(text, active.line, active.character),
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private stylesFor(color: string): Styles {
|
||||
let found = this.styles.get(color);
|
||||
if (!found) {
|
||||
found = {
|
||||
caret: vscode.window.createTextEditorDecorationType({
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
// A zero-width caret and the name above it; the CSS rides in textDecoration,
|
||||
// the usual way to place a decoration without moving the text.
|
||||
before: {
|
||||
contentText: '',
|
||||
textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`,
|
||||
},
|
||||
}),
|
||||
selection: vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: `${color}38`,
|
||||
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
|
||||
}),
|
||||
};
|
||||
this.styles.set(color, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Draws everyone's cursors in the visible editors of shared files. */
|
||||
render(): void {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (editor.document.uri.scheme !== 'file') continue;
|
||||
const cursors = this.source.cursors(editor.document.uri);
|
||||
const text = normalizeEol(editor.document.getText());
|
||||
const at = (offset: number) => {
|
||||
const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length)));
|
||||
return new vscode.Position(line, character);
|
||||
};
|
||||
const byColor = new Map<string, { carets: vscode.DecorationOptions[]; selections: vscode.Range[] }>();
|
||||
for (const c of cursors) {
|
||||
let group = byColor.get(c.color);
|
||||
if (!group) byColor.set(c.color, (group = { carets: [], selections: [] }));
|
||||
const head = at(c.head);
|
||||
group.carets.push({
|
||||
range: new vscode.Range(head, head),
|
||||
hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`),
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: c.name,
|
||||
color: '#fff',
|
||||
backgroundColor: c.color,
|
||||
fontWeight: '500',
|
||||
textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (c.anchor !== c.head) {
|
||||
const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor];
|
||||
group.selections.push(new vscode.Range(at(from), at(to)));
|
||||
}
|
||||
}
|
||||
for (const color of byColor.keys()) this.stylesFor(color);
|
||||
// Every colour is set, so a person who left or moved away disappears.
|
||||
for (const [color, styles] of this.styles) {
|
||||
const group = byColor.get(color);
|
||||
editor.setDecorations(styles.caret, group?.carets ?? []);
|
||||
editor.setDecorations(styles.selection, group?.selections ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
for (const styles of this.styles.values()) {
|
||||
styles.caret.dispose();
|
||||
styles.selection.dispose();
|
||||
}
|
||||
this.styles.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user