import * as vscode from "vscode"; import type { RoomClient } from "./room-client"; type Selection = { anchor: number; head: number }; type PeerState = { user?: { name?: string; color?: string; kind?: string }; path?: string | null; selections?: Selection[]; }; const PALETTE = ["#f778ba", "#3fb950", "#d29922", "#58a6ff", "#a371f7", "#ff7b72", "#39c5cf", "#e3b341"]; function colorFor(clientId: number, preferred?: string): string { if (preferred && /^#[0-9a-f]{6}$/i.test(preferred)) return preferred; return PALETTE[clientId % PALETTE.length]; } /** Publishes the local cursor and paints every peer's cursor, selection and name in visible editors. */ export class Cursors implements vscode.Disposable { private readonly decorations = new Map< number, { caret: vscode.TextEditorDecorationType; range: vscode.TextEditorDecorationType } >(); private readonly disposables: vscode.Disposable[] = []; constructor( private readonly room: RoomClient, private readonly pathOf: (uri: vscode.Uri) => string | null, name: string, ) { room.awareness.setLocalStateField("user", { name, color: colorFor(room.doc.clientID) }); room.awareness.on("change", () => this.render()); this.disposables.push( vscode.window.onDidChangeTextEditorSelection((event) => this.publish(event.textEditor)), vscode.window.onDidChangeActiveTextEditor((editor) => (editor ? this.publish(editor) : this.clearLocal())), vscode.window.onDidChangeVisibleTextEditors(() => this.render()), vscode.workspace.onDidChangeTextDocument(() => this.render()), ); if (vscode.window.activeTextEditor) this.publish(vscode.window.activeTextEditor); } private publish(editor: vscode.TextEditor): void { const path = this.pathOf(editor.document.uri); if (!path) return; const document = editor.document; this.room.awareness.setLocalStateField("path", path); this.room.awareness.setLocalStateField( "selections", editor.selections.map((selection) => ({ anchor: document.offsetAt(selection.anchor), head: document.offsetAt(selection.active), })), ); } private clearLocal(): void { this.room.awareness.setLocalStateField("path", null); this.room.awareness.setLocalStateField("selections", []); } private typesFor(clientId: number, state: PeerState) { let types = this.decorations.get(clientId); if (!types) { const color = colorFor(clientId, state.user?.color); const label = state.user?.name ?? (state.user?.kind === "agent" ? "agent" : "guest"); types = { caret: vscode.window.createTextEditorDecorationType({ border: `solid ${color}`, borderWidth: "0 0 0 2px", after: { contentText: ` ${label}`, color, fontStyle: "italic", margin: "0 0 0 4px" }, rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, }), range: vscode.window.createTextEditorDecorationType({ backgroundColor: `${color}33` }), }; this.decorations.set(clientId, types); } return types; } render(): void { const states = this.room.awareness.getStates() as Map; for (const [clientId, types] of this.decorations) { if (!states.has(clientId)) { types.caret.dispose(); types.range.dispose(); this.decorations.delete(clientId); } } for (const editor of vscode.window.visibleTextEditors) { const path = this.pathOf(editor.document.uri); for (const [clientId, state] of states) { if (clientId === this.room.doc.clientID || !state.user) continue; const types = this.typesFor(clientId, state); const here = path !== null && state.path === path ? (state.selections ?? []) : []; const length = editor.document.getText().length; const clamp = (offset: number) => editor.document.positionAt(Math.max(0, Math.min(length, offset))); editor.setDecorations( types.caret, here.map((selection) => new vscode.Range(clamp(selection.head), clamp(selection.head))), ); editor.setDecorations( types.range, here .filter((selection) => selection.anchor !== selection.head) .map((selection) => new vscode.Range(clamp(selection.anchor), clamp(selection.head))), ); } } } /** Names of everyone else in the room, for the status bar. */ peers(): string[] { const names: string[] = []; for (const [clientId, state] of this.room.awareness.getStates() as Map) { if (clientId !== this.room.doc.clientID && state.user?.name) names.push(state.user.name); } return [...new Set(names)]; } dispose(): void { for (const types of this.decorations.values()) { types.caret.dispose(); types.range.dispose(); } for (const disposable of this.disposables) disposable.dispose(); } }