From 45e3f76da123775eb72cfd0f5753e122503eb967 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Mon, 24 Nov 2025 01:45:33 +0800 Subject: [PATCH] Fix/watch fs large json (#657) * fix: large json import still need to check identity * fix: show wiki server error by hide view * fix: possible loop when image load failed fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/562 * fix: more check and reduce logs * fix: sub wiki no sync notification fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/526 * fix: type * fix: update git sync js fixes https://github.com/tiddly-gittly/TidGi-Desktop/issues/515 * fix: msix not uploaded * typo * lint * Update comparison.ts --- .github/workflows/release.yml | 2 +- package.json | 4 +- pnpm-lock.yaml | 22 +++--- src/services/git/gitWorker.ts | 8 +- src/services/native/index.ts | 47 ++++++------ src/services/view/setupViewFileProtocol.ts | 55 +++++++++++--- src/services/wiki/index.ts | 15 +++- .../WatchFileSystemAdaptor.ts | 15 +++- .../__tests__/FileSystemAdaptor.basic.test.ts | 6 +- .../FileSystemAdaptor.delete.test.ts | 24 +++--- .../FileSystemAdaptor.routing.test.ts | 6 +- .../__tests__/FileSystemAdaptor.save.test.ts | 16 ++-- .../watchFileSystemAdaptor/comparison.ts | 61 +++++++++++++++ .../wiki/wikiWorker/startNodeJSWiki.ts | 75 +++++++++++-------- 14 files changed, 248 insertions(+), 108 deletions(-) create mode 100644 src/services/wiki/plugin/watchFileSystemAdaptor/comparison.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69aa3493..49ed3972 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: draft: true generate_release_notes: true files: | - ${{ matrix.platform == 'win' && 'out/make/**/*.exe' || 'out/make/**/*' }} + ${{ matrix.platform == 'win' && 'out/make/**/*.{exe,msix,appx}' || 'out/make/**/*' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 7e226d80..cec6873a 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "espree": "^11.0.0", "exponential-backoff": "^3.1.3", "fs-extra": "11.3.2", - "git-sync-js": "^2.3.0", + "git-sync-js": "^2.3.1", "graphql-hooks": "8.2.0", "html-minifier-terser": "^7.2.0", "i18next": "25.6.3", @@ -129,6 +129,7 @@ "optionalDependencies": { "@electron-forge/maker-deb": "7.10.2", "@electron-forge/maker-flatpak": "7.10.2", + "@electron-forge/maker-msix": "7.10.2", "@electron-forge/maker-rpm": "7.10.2", "@electron-forge/maker-snap": "7.10.2", "@electron-forge/maker-squirrel": "7.10.2", @@ -138,7 +139,6 @@ "devDependencies": { "@cucumber/cucumber": "^12.2.0", "@electron-forge/cli": "7.10.2", - "@electron-forge/maker-msix": "^7.10.2", "@electron-forge/plugin-auto-unpack-natives": "7.10.2", "@electron-forge/plugin-vite": "7.10.2", "@electron/rebuild": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 067b8dd1..bb0253c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,8 +138,8 @@ importers: specifier: 11.3.2 version: 11.3.2 git-sync-js: - specifier: ^2.3.0 - version: 2.3.0 + specifier: ^2.3.1 + version: 2.3.1 graphql-hooks: specifier: 8.2.0 version: 8.2.0(react@19.2.0) @@ -297,9 +297,6 @@ importers: '@electron-forge/cli': specifier: 7.10.2 version: 7.10.2(@swc/core@1.12.0)(bluebird@3.7.2)(encoding@0.1.13)(esbuild@0.27.0) - '@electron-forge/maker-msix': - specifier: ^7.10.2 - version: 7.10.2(bluebird@3.7.2) '@electron-forge/plugin-auto-unpack-natives': specifier: 7.10.2 version: 7.10.2(bluebird@3.7.2) @@ -448,6 +445,9 @@ importers: '@electron-forge/maker-flatpak': specifier: 7.10.2 version: 7.10.2(bluebird@3.7.2) + '@electron-forge/maker-msix': + specifier: 7.10.2 + version: 7.10.2(bluebird@3.7.2) '@electron-forge/maker-rpm': specifier: 7.10.2 version: 7.10.2(bluebird@3.7.2) @@ -4572,8 +4572,8 @@ packages: gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} - git-sync-js@2.3.0: - resolution: {integrity: sha512-RFyyWBjdZskl2Jxg3a9g2tzAShhhFkxyS83lON6asaKHov4Rks8P7irCMjQIRiFTlqLrvZFDgjeWbo7jc1IKhQ==} + git-sync-js@2.3.1: + resolution: {integrity: sha512-EbX6SIsUc2hcXEQXsaYz0EyDSQwlKi3AbZk00hIB97YOyKQPtKeQWQ7+6w87GSKD6m2mfBGVyIgF57DA0ajo3g==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -8456,6 +8456,7 @@ snapshots: transitivePeerDependencies: - bluebird - supports-color + optional: true '@electron-forge/maker-rpm@7.10.2(bluebird@3.7.2)': dependencies: @@ -11122,6 +11123,7 @@ snapshots: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + optional: true chalk@4.1.2: dependencies: @@ -11650,6 +11652,7 @@ snapshots: xml-escape: 1.1.0 transitivePeerDependencies: - supports-color + optional: true electron-winstaller@5.4.0: dependencies: @@ -12657,7 +12660,7 @@ snapshots: image-q: 4.0.0 omggif: 1.0.10 - git-sync-js@2.3.0: + git-sync-js@2.3.1: dependencies: dugite: 3.0.0-rc12 fs-extra: 11.3.2 @@ -15844,7 +15847,8 @@ snapshots: ws@8.18.3: {} - xml-escape@1.1.0: {} + xml-escape@1.1.0: + optional: true xml-name-validator@5.0.0: {} diff --git a/src/services/git/gitWorker.ts b/src/services/git/gitWorker.ts index 38ebb9cd..beda66cf 100644 --- a/src/services/git/gitWorker.ts +++ b/src/services/git/gitWorker.ts @@ -102,6 +102,8 @@ function initWikiGit( */ function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs, errorI18NDict: Record): Observable { return new Observable((observer) => { + // For sub-wiki, show sync progress in main workspace + const workspaceIDForNotification = isWikiWorkspace(workspace) && workspace.isSubWiki ? workspace.mainWikiID! : workspace.id; void commitAndSync({ ...configs, defaultGitInfo, @@ -113,7 +115,7 @@ function commitAndSyncWiki(workspace: IWorkspace, configs: ICommitAndSyncConfigs observer.next({ message, level: 'warn', meta: { callerFunction: 'commitAndSync', ...context } }); }, info: (message: GitStep, context: ILoggerContext): void => { - observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspace.id, callerFunction: 'commitAndSync', ...context } }); + observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspaceIDForNotification, callerFunction: 'commitAndSync', ...context } }); }, }, filesToIgnore: ['.DS_Store'], @@ -146,6 +148,8 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI observer.error(new Error('forcePullWiki can only be called on wiki workspaces')); return; } + // For sub-wiki, show sync progress in main workspace + const workspaceIDForNotification = isWikiWorkspace(workspace) && workspace.isSubWiki ? workspace.mainWikiID! : workspace.id; void forcePull({ dir: workspace.wikiFolderLocation, ...configs, @@ -158,7 +162,7 @@ function forcePullWiki(workspace: IWorkspace, configs: IForcePullConfigs, errorI observer.next({ message, level: 'warn', meta: { callerFunction: 'forcePull', ...context } }); }, info: (message: GitStep, context: ILoggerContext): void => { - observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspace.id, callerFunction: 'forcePull', ...context } }); + observer.next({ message, level: 'info', meta: { handler: WikiChannel.syncProgress, id: workspaceIDForNotification, callerFunction: 'forcePull', ...context } }); }, }, }).then( diff --git a/src/services/native/index.ts b/src/services/native/index.ts index 76e5b291..8dd5750e 100644 --- a/src/services/native/index.ts +++ b/src/services/native/index.ts @@ -428,14 +428,14 @@ ${message.message} } public formatFileUrlToAbsolutePath(urlWithFileProtocol: string): string { - logger.info('getting url', { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' }); + logger.debug('formatting file URL to absolute path', { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' }); let pathname = ''; let hostname = ''; try { ({ hostname, pathname } = new URL(urlWithFileProtocol)); } catch { pathname = urlWithFileProtocol.replace('file://', '').replace('open://', ''); - logger.error(`Parse URL failed, use original url replace file:// instead`, { pathname, function: 'formatFileUrlToAbsolutePath.error' }); + logger.debug(`Parse URL failed, using fallback string replace`, { pathname, function: 'formatFileUrlToAbsolutePath' }); } /** * urlWithFileProtocol: `file://./files/xxx.png` @@ -446,38 +446,33 @@ ${message.message} if (process.platform === 'win32' && filePath.startsWith('/')) { filePath = filePath.substring(1); } - logger.info('handle file:// or open:// This url will open file in-wiki', { hostname, pathname, filePath, function: 'formatFileUrlToAbsolutePath' }); - let fileExists = fs.existsSync(filePath); - logger.info('file exists (decodeURI)', { - function: 'formatFileUrlToAbsolutePath', - filePath, - exists: fileExists, - }); - if (fileExists) { + + // Strategy 1: Try as-is (for absolute paths) + if (fs.existsSync(filePath)) { + logger.debug('file found (direct path)', { filePath, function: 'formatFileUrlToAbsolutePath' }); return filePath; } - logger.info(`try find file relative to workspace folder`, { filePath, function: 'formatFileUrlToAbsolutePath' }); + + // Strategy 2: Try relative to workspace folder const workspaceService = container.get(serviceIdentifier.Workspace); const workspace = workspaceService.getActiveWorkspaceSync(); - if (workspace === undefined || !isWikiWorkspace(workspace)) { - logger.error(`No active workspace or not a wiki workspace, abort. Try loading filePath as-is.`, { filePath, function: 'formatFileUrlToAbsolutePath' }); - return filePath; + if (workspace !== undefined && isWikiWorkspace(workspace)) { + const filePathInWorkspaceFolder = path.resolve(workspace.wikiFolderLocation, filePath); + if (fs.existsSync(filePathInWorkspaceFolder)) { + logger.debug('file found (workspace relative)', { filePathInWorkspaceFolder, function: 'formatFileUrlToAbsolutePath' }); + return filePathInWorkspaceFolder; + } } - // try concat workspace path + file path to get relative path - const filePathInWorkspaceFolder = path.resolve(workspace.wikiFolderLocation, filePath); - fileExists = fs.existsSync(filePathInWorkspaceFolder); - logger.info(`This file ${fileExists ? '' : 'not '}exists in workspace folder.`, { filePathInWorkspaceFolder, function: 'formatFileUrlToAbsolutePath' }); - if (fileExists) { - return filePathInWorkspaceFolder; - } - // on production, __dirname will be in .webpack/main + + // Strategy 3: Try relative to TidGi App folder (for bundled assets) const inTidGiAppAbsoluteFilePath = path.join(app.getAppPath(), '.webpack', 'renderer', filePath); - logger.info(`try find file relative to TidGi App folder`, { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' }); - fileExists = fs.existsSync(inTidGiAppAbsoluteFilePath); - if (fileExists) { + if (fs.existsSync(inTidGiAppAbsoluteFilePath)) { + logger.debug('file found (app relative)', { inTidGiAppAbsoluteFilePath, function: 'formatFileUrlToAbsolutePath' }); return inTidGiAppAbsoluteFilePath; } - logger.warn(`This url can't be loaded in-wiki. Try loading url as-is.`, { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' }); + + // File not found - return original URL as fallback + logger.warn('file not found in any location, returning original URL', { url: urlWithFileProtocol, filePath, function: 'formatFileUrlToAbsolutePath' }); return urlWithFileProtocol; } diff --git a/src/services/view/setupViewFileProtocol.ts b/src/services/view/setupViewFileProtocol.ts index fb992615..04fd48e5 100644 --- a/src/services/view/setupViewFileProtocol.ts +++ b/src/services/view/setupViewFileProtocol.ts @@ -72,25 +72,60 @@ export function handleViewFileContentLoading(view: WebContentsView) { function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callback: (response: Electron.CallbackResponse) => void) { const nativeService = container.get(serviceIdentifier.NativeService); - const absolutePath: string | undefined = nativeService.formatFileUrlToAbsolutePath(details.url); - // When details.url is an absolute route, we just load it, don't need any redirect + const absolutePath: string = nativeService.formatFileUrlToAbsolutePath(details.url); + + // Prevent infinite redirect loop when path resolution failed + // formatFileUrlToAbsolutePath already checks file existence internally (3 times with different path strategies) + // If file not found, it returns the original URL as fallback + // Case 1: formatFileUrlToAbsolutePath returns the original URL (file not found fallback) + // Case 2: Resolved path still contains protocol (resolution failed) + // Case 3: Resolved path is relative (./xxx or ../xxx) - these cannot be loaded by Electron if ( - `file://${absolutePath}` === decodeURI(details.url) || - absolutePath === decodeURI(details.url) || - // also allow malformed `file:///` on `details.url` on windows, prevent infinite redirect when this check failed. - (process.platform === 'win32' && `file:///${absolutePath}` === decodeURI(details.url)) + absolutePath === details.url || + absolutePath.startsWith('file://') || + absolutePath.startsWith('open://') || + absolutePath.startsWith('./') || + absolutePath.startsWith('../') ) { - logger.debug('open file protocol', { + logger.warn('File path resolution failed or returned invalid path, request canceled to prevent redirect loop', { function: 'handleFileLink', - absolutePath: absolutePath ?? '', + originalUrl: details.url, + resolvedPath: absolutePath, + reason: absolutePath === details.url + ? 'same as original' + : absolutePath.startsWith('file://') || absolutePath.startsWith('open://') + ? 'contains protocol' + : 'relative path', + }); + callback({ + cancel: true, + }); + return; + } + + // When details.url is already an absolute file path, load it directly without redirect + const decodedUrl = decodeURI(details.url); + if ( + `file://${absolutePath}` === decodedUrl || + absolutePath === decodedUrl || + // also allow malformed `file:///` on `details.url` on windows + (process.platform === 'win32' && `file:///${absolutePath}` === decodedUrl) + ) { + logger.debug('Loading file without redirect', { + function: 'handleFileLink', + absolutePath, + originalUrl: details.url, }); callback({ cancel: false, }); } else { - logger.info('redirecting file protocol', { + // Need to redirect relative path to absolute path + logger.info('Redirecting file protocol to absolute path', { function: 'handleFileLink', - absolutePath: absolutePath ?? '', + originalUrl: details.url, + absolutePath, + redirectURL: `file://${absolutePath}`, }); callback({ cancel: false, diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index 60196c93..343a5d93 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -241,6 +241,15 @@ export class Wiki implements IWikiService { function: 'startWiki', }); await workspaceService.updateMetaData(workspaceID, { isLoading: false, didFailLoadErrorMessage: errorMessage }); + + // For plugin errors that occur after wiki boot, realign the view to hide it and show error message + const isPluginError = message.source === 'plugin-error'; + if (isPluginError && workspace.active) { + const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); + await workspaceViewService.realignActiveWorkspace(workspaceID); + logger.info('Realigned view after plugin error', { workspaceID, function: 'startWiki' }); + } + // fix "message":"listen EADDRINUSE: address already in use 0.0.0.0:5212" if (errorMessage.includes('EADDRINUSE')) { const portChange = { @@ -253,7 +262,11 @@ export class Wiki implements IWikiService { reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, true, { ...workspace, ...portChange })); return; } - reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace })); + + // For plugin errors, don't reject - let user see the error and try to recover + if (!isPluginError) { + reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace })); + } } } } diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts index 3354663e..32b9a50b 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import nsfw from 'nsfw'; import path from 'path'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import { hasMeaningfulChanges } from './comparison'; import { FileSystemAdaptor } from './FileSystemAdaptor'; import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex'; @@ -623,8 +624,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { tiddlerTitle, } as IBootFilesIndexItemWithTitle); - // Add tiddler to wiki (this will update if it exists or add if new) + // Get existing tiddler to check if content actually changed const syncAdaptor = $tw.syncadaptor as { wiki: Wiki } | undefined | null; + const existingTiddler = syncAdaptor?.wiki.getTiddler(tiddlerTitle); + + // Use content comparison to prevent unnecessary saves + // This is critical for large JSON imports that would otherwise cause infinite loops + if (existingTiddler) { + if (!hasMeaningfulChanges(existingTiddler.fields, tiddler)) { + // Content is identical, skip addTiddler to prevent save cycle + continue; + } + } + + // Add tiddler to wiki (this will trigger save if content changed) syncAdaptor?.wiki.addTiddler(tiddler); // Log appropriate event diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts index 134f9f2c..6258a066 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts @@ -1,6 +1,6 @@ import { workspace } from '@services/wiki/wikiWorker/services'; import path from 'path'; -import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileSystemAdaptor } from '../FileSystemAdaptor'; @@ -34,7 +34,7 @@ global.$tw = { node: true, boot: { wikiTiddlersPath: '/test/wiki/tiddlers', - files: {} as Record, + files: {} as Record, }, utils: mockUtils, }; @@ -139,7 +139,7 @@ describe('FileSystemAdaptor - Basic Functionality', () => { describe('getTiddlerInfo', () => { it('should return file info for existing tiddler', () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts index 63f8d956..6f56e20e 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts @@ -1,5 +1,5 @@ import { workspace } from '@services/wiki/wikiWorker/services'; -import type { FileInfo, Wiki } from 'tiddlywiki'; +import type { IFileInfo, Wiki } from 'tiddlywiki'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileSystemAdaptor } from '../FileSystemAdaptor'; @@ -33,7 +33,7 @@ global.$tw = { node: true, boot: { wikiTiddlersPath: '/test/wiki/tiddlers', - files: {} as Record, + files: {} as Record, }, utils: mockUtils, }; @@ -73,7 +73,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { describe('deleteTiddler - Callback Mode', () => { it('should delete tiddler and call callback on success', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -105,7 +105,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should handle EPERM error gracefully', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -137,7 +137,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should handle EACCES error gracefully', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -163,7 +163,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should propagate non-permission errors', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -186,7 +186,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should not treat EPERM as graceful if syscall is not unlink', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -212,7 +212,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { describe('deleteTiddler - Async/Await Mode', () => { it('should resolve successfully without callback', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should reject on non-permission errors', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -253,7 +253,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should handle permission errors gracefully even without callback', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -278,7 +278,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { describe('deleteTiddler - Error Conversion', () => { it('should convert string errors to Error objects', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -300,7 +300,7 @@ describe('FileSystemAdaptor - Delete Operations', () => { }); it('should convert unknown errors to Error objects', async () => { - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts index 8f282664..99bea2bd 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts @@ -1,6 +1,6 @@ import { workspace } from '@services/wiki/wikiWorker/services'; import type { IWikiWorkspace } from '@services/workspaces/interface'; -import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileSystemAdaptor } from '../FileSystemAdaptor'; @@ -34,7 +34,7 @@ global.$tw = { node: true, boot: { wikiTiddlersPath: '/test/wiki/tiddlers', - files: {} as Record, + files: {} as Record, }, utils: mockUtils, }; @@ -166,7 +166,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { }); it('should pass existing fileInfo with overwrite flag', async () => { - const existingFileInfo: FileInfo = { + const existingFileInfo: IFileInfo = { filepath: '/test/old.tid', type: 'application/x-tiddler', hasMetaFile: false, diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts index be977eaa..525446a3 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts @@ -1,5 +1,5 @@ import { workspace } from '@services/wiki/wikiWorker/services'; -import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileSystemAdaptor } from '../FileSystemAdaptor'; @@ -33,7 +33,7 @@ global.$tw = { node: true, boot: { wikiTiddlersPath: '/test/wiki/tiddlers', - files: {} as Record, + files: {} as Record, }, utils: mockUtils, }; @@ -77,7 +77,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler', text: 'Test content' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -155,7 +155,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -182,7 +182,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -236,7 +236,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -264,7 +264,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, @@ -304,7 +304,7 @@ describe('FileSystemAdaptor - Save Operations', () => { fields: { title: 'TestTiddler' }, } as Tiddler; - const fileInfo: FileInfo = { + const fileInfo: IFileInfo = { filepath: '/test/wiki/tiddlers/test.tid', type: 'application/x-tiddler', hasMetaFile: false, diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/comparison.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/comparison.ts new file mode 100644 index 00000000..024af718 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/comparison.ts @@ -0,0 +1,61 @@ +/** + * High-performance tiddler comparison utilities + * Used to detect if a tiddler has actually changed to prevent unnecessary saves + */ + +/** + * Fields that change automatically during save and should be excluded from comparison + */ +const EXCLUDED_FIELDS = new Set([ + 'modified', // Always updated on save + 'revision', // Internal TiddlyWiki revision counter + 'bag', // TiddlyWeb specific + 'created', // May be auto-generated +]); + +/** + * Fast comparison of two tiddlers to detect real content changes + * Strategy: + * 1. Quick length check on text field (most common change) + * 2. Compare field counts + * 3. Deep compare only if needed + * + * @param oldTiddler - Existing tiddler in wiki + * @param newTiddler - New tiddler from file + * @returns true if tiddlers are meaningfully different + */ +export function hasMeaningfulChanges( + oldTiddler: Record, + newTiddler: Record, +): boolean { + // Fast path: Compare text field length first (most common change) + const oldText = oldTiddler.text as string | undefined; + const newText = newTiddler.text as string | undefined; + + if ((oldText?.length ?? 0) !== (newText?.length ?? 0)) { + return true; // Text length changed - definitely different + } + + // Get field keys excluding auto-generated ones + const oldKeys = new Set(Object.keys(oldTiddler)).difference(EXCLUDED_FIELDS); + const newKeys = new Set(Object.keys(newTiddler)).difference(EXCLUDED_FIELDS); + + // Quick check: Different number of fields + if (oldKeys.size !== newKeys.size) { + return true; + } + + // Check if there are any different keys (fields added or removed) + if (oldKeys.difference(newKeys).size > 0) { + return true; + } + + // Deep comparison: Check each field value (keys are the same at this point) + for (const key of oldKeys) { + if (oldTiddler[key] !== newTiddler[key]) { + return true; // Field value changed + } + } + + return false; // No meaningful changes +} diff --git a/src/services/wiki/wikiWorker/startNodeJSWiki.ts b/src/services/wiki/wikiWorker/startNodeJSWiki.ts index 6b4fcc4f..57acf660 100644 --- a/src/services/wiki/wikiWorker/startNodeJSWiki.ts +++ b/src/services/wiki/wikiWorker/startNodeJSWiki.ts @@ -38,37 +38,52 @@ export function startNodeJSWiki({ userName, workspace, }: IStartNodeJSWikiConfigs): Observable { - // Wait for services to be ready before using intercept with logFor - onWorkerServicesReady(() => { - void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady'); - const textDecoder = new TextDecoder(); - intercept( - (newStdOut: string | Uint8Array) => { - const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut); - // Send to main process logger if services are ready - void native.logFor(workspace.name, 'info', message).catch((error: unknown) => { - console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace)); - }); - return message; - }, - (newStdError: string | Uint8Array) => { - const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError); - // Send to main process logger if services are ready - void native.logFor(workspace.name, 'error', message).catch((error: unknown) => { - console.error('[intercept] Failed to send stderr to main process:', error, message); - }); - return message; - }, - ); - }); - - if (openDebugger === true) { - inspector.open(); - inspector.waitForDebugger(); - // eslint-disable-next-line no-debugger - debugger; - } return new Observable((observer) => { + if (openDebugger === true) { + inspector.open(); + inspector.waitForDebugger(); + // eslint-disable-next-line no-debugger + debugger; + } + // Wait for services to be ready before using intercept with logFor + onWorkerServicesReady(() => { + void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady'); + const textDecoder = new TextDecoder(); + intercept( + (newStdOut: string | Uint8Array) => { + const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut); + // Send to main process logger if services are ready + void native.logFor(workspace.name, 'info', message).catch((error: unknown) => { + console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace)); + }); + return message; + }, + (newStdError: string | Uint8Array) => { + const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError); + // Send to main process logger if services are ready + void native.logFor(workspace.name, 'error', message).catch((error: unknown) => { + console.error('[intercept] Failed to send stderr to main process:', error, message); + }); + + // Detect critical plugin loading errors that can cause white screen + // These errors occur during TiddlyWiki boot module execution + if ( + message.includes('Error executing boot module') || + message.includes('Cannot find module') + ) { + observer.next({ + type: 'control', + source: 'plugin-error', + actions: WikiControlActions.error, + message, + argv: [], + }); + } + + return message; + }, + ); + }); let fullBootArgv: string[] = []; // mark isDev as used to satisfy lint when not needed directly void isDev;