diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts index 7e7f9e68..36adff81 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts @@ -27,6 +27,8 @@ export class InverseFilesIndex { private mainExcludedFiles: Set = new Set(); /** Temporarily excluded files for each sub-wiki watcher (by absolute path) */ private subWikiExcludedFiles: Map> = new Map(); + /** Temporarily excluded tiddler titles during save/delete operations */ + private excludedTiddlerTitles: Set = new Set(); /** * Set the main wiki path @@ -227,4 +229,48 @@ export class InverseFilesIndex { const excluded = this.subWikiExcludedFiles.get(subWikiId); return excluded ? Array.from(excluded) : []; } + + /** + * Add a tiddler title to the exclusion list + * @param title Tiddler title to exclude + */ + excludeTiddlerTitle(title: string): void { + this.excludedTiddlerTitles.add(title); + } + + /** + * Remove a tiddler title from the exclusion list + * @param title Tiddler title to include + */ + includeTiddlerTitle(title: string): void { + this.excludedTiddlerTitles.delete(title); + } + + /** + * Check if a tiddler title is currently excluded + * @param title Tiddler title + * @returns True if title is excluded + */ + isTiddlerTitleExcluded(title: string): boolean; + /** + * Filter out excluded tiddlers from a changes object + * @param changes Changed tiddlers object + * @returns Filtered changes with excluded tiddlers removed + */ + isTiddlerTitleExcluded(changes: Record): Record; + isTiddlerTitleExcluded(input: string | Record): boolean | Record { + // Single title check + if (typeof input === 'string') { + return this.excludedTiddlerTitles.has(input); + } + + // Filter changes object + const filteredChanges: Record = {}; + for (const title in input) { + if (input[title] && !this.excludedTiddlerTitles.has(title)) { + filteredChanges[title] = input[title]; + } + } + return filteredChanges; + } } diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts index 0095a2f7..b8f55d22 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts @@ -71,6 +71,10 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { * Can be used with callback (legacy) or as async/await */ override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record }): Promise { + const title = tiddler.fields.title; + // Exclude title to prevent Wiki change events from being sent to frontend + this.inverseFilesIndex.excludeTiddlerTitle(title); + try { // Get file info to calculate path for watching const fileInfo = await this.getTiddlerFileInfo(tiddler); @@ -104,7 +108,14 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { // Schedule file re-inclusion after save completes this.scheduleFileInclusion(fileInfo.filepath); + + // Remove title from exclusion after delay + setTimeout(() => { + this.inverseFilesIndex.includeTiddlerTitle(title); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); } catch (error) { + // Clean up title exclusion on error + this.inverseFilesIndex.includeTiddlerTitle(title); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); callback?.(errorObject); throw errorObject; @@ -116,9 +127,13 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { * Can be used with callback (legacy) or as async/await */ override async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise { + // Exclude title to prevent Wiki change events from being sent to frontend + this.inverseFilesIndex.excludeTiddlerTitle(title); + const fileInfo = this.boot.files[title]; if (!fileInfo) { + this.inverseFilesIndex.includeTiddlerTitle(title); callback?.(null, null); return; } @@ -141,7 +156,14 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { // Schedule file re-inclusion after deletion completes this.scheduleFileInclusion(fileRelativePath); + + // Remove title from exclusion after delay + setTimeout(() => { + this.inverseFilesIndex.includeTiddlerTitle(title); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); } catch (error) { + // Clean up title exclusion on error + this.inverseFilesIndex.includeTiddlerTitle(title); // Schedule file re-inclusion on error to clean up exclusion list this.scheduleFileInclusion(fileRelativePath); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); diff --git a/src/services/wiki/wikiWorker/ipcServerRoutes.ts b/src/services/wiki/wikiWorker/ipcServerRoutes.ts index 34258dbd..d7d7690c 100644 --- a/src/services/wiki/wikiWorker/ipcServerRoutes.ts +++ b/src/services/wiki/wikiWorker/ipcServerRoutes.ts @@ -225,7 +225,31 @@ export class IpcServerRoutes { observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`)); } this.wikiInstance.wiki.addEventListener('change', (changes) => { - observer.next(changes); + // Filter out changes for tiddlers that are currently being saved/deleted + // This prevents echo: backend saves file → change event → frontend syncs → loads old file + const syncAdaptor = this.wikiInstance.syncadaptor as { + inverseFilesIndex?: { + isTiddlerTitleExcluded: ((title: string) => boolean) & ((changes: IChangedTiddlers) => IChangedTiddlers); + }; + } | undefined; + + let filteredChanges: IChangedTiddlers = changes; + + // Try to filter out excluded tiddlers if the method exists + if (syncAdaptor?.inverseFilesIndex?.isTiddlerTitleExcluded) { + try { + filteredChanges = syncAdaptor.inverseFilesIndex.isTiddlerTitleExcluded(changes); + } catch (error) { + // If filtering fails, send all changes + console.error('Failed to filter excluded tiddlers:', error); + filteredChanges = changes; + } + } + + // Send changes if there are any + if (filteredChanges && Object.keys(filteredChanges).length > 0) { + observer.next(filteredChanges); + } }); // Log SSE ready every time a new observer subscribes (including after worker restart) // Include timestamp to make each log entry unique for test detection