diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 00a9acfb..44407890 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -14,10 +14,12 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { private readonly workspace: Workspace ) {} - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. const allFiles = []; - const remainingFolders = [this.vault.getRoot().path]; + const remainingFolders = [root ?? this.vault.getRoot().path]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 64c02655..7a7aa959 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -29,7 +29,9 @@ class MockDatabase implements Partial { class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); - public async listAllFiles(): Promise { + public async listFilesRecursively( + _root: RelativePath | undefined + ): Promise { throw new Error("Method not implemented."); } public async read(_path: RelativePath): Promise { diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 38f624e5..ff971889 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -5,6 +5,7 @@ import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { isBinary, reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; + export class FileOperations { private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private readonly fs: SafeFileSystemOperations; @@ -18,8 +19,22 @@ export class FileOperations { this.fs = new SafeFileSystemOperations(fs, logger); } - public async listAllFiles(): Promise { - return this.fs.listAllFiles(); + private static getParentDirAndFile( + path: RelativePath + ): [RelativePath, RelativePath] { + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + if (fileName == "" || fileName == null) { + 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 { @@ -120,7 +135,8 @@ export class FileOperations { public async delete(path: RelativePath): Promise { if (await this.exists(path)) { - return this.fs.delete(path); + await this.fs.delete(path); + await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { this.logger.debug(`No need to delete '${path}', it doesn't exist`); } @@ -146,6 +162,27 @@ export class FileOperations { this.database.move(oldPath, newPath); await this.fs.rename(oldPath, newPath); + await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); + } + + private async deletingEmptyParentDirectoriesOfDeletedFile( + path: RelativePath + ): Promise { + let directory = path; + while (directory.length > 1) { + [directory] = FileOperations.getParentDirAndFile(directory); + + 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 { @@ -184,13 +221,9 @@ export class FileOperations { } private async deconflictPath(path: RelativePath): Promise { - const pathParts = path.split("/"); - const fileName = pathParts.pop(); - if (fileName == "" || fileName == null) { - throw new Error(`Path '${path}' cannot be empty`); - } + // eslint-disable-next-line prefer-const + let [directory, fileName] = FileOperations.getParentDirAndFile(path); - let directory = pathParts.join("/"); if (directory) { directory += "/"; } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index d5d1eedc..9c7a8366 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -3,8 +3,10 @@ import type { RelativePath } from "../persistence/database"; import type { TextWithCursors } from "reconcile-text"; export interface FileSystemOperations { - // List all files that should be synced. - listAllFiles: () => Promise; + // List all files under root that should be synced. If root is undefined, return every file. + listFilesRecursively: ( + root: RelativePath | undefined + ) => Promise; // Read the content of a file. read: (path: RelativePath) => Promise; diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 2b1f908a..2c865c9f 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -20,9 +20,11 @@ export class SafeFileSystemOperations implements FileSystemOperations { this.locks = new Locks(logger); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { this.logger.debug("Listing all files"); - const result = await this.fs.listAllFiles(); + const result = await this.fs.listFilesRecursively(root); this.logger.debug(`Listed ${result.length} files`); return result; } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 186b9a9b..03041a36 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -335,7 +335,7 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { await this.createFakeDocumentsFromRemoteState(); - const allLocalFiles = await this.operations.listAllFiles(); + const allLocalFiles = await this.operations.listFilesRecursively(); let locallyPossiblyDeletedFiles: DocumentRecord[] = []; @@ -431,7 +431,7 @@ export class Syncer { } const [allLocalFiles, remote] = await Promise.all([ - this.operations.listAllFiles(), + this.operations.listFilesRecursively(), this.syncQueue.add(async () => this.syncService.getAll()) ]); diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 9e7806ab..a6ced45d 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -94,7 +94,7 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } - const files = await this.listAllFiles(); + const files = await this.listFilesRecursively(); if (files.length > 0) { options.push( diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 3ef55c8f..2b384c24 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -7,6 +7,7 @@ import { SyncClient } from "sync-client"; import type { TextWithCursors } from "reconcile-text"; + export class MockClient implements FileSystemOperations { protected readonly localFiles = new Map(); protected client!: SyncClient; @@ -46,7 +47,9 @@ export class MockClient implements FileSystemOperations { await this.client.start(); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { return Array.from(this.localFiles.keys()); }