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.
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
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();
|
|
}
|
|
}
|