From adcb031d2f42a8c98a33dfdfdb665a184afce8b0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 7 Dec 2025 15:41:55 +0000 Subject: [PATCH] Fix file watching --- frontend/local-client-cli/package.json | 3 +- frontend/local-client-cli/src/file-watcher.ts | 187 ++++++++++-------- frontend/package-lock.json | 48 ++++- 3 files changed, 152 insertions(+), 86 deletions(-) diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index 0f60af48..aa44748e 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -12,7 +12,8 @@ "test": "tsx --test 'src/**/*.test.ts'" }, "dependencies": { - "commander": "^14.0.2" + "commander": "^14.0.2", + "watcher": "^2.3.1" }, "devDependencies": { "@types/node": "^24.8.1", diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index 65577bc4..e781d18f 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -1,102 +1,121 @@ -import * as fs from "fs"; +import Watcher from "watcher"; import * as path from "path"; import type { SyncClient, RelativePath } from "sync-client"; export class FileWatcher { - private watcher: fs.FSWatcher | undefined; - private isRunning = false; + private watcher: Watcher | undefined; + private isRunning = false; - public constructor( - private readonly basePath: string, - private readonly client: SyncClient - ) {} + public constructor( + private readonly basePath: string, + private readonly client: SyncClient + ) {} - public start(): void { - if (this.isRunning) { - return; - } + public start(): void { + if (this.isRunning) { + return; + } - this.isRunning = true; + this.isRunning = true; - this.watcher = fs.watch( - this.basePath, - { recursive: true }, - (eventType, filename) => { - if (filename === null || filename.length === 0) { - return; - } + this.watcher = new Watcher(this.basePath, { + recursive: true, + renameDetection: true, + renameTimeout: 125, + ignoreInitial: true + }); - // Convert to forward slashes for consistency - const relativePath = this.toUnixPath(filename); + this.watcher.on("add", (filePath: string) => { + this.handleCreate(this.toRelativePath(filePath)); + }); - if (eventType === "rename") { - this.handleRenameOrDelete(relativePath); - } else { - // Must be "change" event - this.handleChange(relativePath); - } - } - ); + this.watcher.on("change", (filePath: string) => { + this.handleChange(this.toRelativePath(filePath)); + }); - this.client.logger.info("File watcher started"); - } + this.watcher.on("unlink", (filePath: string) => { + this.handleDelete(this.toRelativePath(filePath)); + }); - public stop(): void { - if (this.watcher !== undefined) { - this.watcher.close(); - this.watcher = undefined; - } - this.isRunning = false; - this.client.logger.info("File watcher stopped"); - } + this.watcher.on("rename", (oldPath: string, newPath: string) => { + this.handleRename( + this.toRelativePath(oldPath), + this.toRelativePath(newPath) + ); + }); - private handleChange(relativePath: RelativePath): void { - this.client - .syncLocallyUpdatedFile({ relativePath }) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}` - ); - }); - } + this.client.logger.info("File watcher started"); + } - private handleRenameOrDelete(relativePath: RelativePath): void { - const fullPath = path.join(this.basePath, relativePath); + public stop(): void { + if (this.watcher !== undefined) { + this.watcher.close(); + this.watcher = undefined; + } + this.isRunning = false; + this.client.logger.info("File watcher stopped"); + } - fs.access(fullPath, fs.constants.F_OK, (accessError) => { - if (accessError) { - this.client - .syncLocallyDeletedFile(relativePath) - .catch((deleteErr: unknown) => { - this.client.logger.error( - `Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}` - ); - }); - } else { - fs.stat(fullPath, (statErr, stats) => { - if (statErr !== null || !stats.isFile()) { - return; - } + private handleCreate(relativePath: RelativePath): void { + this.client + .syncLocallyCreatedFile(relativePath) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync created file ${relativePath}: ${this.formatError(err)}` + ); + }); + } - this.client - .syncLocallyCreatedFile(relativePath) - .catch((createErr: unknown) => { - this.client.logger.error( - `Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}` - ); - }); - }); - } - }); - } + private handleChange(relativePath: RelativePath): void { + this.client + .syncLocallyUpdatedFile({ relativePath }) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync updated file ${relativePath}: ${this.formatError(err)}` + ); + }); + } - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } + private handleDelete(relativePath: RelativePath): void { + this.client + .syncLocallyDeletedFile(relativePath) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync deleted file ${relativePath}: ${this.formatError(err)}` + ); + }); + } + + private handleRename(oldPath: RelativePath, newPath: RelativePath): void { + this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`); + this.client + .syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}` + ); + }); + } + + private toRelativePath(absolutePath: string): RelativePath { + const relative = path.relative(this.basePath, absolutePath); + return this.toUnixPath(relative); + } + + /** + * Convert a native platform path to forward slashes + */ + private toUnixPath(nativePath: string): string { + if (path.sep === "\\") { + return nativePath.replace(/\\/g, "/"); + } + return nativePath; + } + + private formatError(err: unknown): string { + return err instanceof Error ? err.message : String(err); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d45b165..c8819edb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,8 @@ "local-client-cli": { "version": "0.12.0", "dependencies": { - "commander": "^14.0.2" + "commander": "^14.0.2", + "watcher": "^2.3.1" }, "bin": { "vaultlink": "dist/cli.js" @@ -2452,6 +2453,12 @@ "node": ">=0.10" } }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "dev": true, @@ -5646,6 +5653,21 @@ "dev": true, "license": "MIT" }, + "node_modules/promise-make-counter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/promise-make-counter/-/promise-make-counter-1.0.2.tgz", + "integrity": "sha512-FJAxTBWQuQoAs4ZOYuKX1FHXxEgKLEzBxUvwr4RoOglkTpOjWuM+RXsK3M9q5lMa8kjqctUrhwYeZFT4ygsnag==", + "license": "MIT", + "dependencies": { + "promise-make-naked": "^3.0.2" + } + }, + "node_modules/promise-make-naked": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-make-naked/-/promise-make-naked-3.0.2.tgz", + "integrity": "sha512-B+b+kQ1YrYS7zO7P7bQcoqqMUizP06BOyNSBEnB5VJKDSWo8fsVuDkfSmwdjF0JsRtaNh83so5MMFJ95soH5jg==", + "license": "MIT" + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -6398,6 +6420,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -6697,6 +6724,15 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-readdir": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", + "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "license": "MIT", + "dependencies": { + "promise-make-counter": "^1.0.2" + } + }, "node_modules/to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -7076,6 +7112,16 @@ "license": "MIT", "peer": true }, + "node_modules/watcher": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watcher/-/watcher-2.3.1.tgz", + "integrity": "sha512-d3yl+ey35h05r5EFP0TafE2jsmQUJ9cc2aernRVyAkZiu0J3+3TbNugNcqdUJDoWOfL2p+bNsN427stsBC/HnA==", + "dependencies": { + "dettle": "^1.0.2", + "stubborn-fs": "^1.2.5", + "tiny-readdir": "^2.7.2" + } + }, "node_modules/watchpack": { "version": "2.4.2", "dev": true,