import { applyExtendedPatch, ExtendedPatch } from "../extended"; import type { ClientToServerMessage, ServerToClientMessage, } from "../multiplayer"; import type { GitInterface } from "./gitTypes"; type GitFileKey = `${string}:${string}:${string}`; // repoId:ref:filePath function makeKey(repoId: string, ref: string, filePath: string): GitFileKey { return `${repoId}:${ref}:${filePath}`; } function hashString(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash.toString(36); } type FileState = { content: string; hash: string; dirty: boolean; }; export type LocalGitFileEditorOptions = { getGit: () => Promise; sendMessage: (message: ServerToClientMessage) => void; onPersistNeeded?: (repoId: string, ref: string, filePath: string) => void; }; /** * A simple local git file editor that handles file editing without * multiplayer complexity. Since there's only one local client, * we can directly apply patches without conflict resolution. */ export class LocalGitFileEditor { private files = new Map(); constructor(private options: LocalGitFileEditorOptions) {} async handleMessage(message: ClientToServerMessage): Promise { switch (message.type) { case "gitFileEdit.open": await this.open(message.repoId, message.filePath, message.ref); return true; case "gitFileEdit.close": await this.close(message.repoId, message.filePath, message.ref); return true; case "gitFileEdit.patch": this.patch( message.repoId, message.filePath, message.ref, message.id, message.patches ); return true; default: return false; } } private async open(repoId: string, filePath: string, ref: string) { const key = makeKey(repoId, ref, filePath); // Check if already open let fileState = this.files.get(key); if (fileState) { // Send current state this.options.sendMessage({ type: "gitFileEdit.object", repoId, filePath, ref, content: fileState.content, hash: fileState.hash, }); return; } // Read file content from git let content = ""; try { const git = await this.options.getGit(); const result = await git.readFile(repoId, filePath, ref); content = result.content; } catch { // File doesn't exist yet - start with empty content content = ""; } const hash = hashString(content); fileState = { content, hash, dirty: false }; this.files.set(key, fileState); this.options.sendMessage({ type: "gitFileEdit.object", repoId, filePath, ref, content, hash, }); } private async close(repoId: string, filePath: string, ref: string) { const key = makeKey(repoId, ref, filePath); const fileState = this.files.get(key); // Persist immediately if dirty before closing if (fileState?.dirty) { await this.persist(repoId, filePath, ref); } this.files.delete(key); } private patch( repoId: string, filePath: string, ref: string, id: string, patches: ExtendedPatch[] ) { const key = makeKey(repoId, ref, filePath); const fileState = this.files.get(key); if (!fileState) { this.options.sendMessage({ type: "gitFileEdit.rejectPatch", repoId, filePath, ref, id, reason: "serverStateNotInitialized", }); return; } try { const newContent = applyExtendedPatch(fileState.content, patches); const newHash = hashString(newContent); fileState.content = newContent; fileState.hash = newHash; fileState.dirty = true; this.options.sendMessage({ type: "gitFileEdit.acceptPatch", repoId, filePath, ref, id, patches, hash: newHash, }); // Notify that this file needs to be persisted this.options.onPersistNeeded?.(repoId, ref, filePath); } catch (error) { this.options.sendMessage({ type: "gitFileEdit.rejectPatch", repoId, filePath, ref, id, reason: "errorApplyingPatch", error: error instanceof Error ? error.message : String(error), }); } } /** * Get the current content of a file being edited. * Returns undefined if the file is not open. */ getContent( repoId: string, filePath: string, ref: string ): string | undefined { const key = makeKey(repoId, ref, filePath); return this.files.get(key)?.content; } /** * Check if a file has unsaved changes. */ isDirty(repoId: string, filePath: string, ref: string): boolean { const key = makeKey(repoId, ref, filePath); return this.files.get(key)?.dirty ?? false; } /** * Persist the file to git by committing it. * Returns the new commit OID if successful. */ async persist( repoId: string, filePath: string, ref: string ): Promise<{ oid: string } | undefined> { const key = makeKey(repoId, ref, filePath); const fileState = this.files.get(key); if (!fileState || !fileState.dirty) { return undefined; } const git = await this.options.getGit(); // Use patchFiles to update the file and commit // The create operation will create or update the file const result = await git.patchFiles( repoId, { create: [ { path: filePath, content: fileState.content, }, ], }, ref ); fileState.dirty = false; // Broadcast commit notification this.options.sendMessage({ type: "gitFileEdit.committed", repoId, ref, oid: result.oid, }); return { oid: result.oid }; } /** * Get all files that have unsaved changes. */ getDirtyFiles(): Array<{ repoId: string; ref: string; filePath: string }> { const dirty: Array<{ repoId: string; ref: string; filePath: string }> = []; for (const [key, state] of this.files.entries()) { if (state.dirty) { const [repoId, ref, ...filePathParts] = key.split(":"); dirty.push({ repoId, ref, filePath: filePathParts.join(":") }); } } return dirty; } }