wip
This commit is contained in:
parent
31a5bac054
commit
4c002f9acd
3 changed files with 14543 additions and 4367 deletions
|
|
@ -1,291 +1,277 @@
|
|||
import type { Logger } from "../tracing/logger";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Database, RelativePath } from "../persistence/database";
|
||||
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 type {
|
||||
FileSnapshot,
|
||||
FileSystemOperations
|
||||
} from "./filesystem-operations";
|
||||
import type {
|
||||
Database,
|
||||
EngineState,
|
||||
StoredSnapshot
|
||||
} from "../persistence/database";
|
||||
import { isInternalPath } from "../utils/portable-path";
|
||||
import { FilePaths } from "./file-paths";
|
||||
import {
|
||||
fromStoredSnapshot,
|
||||
toStoredSnapshot,
|
||||
mergeContent
|
||||
} from "../sync-operations/content";
|
||||
import { hash } from "../utils/hash";
|
||||
import { isBinary } from "../utils/is-binary";
|
||||
import { isFileTypeMergeable } from "../utils/is-file-type-mergeable";
|
||||
import type { ServerConfig } from "../services/server-config";
|
||||
import {
|
||||
applyLocalChange,
|
||||
unappliedChanges,
|
||||
type LocalChange
|
||||
} from "../sync-operations/local-changes";
|
||||
import {
|
||||
FileNotFoundError,
|
||||
LocalChangesDuringReconciliation
|
||||
} from "../errors/errors";
|
||||
|
||||
export interface FileWrite {
|
||||
expected?: StoredSnapshot;
|
||||
replacement: StoredSnapshot;
|
||||
}
|
||||
|
||||
type Mutation = (action: () => Promise<void>) => Promise<void>;
|
||||
|
||||
/** Apply files first, then save metadata. Repeating a content merge is idempotent: reconcile(p, a, a) == a */
|
||||
export class FileOperations {
|
||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||
private readonly fs: SafeFileSystemOperations;
|
||||
private applying?: EngineState;
|
||||
private readonly paths: FilePaths;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
public readonly fs: FileSystemOperations,
|
||||
private readonly database: Database,
|
||||
fs: FileSystemOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly nativeLineEndings = "\n"
|
||||
|
||||
private readonly filesApplied: (
|
||||
paths: readonly string[]
|
||||
) => void = () => undefined,
|
||||
|
||||
private readonly localChanges: {
|
||||
entries: () => readonly LocalChange[];
|
||||
flush: () => Promise<void>;
|
||||
} = { entries: () => [], flush: async () => undefined },
|
||||
|
||||
private readonly isProtectedFile: (
|
||||
path: string,
|
||||
size: number
|
||||
) => boolean = () => false
|
||||
) {
|
||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||
this.paths = new FilePaths(fs, isProtectedFile);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
public async read(path: string): Promise<Uint8Array> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
|
||||
return [pathParts.join("/"), fileName];
|
||||
if (!snapshot)
|
||||
throw new FileNotFoundError(`File not found: ${path}`, path);
|
||||
|
||||
return snapshot.content;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||
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.database.move(path, deconflictedPath);
|
||||
await this.fs.rename(path, deconflictedPath, true);
|
||||
} finally {
|
||||
this.fs.unlock(deconflictedPath);
|
||||
}
|
||||
} else {
|
||||
await this.createParentDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 listFilesRecursively(): Promise<string[]> {
|
||||
return (await this.fs.listFilesRecursively()).filter(
|
||||
(path) => !isInternalPath(path)
|
||||
);
|
||||
}
|
||||
|
||||
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 snapshot(path: string): Promise<StoredSnapshot | undefined> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
return snapshot && toStoredSnapshot(snapshot);
|
||||
}
|
||||
|
||||
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(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
public async captureChange(
|
||||
change: LocalChange,
|
||||
previous: readonly LocalChange[]
|
||||
): Promise<void> {
|
||||
if (oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
const state = structuredClone(this.applying ?? this.database.state);
|
||||
|
||||
await this.ensureClearPath(newPath);
|
||||
if (!state.initialized) return;
|
||||
|
||||
this.database.move(oldPath, newPath);
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||
for (const pending of unappliedChanges(state, previous))
|
||||
applyLocalChange(state, pending, () => false);
|
||||
|
||||
applyLocalChange(state, change, () => false);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.fs.reset();
|
||||
}
|
||||
|
||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||
path: RelativePath
|
||||
public async apply(
|
||||
next: EngineState,
|
||||
writes: Partial<Record<string, FileWrite>> = {},
|
||||
validatePlan: () => void = () => undefined
|
||||
): 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
|
||||
if (this.fs.tryLock(newName)) {
|
||||
const newDocument =
|
||||
this.database.getLatestDocumentByRelativePath(newName);
|
||||
if (
|
||||
newDocument?.isDeleted === false || // 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;
|
||||
await this.localChanges.flush();
|
||||
const changes = this.localChanges.entries().length;
|
||||
let started = false;
|
||||
const guard = (): void => {
|
||||
if (started) return;
|
||||
validatePlan();
|
||||
if (this.localChanges.entries().length > changes)
|
||||
throw new LocalChangesDuringReconciliation();
|
||||
};
|
||||
const mutate: Mutation = async (action) => {
|
||||
guard();
|
||||
started = true;
|
||||
await action();
|
||||
};
|
||||
guard();
|
||||
const current = structuredClone(this.database.state);
|
||||
current.initialized = next.initialized;
|
||||
this.applying = current;
|
||||
const paths = new Set<string>();
|
||||
const observeChanges = async (): Promise<void> => {
|
||||
await this.localChanges.flush();
|
||||
guard();
|
||||
for (const change of unappliedChanges(
|
||||
next,
|
||||
this.localChanges.entries()
|
||||
)) {
|
||||
for (const state of [current, next]) {
|
||||
applyLocalChange(
|
||||
state,
|
||||
change,
|
||||
(path) =>
|
||||
isInternalPath(path) || this.isProtectedFile(path, 0)
|
||||
);
|
||||
state.lastAppliedLocalChangeId = change.changeId;
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const id of new Set([
|
||||
...Object.keys(current.local),
|
||||
...Object.keys(next.local)
|
||||
])) {
|
||||
await observeChanges();
|
||||
let from = current.documents[id]?.materialized
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
let to = next.local[id];
|
||||
const write = writes[id];
|
||||
if (!write && (from === undefined || from === to)) continue;
|
||||
if (from !== undefined) {
|
||||
paths.add(from);
|
||||
const info = await this.fs.stat(from);
|
||||
guard();
|
||||
if (info?.kind !== "file") {
|
||||
delete next.local[id];
|
||||
continue;
|
||||
}
|
||||
if (this.isProtectedFile(from, info.size)) {
|
||||
next.local[id] = from;
|
||||
next.documents[id] = structuredClone(
|
||||
current.documents[id]
|
||||
);
|
||||
(next.excluded ??= {})[id] =
|
||||
current.fileManifest.entries[id] ?? null;
|
||||
(next.protectedPaths ??= []).push(from);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (to === undefined) {
|
||||
if (from !== undefined) {
|
||||
await mutate(() => this.fs.deleteFile(from!));
|
||||
delete current.local[id];
|
||||
await this.paths.removeEmptyParents(from);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (from !== to) {
|
||||
to = await this.paths.prepareDestination(
|
||||
next,
|
||||
current,
|
||||
id,
|
||||
to,
|
||||
mutate
|
||||
);
|
||||
next.local[id] = to;
|
||||
from = current.documents[id]?.materialized
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
if (from !== undefined) {
|
||||
await mutate(async () => {
|
||||
current.local[id] = to;
|
||||
await this.fs.rename(from!, to);
|
||||
});
|
||||
await this.paths.removeEmptyParents(from);
|
||||
}
|
||||
}
|
||||
if (write) {
|
||||
const snapshot =
|
||||
from === undefined
|
||||
? undefined
|
||||
: await this.fs.read(to);
|
||||
if (from !== undefined && !snapshot) {
|
||||
delete next.local[id];
|
||||
continue;
|
||||
}
|
||||
const extensions = (await this.serverConfig.getConfig())
|
||||
.mergeableFileExtensions;
|
||||
await mutate(async () => {
|
||||
current.local[id] = to;
|
||||
current.documents[id] = { materialized: true };
|
||||
next.documents[id].observedHash =
|
||||
await this.updateContent(
|
||||
to,
|
||||
snapshot,
|
||||
write,
|
||||
extensions
|
||||
);
|
||||
});
|
||||
}
|
||||
paths.add(to);
|
||||
next.documents[id].materialized = true;
|
||||
current.local[id] = to;
|
||||
current.documents[id] = structuredClone(next.documents[id]);
|
||||
}
|
||||
await observeChanges();
|
||||
await this.database.commit(next);
|
||||
} finally {
|
||||
this.applying = undefined;
|
||||
this.filesApplied([...paths]);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateContent(
|
||||
path: string,
|
||||
current: FileSnapshot | undefined,
|
||||
write: FileWrite,
|
||||
extensions: string[]
|
||||
): Promise<string> {
|
||||
const replacement = fromStoredSnapshot(write.replacement);
|
||||
const base = write.expected && fromStoredSnapshot(write.expected);
|
||||
if (
|
||||
current &&
|
||||
base &&
|
||||
isFileTypeMergeable(path, extensions) &&
|
||||
![current, base, replacement].some((value) =>
|
||||
isBinary(value.content)
|
||||
)
|
||||
) {
|
||||
const decoder = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true
|
||||
});
|
||||
const parent = decoder.decode(base.content);
|
||||
const remote = decoder.decode(replacement.content);
|
||||
const text = await this.fs.atomicUpdateText(path, (latest) =>
|
||||
reconcile(parent, latest, remote)
|
||||
);
|
||||
return hash(new TextEncoder().encode(text));
|
||||
}
|
||||
const result =
|
||||
current && write.expected
|
||||
? await mergeContent(
|
||||
path,
|
||||
write.expected,
|
||||
await toStoredSnapshot(current),
|
||||
write.replacement,
|
||||
extensions
|
||||
)
|
||||
: write.replacement;
|
||||
await this.fs.write(path, fromStoredSnapshot(result));
|
||||
return result.hash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
frontend/sync-client/src/file-operations/file-paths.ts
Normal file
144
frontend/sync-client/src/file-operations/file-paths.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { v4 as uuid } from "uuid";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { EngineState } from "../persistence/database";
|
||||
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
|
||||
|
||||
/** Resolve occupied rename destinations without discarding another file. */
|
||||
export class FilePaths {
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly isProtectedFile: (path: string, size: number) => boolean
|
||||
) { }
|
||||
|
||||
public async prepareDestination(
|
||||
next: EngineState,
|
||||
current: EngineState,
|
||||
id: string,
|
||||
path: string,
|
||||
mutate: (action: () => Promise<void>) => Promise<void>
|
||||
): Promise<string> {
|
||||
try {
|
||||
for (; ;) {
|
||||
const obstruction = await this.obstruction(path);
|
||||
if (!obstruction) break;
|
||||
const children =
|
||||
obstruction.kind === "file"
|
||||
? [obstruction.path]
|
||||
: await this.fs.listFilesRecursively(obstruction.path);
|
||||
const occupants = [];
|
||||
for (const child of children) {
|
||||
const occupant = Object.keys(current.local).find(
|
||||
(key) =>
|
||||
current.documents[key]?.materialized &&
|
||||
current.local[key] === child
|
||||
);
|
||||
const info = await this.fs.stat(child);
|
||||
if (
|
||||
!info ||
|
||||
next.protectedPaths?.includes(child) ||
|
||||
this.isProtectedFile(child, info.size) ||
|
||||
(obstruction.kind === "directory" &&
|
||||
(occupant === undefined ||
|
||||
next.local[occupant] === child))
|
||||
)
|
||||
break;
|
||||
occupants.push({ id: occupant ?? uuid(), path: child });
|
||||
}
|
||||
if (occupants.length !== children.length) {
|
||||
path = await this.conflictPath(next, id, path);
|
||||
continue;
|
||||
}
|
||||
for (const occupant of occupants) {
|
||||
const displaced = await this.conflictPath(
|
||||
next,
|
||||
occupant.id,
|
||||
occupant.path.split("/").at(-1)!
|
||||
);
|
||||
await mutate(async () => {
|
||||
current.local[occupant.id] = displaced;
|
||||
await this.fs.rename(occupant.path, displaced);
|
||||
});
|
||||
if (!current.documents[occupant.id]) {
|
||||
current.documents[occupant.id] = { materialized: true };
|
||||
next.documents[occupant.id] = { materialized: true };
|
||||
next.local[occupant.id] = displaced;
|
||||
} else if (next.local[occupant.id] === occupant.path)
|
||||
next.local[occupant.id] = displaced;
|
||||
}
|
||||
if (obstruction.kind === "directory")
|
||||
await mutate(() => this.fs.deleteDirectory(obstruction.path));
|
||||
}
|
||||
await this.ensureParents(path);
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? error.code
|
||||
: undefined;
|
||||
if (
|
||||
![
|
||||
"ENAMETOOLONG",
|
||||
"EINVAL",
|
||||
"ENOTSUP",
|
||||
"EOPNOTSUPP",
|
||||
"EACCES",
|
||||
"EPERM"
|
||||
].includes(String(code))
|
||||
)
|
||||
throw error;
|
||||
const extension =
|
||||
/\.[^.]*$/u.exec(path.split("/").at(-1) ?? "")?.[0] ?? "";
|
||||
path = await this.conflictPath(
|
||||
next,
|
||||
id,
|
||||
`Recovered ${id}${extension}`
|
||||
);
|
||||
await this.ensureParents(path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private async obstruction(
|
||||
path: string
|
||||
): Promise<{ path: string; kind: "file" | "directory" } | undefined> {
|
||||
const parts = path.split("/");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const prefix = parts.slice(0, i + 1).join("/");
|
||||
const info = await this.fs.stat(prefix);
|
||||
if (info && (info.kind === "file" || i === parts.length - 1))
|
||||
return { path: prefix, kind: info.kind };
|
||||
}
|
||||
}
|
||||
|
||||
private async conflictPath(
|
||||
next: EngineState,
|
||||
id: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
const occupied = { ...next.local };
|
||||
delete occupied[id];
|
||||
const files = (await this.fs.listFilesRecursively()).filter(
|
||||
(path) => !isInternalPath(path)
|
||||
);
|
||||
for (const [i, diskPath] of files.entries())
|
||||
occupied[`disk-${i}`] = diskPath;
|
||||
return allocatePortablePath(path, id, occupied);
|
||||
}
|
||||
|
||||
private async ensureParents(path: string): Promise<void> {
|
||||
const parent = path.split("/").slice(0, -1).join("/");
|
||||
if (parent) await this.fs.createDirectory(parent);
|
||||
}
|
||||
|
||||
public async removeEmptyParents(path: string): Promise<void> {
|
||||
let parent = path.split("/").slice(0, -1).join("/");
|
||||
while (parent && !isInternalPath(parent)) {
|
||||
if ((await this.fs.listFilesRecursively(parent)).length) break;
|
||||
try {
|
||||
await this.fs.deleteDirectory(parent);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
parent = parent.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,36 @@
|
|||
import type { CursorPosition, TextWithCursors } from "reconcile-text";
|
||||
import type { RelativePath } from "../persistence/database";
|
||||
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
export interface FileSnapshot {
|
||||
content: Uint8Array;
|
||||
/** Optional editor selections, moved with the file and applied with its bytes. */
|
||||
cursors?: CursorPosition[];
|
||||
}
|
||||
|
||||
/** Filesystem operations act on the current user-owned namespace.
|
||||
* Paths are vault-relative. Never follow symlinks (including ancestor symlinks).
|
||||
* Only independent regular files are supported; reject hardlinks/special files.
|
||||
* A successful scan is complete: unreadable directories must reject, not vanish.
|
||||
*/
|
||||
export interface FileSystemOperations {
|
||||
// List all files under root that should be synced. If root is undefined, return every file.
|
||||
listFilesRecursively: (
|
||||
root: RelativePath | undefined
|
||||
) => Promise<RelativePath[]>;
|
||||
listFilesRecursively: (root?: string) => Promise<string[]>;
|
||||
|
||||
// Read the content of a file.
|
||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
||||
/** Return undefined on missing file */
|
||||
read: (path: string) => Promise<FileSnapshot | undefined>;
|
||||
|
||||
// Create or overwrite a file with the given content.
|
||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
||||
/** Reject symlinks and special files rather than following them and return undefined. */
|
||||
stat: (
|
||||
path: string
|
||||
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
|
||||
|
||||
/** Includes directories. */
|
||||
exists: (path: string) => Promise<boolean>;
|
||||
|
||||
/** Recursive, idempotent directory creation. */
|
||||
createDirectory: (path: string) => Promise<void>;
|
||||
|
||||
/** Create or overwrite a file, applying optional editor cursor metadata. */
|
||||
write: (path: string, snapshot: FileSnapshot) => Promise<void>;
|
||||
|
||||
// Atomically update the content of a text file.
|
||||
atomicUpdateText: (
|
||||
|
|
@ -20,18 +38,12 @@ export interface FileSystemOperations {
|
|||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
) => Promise<string>;
|
||||
|
||||
// Get the size of a file in bytes.
|
||||
getFileSize: (path: RelativePath) => Promise<number>;
|
||||
/** Move a file without replacing an existing destination. */
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
|
||||
// Check if a file exists.
|
||||
exists: (path: RelativePath) => Promise<boolean>;
|
||||
/** Idempotently unlink one regular file. */
|
||||
deleteFile: (path: string) => Promise<void>;
|
||||
|
||||
// Create a directory at the specified path. All parent directories must already exist.
|
||||
createDirectory: (path: RelativePath) => Promise<void>;
|
||||
|
||||
// Delete a file. It is expected that the path points to an existing file.
|
||||
delete: (path: RelativePath) => Promise<void>;
|
||||
|
||||
// Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist.
|
||||
rename: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
|
||||
/** Prune directories using rmdir only. Reject files and nonempty directories. */
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue