Add lineending support and clean up

This commit is contained in:
Andras Schmelczer 2025-03-22 12:05:16 +00:00
commit 79eb4f6c7b
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C

View file

@ -11,50 +11,32 @@ export class FileOperations {
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly database: Database,
fs: FileSystemOperations fs: FileSystemOperations,
private readonly nativeLineEndings: string = "\n"
) { ) {
this.fs = new SafeFileSystemOperations(fs, logger); this.fs = new SafeFileSystemOperations(fs, logger);
} }
public async listAllFiles(): Promise<RelativePath[]> { public async listAllFiles(): Promise<RelativePath[]> {
const files = await this.fs.listAllFiles(); return this.fs.listAllFiles();
this.logger.debug(`Listing all files, found ${files.length}`);
return files;
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
const content = await this.fs.read(path); return this.fromNativeLineEndings(await this.fs.read(path));
if (isBinary(content)) {
return content;
} }
const decoder = new TextDecoder("utf-8"); /**
* Create a file at the specified path.
// Normalize line-endings to LF on Windows *
let text = decoder.decode(content); * If a file with the same name already exists, it is moved before creating the new one.
text = text.replace(/\r\n/g, "\n"); * Parent directories are created if necessary.
*/
return new TextEncoder().encode(text);
}
public async getFileSize(path: RelativePath): Promise<number> {
return this.fs.getFileSize(path);
}
public async exists(path: RelativePath): Promise<boolean> {
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.
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array
): Promise<void> { ): Promise<void> {
this.logger.debug(`Creating file: ${path}`); await this.ensureClearPath(path);
return this.fs.write(path, this.toNativeLineEndings(newContent));
await this.fs.write(path, newContent);
} }
public async ensureClearPath(path: RelativePath): Promise<void> { public async ensureClearPath(path: RelativePath): Promise<void> {
@ -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. * Update the file at the given path.
// If the file no longer exists, the file is not recreated and an empty array is returned. *
* 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( public async write(
path: RelativePath, path: RelativePath,
expectedContent: Uint8Array, expectedContent: Uint8Array,
newContent: Uint8Array newContent: Uint8Array
): Promise<Uint8Array> { ): Promise<void> {
if (!(await this.fs.exists(path))) { if (!(await this.fs.exists(path))) {
this.logger.debug( this.logger.debug(
`The caller assumed ${path} exists, but it no longer, so we wont recreate it` `The caller assumed ${path} exists, but it no longer, so we wont recreate it`
); );
return new Uint8Array(0); return;
} }
if ( if (
@ -94,44 +79,47 @@ export class FileOperations {
this.logger.debug( this.logger.debug(
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
); );
await this.fs.write(path, newContent); await this.fs.write(
return newContent; 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 expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
const newText = new TextDecoder().decode(newContent); const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
await this.fs.atomicUpdateText(path, (currentText) => {
currentText = currentText.replace(this.nativeLineEndings, "\n");
const resultText = await this.fs.atomicUpdateText(
path,
(currentText) => {
currentText = currentText.replace(/\r\n/g, "\n");
if (currentText !== expectedText) {
this.logger.debug( this.logger.debug(
`Performing a 3-way merge for ${path} with the expected content` `Performing a 3-way merge for ${path} with the expected content`
); );
return mergeText(expectedText, currentText, newText); return mergeText(expectedText, currentText, newText).replace(
} "\n",
this.nativeLineEndings
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);
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {
if (await this.exists(path)) { if (await this.exists(path)) {
this.logger.debug(`Deleting file: ${path}`);
return this.fs.delete(path); return this.fs.delete(path);
} else { } else {
this.logger.debug(`No need to delete '${path}', it doesn't exist`); this.logger.debug(`No need to delete '${path}', it doesn't exist`);
} }
} }
public async getFileSize(path: RelativePath): Promise<number> {
return this.fs.getFileSize(path);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.fs.exists(path);
}
public async move( public async move(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
@ -139,12 +127,35 @@ export class FileOperations {
if (oldPath === newPath) { if (oldPath === newPath) {
return; return;
} }
await this.ensureClearPath(newPath); await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath); this.database.move(oldPath, newPath);
await this.fs.rename(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<void> { private async createParentDirectories(path: string): Promise<void> {
const components = path.split("/"); const components = path.split("/");
if (components.length === 1) { if (components.length === 1) {