diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 6adada5..6d7b2f8 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -11,50 +11,32 @@ export class FileOperations { public constructor( private readonly logger: Logger, private readonly database: Database, - fs: FileSystemOperations + fs: FileSystemOperations, + private readonly nativeLineEndings: string = "\n" ) { this.fs = new SafeFileSystemOperations(fs, logger); } public async listAllFiles(): Promise { - const files = await this.fs.listAllFiles(); - this.logger.debug(`Listing all files, found ${files.length}`); - return files; + return this.fs.listAllFiles(); } public async read(path: RelativePath): Promise { - const content = await this.fs.read(path); - - if (isBinary(content)) { - return content; - } - - const decoder = new TextDecoder("utf-8"); - - // Normalize line-endings to LF on Windows - let text = decoder.decode(content); - text = text.replace(/\r\n/g, "\n"); - - return new TextEncoder().encode(text); + return this.fromNativeLineEndings(await this.fs.read(path)); } - public async getFileSize(path: RelativePath): Promise { - return this.fs.getFileSize(path); - } - - public async exists(path: RelativePath): Promise { - return this.fs.exists(path); - } - - // Create and write the file if it doesn't exist. Otherwise, it has the same behavior as write. - // All parent directories are created if they don't exist. + /** + * 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 { - this.logger.debug(`Creating file: ${path}`); - - await this.fs.write(path, newContent); + await this.ensureClearPath(path); + return this.fs.write(path, this.toNativeLineEndings(newContent)); } public async ensureClearPath(path: RelativePath): Promise { @@ -71,19 +53,22 @@ export class FileOperations { } } - // Update the file at the given path. - // If the file's content is different from `expectedContent`, the a 3-way merge is performed before writing. - // If the file no longer exists, the file is not recreated and an empty array is returned. + /** + * 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 { + ): 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 new Uint8Array(0); + return; } if ( @@ -94,44 +79,47 @@ export class FileOperations { 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); - return newContent; + 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); - const newText = new TextDecoder().decode(newContent); + 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 - const resultText = await this.fs.atomicUpdateText( - path, - (currentText) => { - currentText = currentText.replace(/\r\n/g, "\n"); - if (currentText !== expectedText) { - this.logger.debug( - `Performing a 3-way merge for ${path} with the expected content` - ); + await this.fs.atomicUpdateText(path, (currentText) => { + currentText = currentText.replace(this.nativeLineEndings, "\n"); - return mergeText(expectedText, currentText, newText); - } + this.logger.debug( + `Performing a 3-way merge for ${path} with the expected content` + ); - this.logger.debug( - `The current content of ${path} is the same as the expected content, so we will just write the new content` - ); - - return newText; - } - ); - return new TextEncoder().encode(resultText); + return mergeText(expectedText, currentText, newText).replace( + "\n", + this.nativeLineEndings + ); + }); } public async delete(path: RelativePath): Promise { if (await this.exists(path)) { - this.logger.debug(`Deleting file: ${path}`); return this.fs.delete(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); + } + public async move( oldPath: RelativePath, newPath: RelativePath @@ -139,12 +127,35 @@ export class FileOperations { if (oldPath === newPath) { return; } + await this.ensureClearPath(newPath); this.database.move(oldPath, newPath); await this.fs.rename(oldPath, newPath); } + private fromNativeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } + + const decoder = new TextDecoder("utf-8"); + let text = decoder.decode(content); + text = text.replace(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.replace("\n", this.nativeLineEndings); + return new TextEncoder().encode(text); + } + private async createParentDirectories(path: string): Promise { const components = path.split("/"); if (components.length === 1) {