298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
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 = / \((?<count>\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<RelativePath[]> {
|
|
return this.fs.listFilesRecursively(root);
|
|
}
|
|
|
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
|
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<void> {
|
|
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<RelativePath | undefined> {
|
|
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<void> {
|
|
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<void> {
|
|
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<number> {
|
|
return this.fs.getFileSize(path);
|
|
}
|
|
|
|
public async exists(path: RelativePath): Promise<boolean> {
|
|
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<RelativePath | undefined> {
|
|
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<void> {
|
|
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<void> {
|
|
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<RelativePath> {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|