mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-03-10 17:00:25 -07:00
* fix: missing return
* feat: showApiKey
* feat: undo commit
* feat: amend commit
* fix: file name quoted in git log
* fix: wikiWorkspaceDefaultValues
* fix: no ai commit message sometimes
* Persist only non-default preferences to storage
Added a utility to store only preferences that differ from defaults, reducing storage size and improving config readability. Updated the setPreferences method to use this utility before saving preferences.
* fix: External Attachment Handling in fs plugin instead of ext-attachment-plugin to handle direct tag update case which won't trigger th-saving-tiddler hook
* feat: api for plugin to create base64 file
* Show all untracked files and recreate Git history window
Updated git status commands to use '-uall' for displaying all untracked files, not just directories. Modified windowService.open calls for Git history to include the { recreate: true } option, ensuring the window is refreshed when opened from various menus.
* fix: handling of external attachments with _canonical_uri
Ensure tiddlers with _canonical_uri are always saved as .tid files, not as binary files, by forcing the .tid extension in FileSystemAdaptor. Update tests to verify this behavior. Also, skip loading files from the external attachments folder in loadWikiTiddlersWithSubWikis to prevent them from being loaded as separate tiddlers.
* Refactor external attachment utilities to module exports
Refactored externalAttachmentUtilities to use ES module exports instead of attaching functions to $tw.utils. Updated imports and mocks accordingly, removed related type definitions from ExtendedUtilities, and cleaned up obsolete meta file.
* disable enableFileSystemWatch to prevent bug for innocent users
* fix: test that requires enableFileSystemWatch use new step set to true
* Fix extension filter usage and sync workspace state after save
Refactored variable naming for extension filters in FileSystemAdaptor to improve clarity and fixed their usage in generateTiddlerFileInfo calls. Removed an unused import in routingUtilities.type.ts. Added a useEffect in useForm to sync workspace state with originalWorkspace after save, ensuring the save button disappears as expected.
* fix: review
* lint
* feat: unify AI commit entry points and add availability check - Unified all AI commit message generation to use syncService.syncWikiIfNeeded() for consistent business logic handling - Added externalAPI.isAIAvailable() method to check if AI provider and model are properly configured - Updated gitService.isAIGenerateBackupTitleEnabled() to use the new availability check - Removed redundant logging code since generateFromAI() automatically logs to database when externalAPIDebug is enabled - Simplified menu item creation logic in menuItems.ts - Ensured AI menu options only appear when both API credentials and free model are configured - Updated documentation to reflect the unified architecture
* Improve AI commit message diff filtering and API checks
Renamed the AI commit message entry points doc for clarity. Enhanced the AI availability check to better handle provider API key requirements, including support for providers that do not require keys. Improved plugin diff filtering to retain small config file diffs while omitting large plugin file contents, optimizing AI token usage.
* Update wiki
* Refactor and enhance Tidgi mini window initialization and sync
Refactors Tidgi mini window startup to use a new initializeTidgiMiniWindow method, improving workspace selection logic and view management. Adds concurrency locks to prevent race conditions during open/close operations. Enhances workspace sync/fixed mode handling, view cleanup, and error logging. Updates interfaces and utilities to support new behaviors and improves robustness of tray icon creation and view realignment.
* Refactor file system sync to use $tw.syncer.syncFromServer()
Introduces FileSystemWatcher to monitor file changes and collect updates for the syncer, replacing direct wiki updates in WatchFileSystemAdaptor. Updates documentation to describe the new syncer-driven architecture, echo prevention, and event handling. WatchFileSystemAdaptor now delegates file change detection and lazy loading to FileSystemWatcher, improving batch change handling and eliminating echo loops.
* Improve logging and cleanup in file system watcher and git ops
Added detailed logging to WatchFileSystemAdaptor and FileSystemWatcher for better traceability during initialization and test stabilization. Introduced a constant for the temporary git index prefix in gitOperations. Removed the unused comparison.ts utility for tiddler comparison. Enhanced comments and logging for AI commit message generation context.
* Improve GitLog i18n test and config refresh logic
Updated gitLog.feature to use only Chinese selectors for actions, revert, and discard buttons, improving i18n test reliability. In FileSystemWatcher, re-fetch workspace config before checking enableFileSystemWatch to ensure latest settings are respected. In useGitLogData, prevent file-change events from overriding commit/undo events to maintain correct auto-selection behavior.
* Improve Git log selection and test stability
Refines auto-selection logic in the Git log window to better handle uncommitted changes, commits, reverts, and undos. Updates the feature test to explicitly verify selection and UI state after each operation, improving reliability. Removes unnecessary config re-fetch in FileSystemWatcher and enhances logging for more accurate DOM update detection.
* Implement workspace config sync via tidgi.config.json
Adds support for syncing workspace configuration to tidgi.config.json in the wiki folder, enabling settings persistence and migration across devices. Introduces new documentation, feature tests, and supporting utilities for config file reading, writing, migration, and validation. Updates step definitions and test helpers to support config sync scenarios, and refactors database config utilities for modularity.
* Improve workspace config handling and sync logic
Enhances workspace lookup in step definitions to check both settings.json and tidgi.config.json, ensuring properties are found even if moved. Updates tidgiConfig write logic to remove the config file if all values are default. Refactors workspace save logic to always write syncable config to tidgi.config.json for all wiki workspaces before removing those fields from settings.json, preventing config loss.
* Update .gitignore
* Update wiki.ts
* Add delay before waiting for git log render after revert
- Add 1 second wait after clearing git-log-data-rendered markers following revert
- This gives UI time to start refreshing before we check for the new marker
- Fixes CI timing issue where revert operation needs more time to trigger UI refresh
* Update test log markers for git log refresh events
Replaces '[test-id-git-log-data-rendered]' with '[test-id-git-log-refreshed]' in gitLog.feature to better reflect UI refresh events after commit and revert actions. Adds a debug log marker '[test-id-git-revert-complete]' in revertCommit for improved test synchronization.
* Fix git revert refresh timing - remove intermediate step and rely on git-log-refreshed
* Add detailed logging to handleRevert for CI debugging
* Fix git log refresh by adding manual triggerRefresh fallback
- Add triggerRefresh function to useGitLogData hook for manual refresh
- Call triggerRefresh in handleCommitSuccess, handleRevertSuccess, and handleUndoSuccess
- This fixes cross-process IPC observable subscription issues where gitStateChange$
notifications from main process may not reach renderer process reliably
- Add detailed logging to handleRevert for CI debugging
* Update index.tsx
731 lines
25 KiB
TypeScript
731 lines
25 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;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
if (this.boot.wikiTiddlersPath) {
|
|
this.watchPathBase = path.resolve(this.boot.wikiTiddlersPath);
|
|
} 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 = 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);
|
|
}
|
|
}
|