import type { Logger } from "../tracing/logger"; import type { FileSystemOperations } from "./filesystem-operations"; import type { RelativePath } from "../sync-operations/types"; import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isBinary } from "../utils/is-binary"; import type { ServerConfig } from "../services/server-config"; export class FileOperations { private static readonly PARENTHESES_REGEX = / \((?\d+)\)$/; private readonly fs: SafeFileSystemOperations; public constructor( private readonly logger: Logger, private readonly queue: SyncEventQueue, fs: FileSystemOperations, private readonly serverConfig: ServerConfig, private readonly nativeLineEndings = "\n" ) { this.fs = new SafeFileSystemOperations(fs, logger); } private static getParentDirAndFile( path: RelativePath ): [RelativePath, RelativePath] { const pathParts = path.split("/"); const fileName = pathParts.pop(); if (fileName == null || fileName === "") { throw new Error(`Path '${path}' cannot be empty`); } return [pathParts.join("/"), fileName]; } public async listFilesRecursively( root: RelativePath | undefined = undefined ): Promise { return this.fs.listFilesRecursively(root); } public async read(path: RelativePath): Promise { return this.fromNativeLineEndings(await this.fs.read(path)); } /** * Create a file at the specified path. * * If a file with the same name already exists, it is moved before creating the new one. * Parent directories are created if necessary. */ public async create( path: RelativePath, newContent: Uint8Array ): Promise { await this.ensureClearPath(path); return this.fs.write(path, this.toNativeLineEndings(newContent)); } // Returns the deconflicted path if a file was moved, undefined otherwise public async ensureClearPath( path: RelativePath ): Promise { if (await this.fs.exists(path)) { const deconflictedPath = await this.deconflictPath(path); try { this.logger.debug( `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` ); this.queue.moveDocument(path, deconflictedPath); await this.fs.rename(path, deconflictedPath, true); return deconflictedPath; } finally { this.fs.unlock(deconflictedPath); } } else { await this.createParentDirectories(path); } return undefined; } /** * Update the file at the given path. * * Performs a 3-way merge before writing if the file's content differs from `expectedContent`. * Does not recreate the file if it no longer exists, returning an empty array instead. */ public async write( path: RelativePath, expectedContent: Uint8Array, newContent: Uint8Array ): Promise { if (!(await this.fs.exists(path))) { this.logger.debug( `The caller assumed ${path} exists, but it no longer, so we wont recreate it` ); return; } if ( !isFileTypeMergable( path, (await this.serverConfig.getConfig()).mergeableFileExtensions ) || isBinary(expectedContent) || isBinary(newContent) ) { this.logger.debug( `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` ); await this.fs.write( path, // `newContent` might not be binary so we still have to ensure the line endings are correct this.toNativeLineEndings(newContent) ); return; } const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings await this.fs.atomicUpdateText( path, ({ text, cursors }: TextWithCursors): TextWithCursors => { this.logger.debug( `Performing a 3-way merge for ${path} with the expected content` ); text = text.replaceAll(this.nativeLineEndings, "\n"); const merged = reconcile( expectedText, { text, cursors }, newText ); const resultText = merged.text.replaceAll( "\n", this.nativeLineEndings ); return { text: resultText, cursors: merged.cursors }; } ); } public async delete(path: RelativePath): Promise { if (await this.exists(path)) { await this.fs.delete(path); await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { this.logger.debug(`No need to delete '${path}', it doesn't exist`); } } public async getFileSize(path: RelativePath): Promise { return this.fs.getFileSize(path); } public async exists(path: RelativePath): Promise { return this.fs.exists(path); } // Returns the deconflicted path if a file at the target was displaced public async move( oldPath: RelativePath, newPath: RelativePath ): Promise { if (oldPath === newPath) { return undefined; } const deconflictedPath = await this.ensureClearPath(newPath); this.queue.moveDocument(oldPath, newPath); await this.fs.rename(oldPath, newPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); return deconflictedPath; } public reset(): void { this.fs.reset(); } private async deletingEmptyParentDirectoriesOfDeletedFile( path: RelativePath ): Promise { let directory = path; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { [directory] = FileOperations.getParentDirAndFile(directory); if (directory.length === 0) { break; } const remainingContent = await this.fs.listFilesRecursively(directory); if (remainingContent.length === 0) { this.logger.debug( `Folder (${directory}) is now empty, deleting` ); await this.fs.delete(directory); } else { break; } } } private fromNativeLineEndings(content: Uint8Array): Uint8Array { if (isBinary(content)) { return content; } const decoder = new TextDecoder("utf-8"); let text = decoder.decode(content); text = text.replaceAll(this.nativeLineEndings, "\n"); return new TextEncoder().encode(text); } private toNativeLineEndings(content: Uint8Array): Uint8Array { if (isBinary(content)) { return content; } const decoder = new TextDecoder("utf-8"); let text = decoder.decode(content); text = text.replaceAll("\n", this.nativeLineEndings); return new TextEncoder().encode(text); } private async createParentDirectories(path: string): Promise { const components = path.split("/"); if (components.length === 1) { return; } for (let i = 1; i < components.length; i++) { const parentDir = components.slice(0, i).join("/"); if (!(await this.fs.exists(parentDir))) { await this.fs.createDirectory(parentDir); } } } /** * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. * The returned path has a lock acquired on it; it must be released by the caller when no longer needed. * * @param path The starting path to deconflict * @returns a non-existent path with a lock acquired on it */ private async deconflictPath(path: RelativePath): Promise { // eslint-disable-next-line prefer-const let [directory, fileName] = FileOperations.getParentDirAndFile(path); if (directory) { directory += "/"; } const nameParts = fileName.split("."); // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json" const isDotfile = fileName.startsWith(".") && nameParts[0] === ""; const extension = nameParts.length > 1 && !(isDotfile && nameParts.length === 2) ? "." + nameParts[nameParts.length - 1] : ""; let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; let currentCount = Number.parseInt( FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0" ); stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); let newName = path; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { currentCount++; newName = `${directory}${stem} (${currentCount})${extension}`; // Avoid multiple deconflictPath calls returning the same path await this.fs.waitForLock(newName); const existingRecord = this.queue.getSettledDocumentByPath(newName); if ( existingRecord !== undefined || // the document might have been confirmed by the server at a new path but haven't yet moved there locally (await this.fs.exists(newName, true)) ) { this.fs.unlock(newName); } else { return newName; } } } }