import { Observable } from "@noya-app/observable"; import { AssetManager, NoyaAsset } from "./AssetManager"; import type { ConnectionEvent } from "./ConnectionEventManager"; import { GIT_BUNDLE_CONTENT_TYPE } from "./constants"; import { GitFileEditHandle, GitFileEditServerMessage, GitFileEditSession, } from "./GitFileEditSession"; import { ClientToServerMessage, ServerToClientMessage } from "./multiplayer"; import type { GitFileInfo, GitFilePatchRequestBody, GitFilePatchResponseBody, GitInitRepoRequestBody, GitListFilesResponseBody, } from "./rpc/routes"; import { RPCManager } from "./rpcManager"; export type GitFileCommittedMessage = Extract< ServerToClientMessage, { type: "gitFileEdit.committed" } >; export type GitRepo = NoyaAsset; export type BranchInfo = { name: string; oid: string; }; export type RepoFilesFetchState = "idle" | "loading" | "refreshing"; export type RepoFilesState = { fetchState: RepoFilesFetchState; files: string[]; ref: string; oid: string; branches: BranchInfo[]; error?: string; /** Present when files were fetched with includeContent=true */ fileContents?: GitFileInfo[]; /** Whether content was included in the last fetch (used for background refresh) */ includeContent?: boolean; }; export type GitManagerOptions = { // Send a message over the active WebSocket connection sendMessage?: (message: ClientToServerMessage) => void; // Subscribe to server messages coming over WebSocket subscribeConnectionEvents?: ( handler: (event: ConnectionEvent) => void ) => () => void; // Whether to include base64-encoded file contents in responses includeContent?: boolean; }; export class GitManager { repos$: Observable; isInitialized$: Observable; // Map of repo id to files state repoFiles$: Observable>; // Whether to include base64-encoded file contents in responses includeContent: boolean; constructor( public rpcManager: RPCManager, public assetManager: AssetManager, options: GitManagerOptions = {} ) { this.includeContent = options.includeContent ?? false; // Derived observable that filters assets to only git bundle assets this.repos$ = this.assetManager.assets$.map((assets) => assets .filter((a) => a.contentType === GIT_BUNDLE_CONTENT_TYPE) .map((a) => this.assetManager._toNoyaAsset(a)) ); // Derived from asset manager initialization this.isInitialized$ = this.assetManager.isInitialized$; // Initialize files state this.repoFiles$ = new Observable>({}); // Wire up connection events for file editing this._sendMessage = options.sendMessage; if (options.subscribeConnectionEvents) { this._unsubscribeConnectionEvents = options.subscribeConnectionEvents( this._handleConnectionEvent ); } } getRepoState$(repoId: string): Observable { const cacheKey = `${repoId}:${this.includeContent}`; const cached = this._repoStateCache.get(cacheKey); if (cached) return cached; const verifiedRepoId$ = this.repos$.map( (repos) => repos.find((r) => r.id === repoId)?.id ); const combined$ = Observable.combine( [this.repoFiles$, verifiedRepoId$], ([repoFiles, verifiedRepoId]) => verifiedRepoId && verifiedRepoId in repoFiles ? repoFiles[verifiedRepoId] : undefined ); // Auto-fetch side effect: trigger fetch when repo is verified but has no state Observable.combine( [this.repoFiles$, verifiedRepoId$], ([repoFiles, verifiedRepoId]) => { if (!verifiedRepoId) return; if (verifiedRepoId in repoFiles) return; if (this._autoFetchedRepos.has(verifiedRepoId)) return; this._autoFetchedRepos.add(verifiedRepoId); this.fetchRepoFiles(verifiedRepoId); } ); this._repoStateCache.set(cacheKey, combined$); return combined$; } /** * Fetch files and branches for a specific repo at an optional ref. * Uses the instance's includeContent setting to determine whether to fetch file contents. */ async fetchRepoFiles(repoId: string, ref?: string): Promise { // Preserve existing branches during loading, or use empty array const existingState = this.repoFiles$.get()[repoId]; const existingBranches = existingState?.branches ?? []; // Set loading state this.repoFiles$.set({ ...this.repoFiles$.get(), [repoId]: { fetchState: "loading", files: [], ref: ref ?? "HEAD", oid: "", branches: existingBranches, }, }); try { // Fetch files and branches in parallel const [filesResult, branches] = await Promise.all([ this.listFiles(repoId, ref), // Only fetch branches if we don't have them yet existingBranches.length > 0 ? Promise.resolve(existingBranches) : this.listBranches(repoId), ]); this.repoFiles$.set({ ...this.repoFiles$.get(), [repoId]: { fetchState: "idle", ...filesResult, branches, includeContent: this.includeContent, }, }); } catch (error) { this.repoFiles$.set({ ...this.repoFiles$.get(), [repoId]: { fetchState: "idle", files: [], ref: ref ?? "HEAD", oid: "", branches: existingBranches, error: error instanceof Error ? error.message : "Unknown error", includeContent: this.includeContent, }, }); } } /** * Refresh files for a repo in the background. * Sets fetchState to "refreshing" so the UI can show a subtle indicator * without replacing the file list with a loading spinner. */ async refreshRepoFiles(repoId: string, ref?: string): Promise { const currentFiles = this.repoFiles$.get(); const existingState = currentFiles[repoId]; if (!existingState) return; // Set refreshing state (keeps existing files visible) this.repoFiles$.set({ ...currentFiles, [repoId]: { ...existingState, fetchState: "refreshing", }, }); try { const filesResult = await this.listFiles(repoId, ref); const latestFiles = this.repoFiles$.get(); this.repoFiles$.set({ ...latestFiles, [repoId]: { ...latestFiles[repoId], fetchState: "idle", files: filesResult.files, oid: filesResult.oid, ref: filesResult.ref, fileContents: filesResult.fileContents, includeContent: this.includeContent, }, }); } catch (error) { // Reset to idle on error, keep existing files const latestFiles = this.repoFiles$.get(); this.repoFiles$.set({ ...latestFiles, [repoId]: { ...latestFiles[repoId], fetchState: "idle", }, }); console.error("[GitManager] Background refresh failed:", error); } } /** * Initialize a new git repo by creating a mutable asset with git bundle content type. * Optionally accepts initial files to populate the repo. */ async initRepo(options?: GitInitRepoRequestBody): Promise { const response = await this.rpcManager.requestRoute("POST /api/git/repos", { headers: { "Content-Type": "application/json", }, body: JSON.stringify(options ?? {}), }); const { id } = this.rpcManager.getResponseBody(response); // Refresh assets to get the new repo await this.assetManager.fetch(); // Find the asset by server id, then convert to NoyaAsset // Server returns asset.id, but NoyaAsset uses stableId as id const asset = this.assetManager.assets$ .get() .find( (a) => (a.id === id || a.stableId === id) && a.contentType === GIT_BUNDLE_CONTENT_TYPE ); if (!asset) { throw new Error(`Failed to find created repo ${id}`); } return this.assetManager._toNoyaAsset(asset); } /** * List all files in a git repo at an optional ref. * The repoId can be either the asset id or stableId. * Uses the instance's includeContent setting to determine whether to fetch file contents. */ async listFiles(repoId: string, ref?: string): Promise { const response = await this.rpcManager.requestRoute( `GET /api/git/repos/${repoId}/files` as "GET /api/git/repos/:id/files", { searchParams: { ref, includeContent: this.includeContent ? "true" : undefined, }, } ); return this.rpcManager.getResponseBody(response); } /** * List all branches in a git repo. * The repoId can be either the asset id or stableId. */ async listBranches(repoId: string): Promise { const response = await this.rpcManager.requestRoute( `GET /api/git/repos/${repoId}/branches` as "GET /api/git/repos/:id/branches" ); const { branches } = this.rpcManager.getResponseBody(response); return branches; } /** * Patch files in a git repo (create, rename, delete). * Creates a single commit with all the requested operations. * Uses the instance's includeContent setting to determine whether to include file contents in response. */ async patchFiles( repoId: string, patch: GitFilePatchRequestBody, ref?: string ): Promise { const currentFiles = this.repoFiles$.get(); const repoState = currentFiles[repoId]; const response = await this.rpcManager.requestRoute( `PATCH /api/git/repos/${repoId}/files` as "PATCH /api/git/repos/:id/files", { body: JSON.stringify(patch), headers: { "Content-Type": "application/json" }, searchParams: { ref, includeContent: this.includeContent ? "true" : undefined, }, } ); const result = this.rpcManager.getResponseBody(response); // Update the local files state with the new commit info if (repoState) { this.repoFiles$.set({ ...this.repoFiles$.get(), [repoId]: { ...repoState, files: result.files, oid: result.oid, ref: result.ref, fileContents: result.fileContents, }, }); } return result; } // --- File Editing handle API --- _sendMessage?: (message: ClientToServerMessage) => void; _unsubscribeConnectionEvents?: () => void; // key: repoId:ref:filePath -> session _editSessions = new Map(); // Track subscriptions for content updates per session key _sessionSubscriptions = new Map void>(); // Track which repos have had an auto-fetch attempt via getRepoState$ _autoFetchedRepos = new Set(); // Cache for getRepoState$ observables (key: repoId:includeContent) _repoStateCache = new Map>(); private _makeSessionKey( repoId: string, ref: string, filePath: string ): string { return `${repoId}:${ref}:${filePath}`; } _handleConnectionEvent = (event: ConnectionEvent) => { if (event.type !== "receive") return; const message = event.message as | GitFileEditServerMessage | GitFileCommittedMessage; switch (message.type) { case "gitFileEdit.object": case "gitFileEdit.acceptPatch": case "gitFileEdit.rejectPatch": { const key = this._makeSessionKey( message.repoId, message.ref, message.filePath ); const session = this._editSessions.get(key); if (session) { session.handleServerMessage(message); } break; } case "gitFileEdit.committed": { // The server sends asset.id but the client uses stableId, so resolve it const resolvedRepoId = this.assetManager.read(message.repoId)?.id ?? message.repoId; const currentFiles = this.repoFiles$.get(); const repoState = currentFiles[resolvedRepoId]; if (repoState && repoState.ref === message.ref) { // Refetch the file list in the background (don't show loading state) this.refreshRepoFiles(resolvedRepoId, repoState.ref); } break; } } }; openEditingHandle( repoId: string, filePath: string, ref: string ): GitFileEditHandle { const key = this._makeSessionKey(repoId, ref, filePath); let session = this._editSessions.get(key); const isNewSession = !session; if (!session) { session = new GitFileEditSession({ repoId, filePath, ref, sendMessage: (m) => { this._sendMessage?.(m); }, onClose: () => { this._editSessions.delete(key); // Clean up subscription when session closes const existingSub = this._sessionSubscriptions.get(key); if (existingSub) { existingSub(); this._sessionSubscriptions.delete(key); } }, }); this._editSessions.set(key, session); } session.open(); const handle = session.getHandle(); // Only subscribe once per session to avoid duplicate subscriptions if (isNewSession) { // Subscribe to content changes to optimistically update fileContents // This keeps the file list view in sync with real-time edits const unsubscribe = handle.content$.subscribe((content) => { this._updateFileContent(repoId, ref, filePath, content); }); this._sessionSubscriptions.set(key, unsubscribe); } // Wrap the close function to clean up subscription const originalClose = handle.close; handle.close = () => { const existingSub = this._sessionSubscriptions.get(key); if (existingSub) { existingSub(); this._sessionSubscriptions.delete(key); } originalClose(); }; return handle; } /** * Optimistically update a file's content in the repoFiles state. * This is called when a file edit session receives content updates. */ private _updateFileContent( repoId: string, ref: string, filePath: string, content: string ): void { const currentFiles = this.repoFiles$.get(); const repoState = currentFiles[repoId]; // Only update if we have file contents and the ref matches if (!repoState || repoState.ref !== ref || !repoState.fileContents) { return; } // Base64 encode the content // btoa() can throw with non-Latin1 characters, so we handle that case let base64Content: string; try { // For binary-safe base64 encoding, convert to UTF-8 bytes first const bytes = new TextEncoder().encode(content); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } base64Content = btoa(binary); } catch { // If encoding fails, skip this update return; } // Find and update the file content, or add it if not present const existingIndex = repoState.fileContents.findIndex( (f) => f.path === filePath ); let newFileContents: GitFileInfo[]; if (existingIndex >= 0) { // Update existing entry newFileContents = [...repoState.fileContents]; newFileContents[existingIndex] = { path: filePath, content: base64Content, }; } else { // Add new entry newFileContents = [ ...repoState.fileContents, { path: filePath, content: base64Content }, ]; } this.repoFiles$.set({ ...currentFiles, [repoId]: { ...repoState, fileContents: newFileContents, }, }); } }