import { Observable } from "@noya-app/observable"; import { diff } from "./diff"; import { ExtendedPatch } from "./extended"; import { ClientToServerMessage, MultiplayerStateManager, ServerToClientMessage, } from "./multiplayer"; export type GitFileEditServerMessage = | Extract, { type: "gitFileEdit.object" }> | Extract, { type: "gitFileEdit.acceptPatch" }> | Extract, { type: "gitFileEdit.rejectPatch" }>; export type GitFileEditHandle = { repoId: string; filePath: string; ref: string; content$: Observable; isInitialized$: Observable; sendPatches: (patches: ExtendedPatch[], metadata?: { name?: string }) => void; /** Set the content, automatically computing and sending patches from the current state */ setState: (newContent: string, metadata?: { name?: string }) => void; close: () => void; }; export class GitFileEditSession { private multiplayer = new MultiplayerStateManager( "" ); private unsubscribeMultiplayerMessages: (() => void) | undefined; private isOpen = false; constructor( public options: { repoId: string; filePath: string; ref: string; sendMessage?: (message: ClientToServerMessage) => void; onClose?: () => void; } ) { this.ensureMessageSubscription(); } getHandle(): GitFileEditHandle { const handle: GitFileEditHandle = { repoId: this.options.repoId, filePath: this.options.filePath, ref: this.options.ref, content$: this.multiplayer.optimisticState$, isInitialized$: this.multiplayer.isInitialized$, sendPatches: (patches, metadata) => { this.sendPatches(patches, metadata); }, setState: (newContent, metadata) => { this.setState(newContent, metadata); }, close: () => this.close(), }; return handle; } open() { this.ensureMessageSubscription(); if (!this.isOpen) { this.isOpen = true; this.multiplayer.connect(); return; } this.multiplayer.reinitialize(this.multiplayer.getConfirmedState()); } close() { if (!this.isOpen) { return; } this.isOpen = false; this.multiplayer.disconnect(); this.options.sendMessage?.({ type: "gitFileEdit.close", repoId: this.options.repoId, filePath: this.options.filePath, ref: this.options.ref, }); this.unsubscribeMultiplayerMessages?.(); this.unsubscribeMultiplayerMessages = undefined; this.options.onClose?.(); } private sendPatches(patches: ExtendedPatch[], metadata?: { name?: string }) { this.multiplayer.applyPatch(metadata ?? {}, { patches }); } private setState(newContent: string, metadata?: { name?: string }) { const oldContent = this.multiplayer.optimisticState$.get(); const [patches] = diff(oldContent, newContent, { useExtendedPatches: true, }); if (patches.length > 0) { this.sendPatches(patches, metadata); } } handleServerMessage(message: GitFileEditServerMessage) { const translated = this.translateIncomingMessage(message); if (!translated) return; this.multiplayer.incomingMessages.push(translated); this.multiplayer.processIncomingMessages(); } private flushOutgoingMessages() { while (this.multiplayer.outgoingMessages.length > 0) { const message = this.multiplayer.outgoingMessages.shift()!; const translated = this.translateOutgoingMessage(message); if (!translated) continue; this.options.sendMessage?.(translated); } } private translateOutgoingMessage( message: ClientToServerMessage ): ClientToServerMessage | undefined { switch (message.type) { case "init": { return { type: "gitFileEdit.open", repoId: this.options.repoId, filePath: this.options.filePath, ref: this.options.ref, }; } case "patch": { return { type: "gitFileEdit.patch", repoId: this.options.repoId, filePath: this.options.filePath, ref: this.options.ref, id: message.id, patches: message.patches, baseHash: message.baseHash, }; } default: { return undefined; } } } private translateIncomingMessage( message: GitFileEditServerMessage ): ServerToClientMessage | undefined { switch (message.type) { case "gitFileEdit.object": { return { type: "object", object: message.content }; } case "gitFileEdit.acceptPatch": { return { type: "acceptPatch", id: message.id, patches: message.patches, hash: message.hash, }; } case "gitFileEdit.rejectPatch": { return { type: "rejectPatch", id: message.id, reason: message.reason, error: message.error, }; } default: return undefined; } } private ensureMessageSubscription() { if (this.unsubscribeMultiplayerMessages) return; this.unsubscribeMultiplayerMessages = this.multiplayer.messageEmitter.addListener(() => { this.flushOutgoingMessages(); }); } }