mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-03-09 16:30:39 -07:00
734 lines
26 KiB
TypeScript
734 lines
26 KiB
TypeScript
import type { Logger } from '$:/core/modules/utils/logger.js';
|
|
import { git, workspace } from '@services/wiki/wikiWorker/services';
|
|
import type { IWikiWorkspace } from '@services/workspaces/interface';
|
|
import fs from 'fs';
|
|
import nsfw from 'nsfw';
|
|
import path from 'path';
|
|
import type { IFileInfo, Syncer, Wiki } from 'tiddlywiki';
|
|
import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex';
|
|
|
|
/**
|
|
* Delay before actually processing file deletion.
|
|
* This handles git operations that delete-then-recreate files (e.g., revert, checkout).
|
|
* If a file is recreated within this window, we treat it as modification instead of delete+create.
|
|
*/
|
|
const FILE_DELETION_DELAY_MS = 100;
|
|
|
|
/**
|
|
* Delay before re-including file after save/delete operations.
|
|
* Must be longer than nsfw's debounceMS (100ms) to ensure all file system events
|
|
* from our own operations are in the debounce queue before we re-include the file.
|
|
*/
|
|
const FILE_INCLUSION_DELAY_MS = 150;
|
|
|
|
/**
|
|
* Delay before notifying git service about file changes.
|
|
* Aggregates multiple file changes into a single notification.
|
|
*/
|
|
const GIT_NOTIFICATION_DELAY_MS = 1000;
|
|
|
|
/**
|
|
* Delay before triggering syncer after file changes.
|
|
* Allows multiple file changes (e.g., git checkout) to be batched together.
|
|
*/
|
|
const SYNCER_TRIGGER_DELAY_MS = 200;
|
|
|
|
export interface IUpdatedTiddlers {
|
|
deletions: string[];
|
|
modifications: string[];
|
|
}
|
|
|
|
/**
|
|
* Represents a file change detected by the watcher.
|
|
* Contains all information needed to load the tiddler.
|
|
*/
|
|
export interface IFileChange {
|
|
absolutePath: string;
|
|
relativePath: string;
|
|
type: 'add' | 'change' | 'delete';
|
|
/** Cached tiddler fields loaded during detection, avoids re-reading file */
|
|
cachedTiddlerFields?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* FileSystemWatcher: Responsible only for monitoring file system changes.
|
|
*
|
|
* This class implements a clean separation of concerns:
|
|
* - Monitors file system using nsfw library
|
|
* - Maintains exclusion list to prevent echo from our own save operations
|
|
* - Collects file changes into updatedTiddlers list
|
|
* - Triggers $tw.syncer.syncFromServer() to let syncer handle the actual wiki updates
|
|
*
|
|
* By delegating wiki updates to syncer, we:
|
|
* - Avoid duplicating sync logic
|
|
* - Get proper queuing and throttling for free
|
|
* - Handle edge cases like git checkout (many files at once) correctly
|
|
*/
|
|
export class FileSystemWatcher {
|
|
private readonly logger: Logger;
|
|
private readonly wiki: Wiki;
|
|
private readonly boot: typeof $tw.boot;
|
|
private readonly watchPathBase: string;
|
|
private readonly workspaceID: string;
|
|
private workspaceConfig: IWikiWorkspace | undefined;
|
|
|
|
/** Inverse index for mapping file paths to tiddler information */
|
|
private readonly inverseFilesIndex: InverseFilesIndex = new InverseFilesIndex();
|
|
|
|
/** Main wiki nsfw watcher instance */
|
|
private watcher: nsfw.NSFW | undefined;
|
|
|
|
/** Base excluded paths (permanent) */
|
|
private readonly baseExcludedPaths: string[] = [];
|
|
|
|
/** Excluded path patterns that apply to all wikis (directory names) */
|
|
private readonly excludedPathPatterns: string[] = ['.git', 'node_modules', '.DS_Store'];
|
|
|
|
/** Excluded file names that should not be treated as tiddlers */
|
|
private readonly excludedFileNames: string[] = ['tidgi.config.json'];
|
|
|
|
/** External attachments folder to exclude */
|
|
private externalAttachmentsFolder: string = 'files';
|
|
|
|
/** Pending file deletions (for git revert/checkout handling) */
|
|
private readonly pendingDeletions: Map<string, NodeJS.Timeout> = new Map();
|
|
|
|
/** Pending file inclusions (to prevent memory leaks) */
|
|
private readonly pendingInclusions: Map<string, NodeJS.Timeout> = new Map();
|
|
|
|
/** Timer for debouncing git notifications */
|
|
private gitNotificationTimer: NodeJS.Timeout | undefined;
|
|
|
|
/** Timer for debouncing syncer trigger */
|
|
private syncerTriggerTimer: NodeJS.Timeout | undefined;
|
|
|
|
/** Whether to ignore symlinks */
|
|
private ignoreSymlinks: boolean = true;
|
|
private readonly useWikiFolderAsTiddlersPath: boolean;
|
|
|
|
/**
|
|
* Collected file changes waiting to be processed by syncer.
|
|
* The syncer will call getUpdatedTiddlers() to retrieve these.
|
|
*/
|
|
private readonly updatedTiddlers: IUpdatedTiddlers = {
|
|
modifications: [],
|
|
deletions: [],
|
|
};
|
|
|
|
/**
|
|
* Map of pending file changes that need to be loaded.
|
|
* Key is tiddler title, value is the file change info.
|
|
*/
|
|
private readonly pendingFileLoads: Map<string, IFileChange> = new Map();
|
|
|
|
constructor(options: {
|
|
boot: typeof $tw.boot;
|
|
logger: Logger;
|
|
wiki: Wiki;
|
|
workspaceConfig?: IWikiWorkspace;
|
|
workspaceID: string;
|
|
}) {
|
|
this.wiki = options.wiki;
|
|
this.boot = options.boot;
|
|
this.logger = options.logger;
|
|
this.workspaceID = options.workspaceID;
|
|
this.workspaceConfig = options.workspaceConfig;
|
|
this.useWikiFolderAsTiddlersPath = this.wiki.getTiddlerText('$:/info/tidgi/useWikiFolderAsTiddlersPath', 'no') === 'yes';
|
|
|
|
const preferredWatchPath = this.useWikiFolderAsTiddlersPath ? this.boot.wikiPath : this.boot.wikiTiddlersPath;
|
|
if (preferredWatchPath) {
|
|
this.watchPathBase = path.resolve(preferredWatchPath);
|
|
} else {
|
|
this.watchPathBase = '';
|
|
}
|
|
|
|
// Initialize main wiki path in index
|
|
this.inverseFilesIndex.setMainWikiPath(this.watchPathBase);
|
|
|
|
// Load config from workspace
|
|
if (this.workspaceConfig) {
|
|
this.ignoreSymlinks = this.workspaceConfig.ignoreSymlinks;
|
|
const externalAttachmentsFolderConfig = this.wiki.getTiddlerText('$:/config/ExternalAttachments/WikiFolderToMove', 'files');
|
|
this.externalAttachmentsFolder = externalAttachmentsFolderConfig;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize file watching - must be called after construction
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
if (!this.watchPathBase) {
|
|
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized (no watch path)');
|
|
return;
|
|
}
|
|
|
|
// Check if file system watch is enabled for this workspace
|
|
if (this.workspaceConfig && !this.workspaceConfig.enableFileSystemWatch) {
|
|
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized (disabled by config)');
|
|
this.logger.log('FileSystemWatcher File system watching is disabled for this workspace');
|
|
return;
|
|
}
|
|
|
|
// Initialize inverse index from boot.files
|
|
this.initializeInverseFilesIndex();
|
|
|
|
// Setup base excluded paths
|
|
this.baseExcludedPaths.push(
|
|
path.join(this.watchPathBase, 'subwiki'),
|
|
path.join(this.watchPathBase, '$__StoryList'),
|
|
);
|
|
|
|
// Setup nsfw watcher
|
|
await this.setupNsfwWatcher();
|
|
}
|
|
|
|
/**
|
|
* Get the collected updates and clear them.
|
|
* Called by syncer's SyncFromServerTask.
|
|
*/
|
|
getUpdatedTiddlers(_syncer: Syncer, callback: (error: Error | null, updates: IUpdatedTiddlers) => void): void {
|
|
const updates = {
|
|
modifications: [...this.updatedTiddlers.modifications],
|
|
deletions: [...this.updatedTiddlers.deletions],
|
|
};
|
|
|
|
// Clear collected updates
|
|
this.updatedTiddlers.modifications = [];
|
|
this.updatedTiddlers.deletions = [];
|
|
|
|
callback(null, updates);
|
|
}
|
|
|
|
/**
|
|
* Load a tiddler from the file system.
|
|
* Called by syncer's LoadTiddlerTask for lazy loading.
|
|
*/
|
|
loadTiddler(title: string, callback: (error: Error | null, tiddlerFields?: Record<string, unknown> | null) => void): void {
|
|
const fileChange = this.pendingFileLoads.get(title);
|
|
if (!fileChange) {
|
|
// No pending load for this title - the tiddler might be a shadow tiddler
|
|
// or already loaded, return null to indicate no new data
|
|
callback(null, null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Use cached tiddler fields if available (loaded during detection)
|
|
if (fileChange.cachedTiddlerFields) {
|
|
// Still need to update boot.files for subsequent saves
|
|
const hasMetaFile = fileChange.absolutePath.endsWith('.tid') ? false : fs.existsSync(`${fileChange.absolutePath}.meta`);
|
|
this.boot.files[title] = {
|
|
filepath: fileChange.absolutePath,
|
|
type: (fileChange.cachedTiddlerFields.type as string) ?? 'application/x-tiddler',
|
|
hasMetaFile,
|
|
isEditableFile: true,
|
|
};
|
|
callback(null, fileChange.cachedTiddlerFields);
|
|
this.pendingFileLoads.delete(title);
|
|
return;
|
|
}
|
|
|
|
// Fallback: Load tiddler from file (should rarely happen)
|
|
const tiddlersDescriptor = $tw.loadTiddlersFromFile(fileChange.absolutePath);
|
|
const tiddlers = tiddlersDescriptor.tiddlers;
|
|
|
|
if (tiddlers.length === 0) {
|
|
callback(null, null);
|
|
return;
|
|
}
|
|
|
|
// Find the tiddler with matching title
|
|
const tiddler = tiddlers.find(t => t.title === title);
|
|
if (!tiddler) {
|
|
// Title doesn't match - might be renamed, return first tiddler
|
|
callback(null, tiddlers[0] as unknown as Record<string, unknown>);
|
|
} else {
|
|
callback(null, tiddler as unknown as Record<string, unknown>);
|
|
}
|
|
|
|
// Remove from pending loads
|
|
this.pendingFileLoads.delete(title);
|
|
|
|
// Update boot.files so getTiddlerInfo() works correctly
|
|
const { tiddlers: _, ...fileDescriptor } = tiddlersDescriptor;
|
|
const absoluteFilePath = fileChange.absolutePath;
|
|
this.boot.files[title] = {
|
|
filepath: absoluteFilePath,
|
|
type: fileDescriptor.type ?? 'application/x-tiddler',
|
|
hasMetaFile: fileDescriptor.hasMetaFile ?? false,
|
|
isEditableFile: fileDescriptor.isEditableFile ?? true,
|
|
};
|
|
|
|
// Update inverse index (with tiddlerTitle for reverse lookup)
|
|
this.inverseFilesIndex.set(fileChange.relativePath, {
|
|
...fileDescriptor,
|
|
filepath: fileChange.relativePath,
|
|
tiddlerTitle: title,
|
|
} as IBootFilesIndexItemWithTitle);
|
|
} catch (error) {
|
|
this.logger.alert('FileSystemWatcher Failed to load tiddler:', title, error);
|
|
callback(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get information about a pending file load
|
|
*/
|
|
getPendingFileLoad(title: string): IFileChange | undefined {
|
|
return this.pendingFileLoads.get(title);
|
|
}
|
|
|
|
/**
|
|
* Temporarily exclude a file from watching (during save/delete operations)
|
|
*/
|
|
excludeFile(absoluteFilePath: string): void {
|
|
this.inverseFilesIndex.excludeFile(absoluteFilePath);
|
|
}
|
|
|
|
/**
|
|
* Schedule a file to be re-included after a delay
|
|
*/
|
|
scheduleFileInclusion(absoluteFilePath: string): void {
|
|
const existingTimer = this.pendingInclusions.get(absoluteFilePath);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
this.inverseFilesIndex.includeFile(absoluteFilePath);
|
|
this.pendingInclusions.delete(absoluteFilePath);
|
|
this.scheduleGitNotification();
|
|
}, FILE_INCLUSION_DELAY_MS);
|
|
|
|
this.pendingInclusions.set(absoluteFilePath, timer);
|
|
}
|
|
|
|
/**
|
|
* Update the inverse index after a tiddler is saved
|
|
*/
|
|
updateIndexAfterSave(title: string, fileInfo: IFileInfo): void {
|
|
const fileRelativePath = path.relative(this.watchPathBase, fileInfo.filepath);
|
|
this.inverseFilesIndex.set(fileRelativePath, {
|
|
...fileInfo,
|
|
filepath: fileRelativePath,
|
|
tiddlerTitle: title,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove a tiddler from the inverse index after deletion
|
|
*/
|
|
removeFromIndex(absoluteFilePath: string): void {
|
|
const fileRelativePath = path.relative(this.watchPathBase, absoluteFilePath);
|
|
this.inverseFilesIndex.delete(fileRelativePath);
|
|
}
|
|
|
|
/**
|
|
* Get tiddler title by file path
|
|
*/
|
|
getTitleByPath(relativePath: string): string | undefined {
|
|
try {
|
|
return this.inverseFilesIndex.getTitleByPath(relativePath);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a file path is in the inverse index
|
|
*/
|
|
hasFile(relativePath: string): boolean {
|
|
return this.inverseFilesIndex.has(relativePath);
|
|
}
|
|
|
|
/**
|
|
* Get sub-wiki info for a file
|
|
*/
|
|
getSubWikiForFile(absoluteFilePath: string) {
|
|
return this.inverseFilesIndex.getSubWikiForFile(absoluteFilePath);
|
|
}
|
|
|
|
/**
|
|
* Cleanup resources when shutting down
|
|
*/
|
|
async cleanup(): Promise<void> {
|
|
// Clear timers
|
|
if (this.gitNotificationTimer) {
|
|
clearTimeout(this.gitNotificationTimer);
|
|
this.gitNotificationTimer = undefined;
|
|
}
|
|
if (this.syncerTriggerTimer) {
|
|
clearTimeout(this.syncerTriggerTimer);
|
|
this.syncerTriggerTimer = undefined;
|
|
}
|
|
|
|
for (const timer of this.pendingDeletions.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.pendingDeletions.clear();
|
|
|
|
for (const timer of this.pendingInclusions.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.pendingInclusions.clear();
|
|
|
|
// Stop main watcher
|
|
if (this.watcher) {
|
|
this.logger.log('FileSystemWatcher Closing filesystem watcher');
|
|
await this.watcher.stop();
|
|
this.watcher = undefined;
|
|
}
|
|
|
|
// Stop sub-wiki watchers
|
|
for (const subWiki of this.inverseFilesIndex.getSubWikis()) {
|
|
this.logger.log(`FileSystemWatcher Closing sub-wiki watcher: ${subWiki.id}`);
|
|
await subWiki.watcher.stop();
|
|
this.inverseFilesIndex.unregisterSubWiki(subWiki.id);
|
|
}
|
|
}
|
|
|
|
private initializeInverseFilesIndex(): void {
|
|
const initialLoadedFiles = this.boot.files;
|
|
for (const tiddlerTitle in initialLoadedFiles) {
|
|
if (Object.hasOwn(initialLoadedFiles, tiddlerTitle)) {
|
|
const fileDescriptor = initialLoadedFiles[tiddlerTitle];
|
|
const fileRelativePath = path.relative(this.watchPathBase, fileDescriptor.filepath);
|
|
this.inverseFilesIndex.set(fileRelativePath, { ...fileDescriptor, filepath: fileRelativePath, tiddlerTitle });
|
|
}
|
|
}
|
|
}
|
|
|
|
private async setupNsfwWatcher(): Promise<void> {
|
|
try {
|
|
this.watcher = await nsfw(
|
|
this.watchPathBase,
|
|
(events) => {
|
|
this.handleNsfwEvents(events);
|
|
},
|
|
{
|
|
debounceMS: 100,
|
|
errorCallback: (error) => {
|
|
this.logger.alert('FileSystemWatcher NSFW error:', error);
|
|
},
|
|
// @ts-expect-error - nsfw types are incorrect
|
|
excludedPaths: [...this.baseExcludedPaths],
|
|
},
|
|
);
|
|
|
|
await this.watcher.start();
|
|
await this.initializeSubWikiWatchers();
|
|
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized');
|
|
} catch (error) {
|
|
this.logger.alert('FileSystemWatcher Failed to initialize file watching:', error);
|
|
// Still log stabilized marker even if initialization failed
|
|
// This prevents tests from hanging waiting for the marker
|
|
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized (with errors)');
|
|
}
|
|
}
|
|
|
|
private async initializeSubWikiWatchers(): Promise<void> {
|
|
if (!this.workspaceID) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const subWikis = await workspace.getSubWorkspacesAsList(this.workspaceID);
|
|
this.logger.log(`FileSystemWatcher Found ${subWikis.length} sub-wikis to watch`);
|
|
|
|
for (const subWiki of subWikis) {
|
|
if (!('wikiFolderLocation' in subWiki) || !subWiki.wikiFolderLocation) {
|
|
continue;
|
|
}
|
|
|
|
const subWikiPath = subWiki.wikiFolderLocation;
|
|
|
|
if (!fs.existsSync(subWikiPath)) {
|
|
this.logger.log(`FileSystemWatcher Path does not exist for sub-wiki ${subWiki.name}: ${subWikiPath}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const subWikiWatcher = await nsfw(
|
|
subWikiPath,
|
|
(events) => {
|
|
this.handleNsfwEvents(events);
|
|
},
|
|
{
|
|
debounceMS: 100,
|
|
errorCallback: (error) => {
|
|
this.logger.alert(`FileSystemWatcher NSFW error for sub-wiki ${subWiki.name}:`, error);
|
|
},
|
|
// @ts-expect-error - nsfw types are incorrect
|
|
excludedPaths: this.excludedPathPatterns.map(pattern => path.join(subWikiPath, pattern)),
|
|
},
|
|
);
|
|
|
|
await subWikiWatcher.start();
|
|
this.inverseFilesIndex.registerSubWiki(subWiki.id, subWikiPath, subWikiWatcher);
|
|
this.logger.log(`FileSystemWatcher Watching sub-wiki: ${subWiki.name} at ${subWikiPath}`);
|
|
} catch (error) {
|
|
this.logger.alert(`FileSystemWatcher Failed to watch sub-wiki ${subWiki.name}:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.alert('FileSystemWatcher Failed to initialize sub-wiki watchers:', error);
|
|
}
|
|
}
|
|
|
|
private handleNsfwEvents(events: nsfw.FileChangeEvent[]): void {
|
|
let hasFileChanges = false;
|
|
|
|
for (const event of events) {
|
|
const { action, directory } = event;
|
|
|
|
let fileName = '';
|
|
if ('file' in event) {
|
|
fileName = event.file;
|
|
} else if ('newFile' in event) {
|
|
fileName = event.newFile;
|
|
}
|
|
|
|
const fileAbsolutePath = path.join(directory, fileName);
|
|
|
|
// Skip excluded patterns
|
|
if (this.shouldExcludeByPattern(fileAbsolutePath) || this.shouldExcludeByPattern(directory)) {
|
|
continue;
|
|
}
|
|
|
|
// Skip directories
|
|
if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) {
|
|
try {
|
|
const stats = fs.statSync(fileAbsolutePath);
|
|
if (stats.isDirectory()) {
|
|
continue;
|
|
}
|
|
if (this.ignoreSymlinks && stats.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check exclusion list
|
|
const subWikiForExclusion = this.inverseFilesIndex.getSubWikiForFile(fileAbsolutePath);
|
|
const isExcluded = subWikiForExclusion
|
|
? this.inverseFilesIndex.isSubWikiFileExcluded(subWikiForExclusion.id, fileAbsolutePath)
|
|
: this.inverseFilesIndex.isMainFileExcluded(fileAbsolutePath);
|
|
|
|
if (isExcluded) {
|
|
this.logger.log(`FileSystemWatcher Skipping excluded file: ${fileAbsolutePath}`);
|
|
continue;
|
|
}
|
|
|
|
hasFileChanges = true;
|
|
|
|
// Compute relative path
|
|
const subWikiInfo = this.inverseFilesIndex.getSubWikiForFile(fileAbsolutePath);
|
|
const basePath = subWikiInfo ? subWikiInfo.path : this.watchPathBase;
|
|
const fileRelativePath = path.relative(basePath, fileAbsolutePath);
|
|
const fileExtension = path.extname(fileRelativePath);
|
|
|
|
// Handle events
|
|
if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) {
|
|
this.cancelPendingDeletion(fileAbsolutePath);
|
|
this.handleFileAddOrChange(fileAbsolutePath, fileRelativePath, fileExtension, action === nsfw.actions.CREATED ? 'add' : 'change');
|
|
} else if (action === nsfw.actions.DELETED) {
|
|
this.scheduleDeletion(fileAbsolutePath, fileRelativePath, fileExtension);
|
|
} else if (action === nsfw.actions.RENAMED) {
|
|
if ('oldFile' in event && 'newFile' in event) {
|
|
const oldFileAbsPath = path.join(directory, event.oldFile);
|
|
const oldSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(oldFileAbsPath);
|
|
const oldBasePath = oldSubWikiInfo ? oldSubWikiInfo.path : this.watchPathBase;
|
|
const oldFileRelativePath = path.relative(oldBasePath, oldFileAbsPath);
|
|
const oldFileExtension = path.extname(oldFileRelativePath);
|
|
this.handleFileDelete(oldFileAbsPath, oldFileRelativePath, oldFileExtension);
|
|
|
|
const newDirectory = 'newDirectory' in event ? event.newDirectory : directory;
|
|
const newFileAbsPath = path.join(newDirectory, event.newFile);
|
|
const newSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(newFileAbsPath);
|
|
const newBasePath = newSubWikiInfo ? newSubWikiInfo.path : this.watchPathBase;
|
|
const newFileRelativePath = path.relative(newBasePath, newFileAbsPath);
|
|
const newFileExtension = path.extname(newFileRelativePath);
|
|
this.handleFileAddOrChange(newFileAbsPath, newFileRelativePath, newFileExtension, 'add');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasFileChanges) {
|
|
this.scheduleGitNotification();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle file add or change events.
|
|
* Instead of directly calling wiki.addTiddler(), we collect the change
|
|
* and trigger syncer to handle it.
|
|
*/
|
|
private handleFileAddOrChange(
|
|
fileAbsolutePath: string,
|
|
fileRelativePath: string,
|
|
fileExtension: string,
|
|
changeType: 'add' | 'change',
|
|
): void {
|
|
// For .meta files, process the corresponding base file
|
|
let actualFileAbsPath = fileAbsolutePath;
|
|
let actualFileRelativePath = fileRelativePath;
|
|
if (fileExtension === '.meta') {
|
|
actualFileAbsPath = fileAbsolutePath.slice(0, -5);
|
|
actualFileRelativePath = fileRelativePath.slice(0, -5);
|
|
}
|
|
|
|
// Load the file to get tiddler titles
|
|
let tiddlersDescriptor;
|
|
try {
|
|
tiddlersDescriptor = $tw.loadTiddlersFromFile(actualFileAbsPath);
|
|
} catch (error) {
|
|
this.logger.alert('FileSystemWatcher Failed to load file:', actualFileAbsPath, error);
|
|
return;
|
|
}
|
|
|
|
const { tiddlers, ...fileDescriptor } = tiddlersDescriptor;
|
|
|
|
for (const tiddler of tiddlers) {
|
|
const tiddlerTitle = tiddler?.title;
|
|
if (!tiddlerTitle) {
|
|
this.logger.alert(`FileSystemWatcher Tiddler has no title. File: ${actualFileAbsPath}`);
|
|
continue;
|
|
}
|
|
|
|
// Store the file change info for later loading by syncer
|
|
// Cache tiddler fields to avoid re-reading file in loadTiddler()
|
|
this.pendingFileLoads.set(tiddlerTitle, {
|
|
absolutePath: actualFileAbsPath,
|
|
relativePath: actualFileRelativePath,
|
|
type: changeType,
|
|
cachedTiddlerFields: tiddler as unknown as Record<string, unknown>,
|
|
});
|
|
|
|
// Update inverse index
|
|
this.inverseFilesIndex.set(actualFileRelativePath, {
|
|
...fileDescriptor,
|
|
filepath: actualFileRelativePath,
|
|
tiddlerTitle,
|
|
} as IBootFilesIndexItemWithTitle);
|
|
|
|
// Add to modifications list (syncer will handle duplicates)
|
|
if (!this.updatedTiddlers.modifications.includes(tiddlerTitle)) {
|
|
this.updatedTiddlers.modifications.push(tiddlerTitle);
|
|
}
|
|
|
|
this.logger.log(`[test-id-WATCH_FS_TIDDLER_${changeType === 'add' ? 'ADDED' : 'UPDATED'}] ${tiddlerTitle}`);
|
|
}
|
|
|
|
// Trigger syncer to process the changes
|
|
this.scheduleSyncerTrigger();
|
|
}
|
|
|
|
private handleFileDelete(_fileAbsolutePath: string, fileRelativePath: string, _fileExtension: string): void {
|
|
let tiddlerTitle: string;
|
|
|
|
if (this.inverseFilesIndex.has(fileRelativePath)) {
|
|
try {
|
|
tiddlerTitle = this.inverseFilesIndex.getTitleByPath(fileRelativePath);
|
|
} catch {
|
|
this.logger.alert(`FileSystemWatcher Could not find title for: ${fileRelativePath}`);
|
|
return;
|
|
}
|
|
} else {
|
|
// Extract title from filename as fallback
|
|
const fileNameWithoutExtension = path.basename(fileRelativePath, path.extname(fileRelativePath));
|
|
tiddlerTitle = fileNameWithoutExtension;
|
|
}
|
|
|
|
// Add to deletions list
|
|
if (!this.updatedTiddlers.deletions.includes(tiddlerTitle)) {
|
|
this.updatedTiddlers.deletions.push(tiddlerTitle);
|
|
}
|
|
|
|
// Remove from modifications if present (deletion takes precedence)
|
|
const modIndex = this.updatedTiddlers.modifications.indexOf(tiddlerTitle);
|
|
if (modIndex !== -1) {
|
|
this.updatedTiddlers.modifications.splice(modIndex, 1);
|
|
}
|
|
|
|
// Clean up boot.files so syncer won't try to delete the file again
|
|
if (this.boot.files[tiddlerTitle]) {
|
|
delete this.boot.files[tiddlerTitle];
|
|
}
|
|
|
|
// Clean up internal tracking
|
|
this.pendingFileLoads.delete(tiddlerTitle);
|
|
this.inverseFilesIndex.delete(fileRelativePath);
|
|
|
|
this.logger.log(`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`);
|
|
|
|
// Trigger syncer
|
|
this.scheduleSyncerTrigger();
|
|
}
|
|
|
|
private scheduleDeletion(fileAbsolutePath: string, fileRelativePath: string, fileExtension: string): void {
|
|
this.cancelPendingDeletion(fileAbsolutePath);
|
|
|
|
const timer = setTimeout(() => {
|
|
this.handleFileDelete(fileAbsolutePath, fileRelativePath, fileExtension);
|
|
this.pendingDeletions.delete(fileAbsolutePath);
|
|
}, FILE_DELETION_DELAY_MS);
|
|
|
|
this.pendingDeletions.set(fileAbsolutePath, timer);
|
|
}
|
|
|
|
private cancelPendingDeletion(fileAbsolutePath: string): void {
|
|
const existingTimer = this.pendingDeletions.get(fileAbsolutePath);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
this.pendingDeletions.delete(fileAbsolutePath);
|
|
this.logger.log(`FileSystemWatcher Cancelled pending deletion for: ${fileAbsolutePath}`);
|
|
}
|
|
}
|
|
|
|
private shouldExcludeByPattern(filePath: string): boolean {
|
|
const pathParts = filePath.split(path.sep);
|
|
const fileName = path.basename(filePath);
|
|
const hasExcludedPattern = this.excludedPathPatterns.some(pattern => pathParts.includes(pattern));
|
|
const hasExcludedFileName = this.excludedFileNames.includes(fileName);
|
|
const hasExternalAttachmentsFolder = pathParts.includes(this.externalAttachmentsFolder);
|
|
return hasExcludedPattern || hasExcludedFileName || hasExternalAttachmentsFolder;
|
|
}
|
|
|
|
private scheduleGitNotification(): void {
|
|
if (this.gitNotificationTimer) {
|
|
clearTimeout(this.gitNotificationTimer);
|
|
}
|
|
|
|
this.gitNotificationTimer = setTimeout(() => {
|
|
const wikiFolderLocation = this.useWikiFolderAsTiddlersPath ? this.watchPathBase : path.dirname(this.watchPathBase);
|
|
try {
|
|
void git.notifyFileChange(wikiFolderLocation, { onlyWhenGitLogOpened: true });
|
|
} catch (error) {
|
|
this.logger.alert('FileSystemWatcher Failed to notify git service:', error);
|
|
}
|
|
this.gitNotificationTimer = undefined;
|
|
}, GIT_NOTIFICATION_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Schedule syncer trigger with debounce.
|
|
* This allows multiple file changes to be batched together.
|
|
*/
|
|
private scheduleSyncerTrigger(): void {
|
|
if (this.syncerTriggerTimer) {
|
|
clearTimeout(this.syncerTriggerTimer);
|
|
}
|
|
|
|
this.syncerTriggerTimer = setTimeout(() => {
|
|
// Trigger syncer to process collected changes
|
|
if ($tw.syncer) {
|
|
$tw.syncer.syncFromServer();
|
|
} else {
|
|
this.logger.log('FileSystemWatcher Warning: $tw.syncer is not available, file changes will not be synced');
|
|
}
|
|
this.syncerTriggerTimer = undefined;
|
|
}, SYNCER_TRIGGER_DELAY_MS);
|
|
}
|
|
}
|