Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,9 @@ export default class TaskNotesPlugin extends Plugin {
// Initialize native cache system (lightweight - no index building)
this.cacheManager.initialize();

// Set up cache event bridge to trigger view refreshes
this.setupCacheEventBridge();

// Initialize FilterService and set up event listeners (lightweight)
this.filterService.initialize();

Expand All @@ -502,6 +505,9 @@ export default class TaskNotesPlugin extends Plugin {
// Initialize notification service
await this.notificationService.initialize();

// Initialize project subtasks service with event listeners
this.projectSubtasksService.initialize();

// Build project status cache for better TaskCard performance
await this.projectSubtasksService.buildProjectStatusCache();

Expand Down Expand Up @@ -697,6 +703,40 @@ export default class TaskNotesPlugin extends Plugin {
await this.readyPromise;
}

/**
* Set up cache event bridge to propagate cache events to plugin emitter
* This ensures views refresh when task identification changes (issue #953)
*/
private setupCacheEventBridge(): void {
// Bridge file-updated events to EVENT_DATA_CHANGED
this.registerEvent(
this.cacheManager.on("file-updated", () => {
// Use requestAnimationFrame for better UI timing
requestAnimationFrame(() => {
this.emitter.trigger(EVENT_DATA_CHANGED);
});
})
);

// Bridge file-deleted events to EVENT_DATA_CHANGED
this.registerEvent(
this.cacheManager.on("file-deleted", () => {
requestAnimationFrame(() => {
this.emitter.trigger(EVENT_DATA_CHANGED);
});
})
);

// Bridge file-renamed events to EVENT_DATA_CHANGED
this.registerEvent(
this.cacheManager.on("file-renamed", () => {
requestAnimationFrame(() => {
this.emitter.trigger(EVENT_DATA_CHANGED);
});
})
);
}

/**
* Set up event listeners for status bar updates
*/
Expand Down
32 changes: 31 additions & 1 deletion src/services/ProjectSubtasksService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ export class ProjectSubtasksService {
this.plugin = plugin;
}

/**
* Initialize the service and set up event listeners for cache invalidation
*/
initialize(): void {
// Listen to cache events to invalidate the project index when files change
this.plugin.cacheManager.on("file-updated", () => {
this.invalidateIndex();
});

this.plugin.cacheManager.on("file-deleted", () => {
this.invalidateIndex();
});

this.plugin.cacheManager.on("file-renamed", () => {
this.invalidateIndex();
});
}

/**
* Invalidate the project index to force a rebuild on next access
*/
private invalidateIndex(): void {
this.indexLastBuilt = 0;
}

/**
* Get all files that link to a specific project using native resolvedLinks API
* resolvedLinks format: Record<sourcePath, Record<targetPath, linkCount>>
Expand Down Expand Up @@ -191,9 +216,14 @@ export class ProjectSubtasksService {
// Check if source has projects frontmatter
const metadata = this.plugin.app.metadataCache.getCache(sourcePath);

// Validate that the source file is actually a task (issue #953)
// Only tasks should be able to create project relationships
if (!metadata?.frontmatter) continue;
if (!this.plugin.cacheManager.isTaskFile(metadata.frontmatter)) continue;

// Use the user's configured field mapping for projects
const projectsFieldName = this.plugin.fieldMapper.toUserField("projects");
const projects = metadata?.frontmatter?.[projectsFieldName];
const projects = metadata.frontmatter[projectsFieldName];

if (
Array.isArray(projects) &&
Expand Down
28 changes: 22 additions & 6 deletions src/utils/MinimalNativeCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ export class MinimalNativeCache extends Events {

/**
* Check if a file is a task based on current settings
* Public to allow ProjectSubtasksService to validate task files (issue #953)
*/
private isTaskFile(frontmatter: any): boolean {
isTaskFile(frontmatter: any): boolean {
if (!frontmatter) return false;

if (this.settings.taskIdentificationMethod === "property") {
Expand Down Expand Up @@ -262,6 +263,9 @@ export class MinimalNativeCache extends Events {
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter) return null;

// Validate that the file is actually a task based on identification settings
if (!this.isTaskFile(metadata.frontmatter)) return null;

return this.extractTaskInfoFromNative(path, metadata.frontmatter);
}

Expand Down Expand Up @@ -1344,16 +1348,20 @@ export class MinimalNativeCache extends Events {
* Debounced version of handleFileChanged to prevent excessive updates during typing
*/
private handleFileChangedDebounced(file: TFile, cache: any): void {
// Early exit: Only process files that are potentially task files
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter || !this.isTaskFile(metadata.frontmatter)) {
const currentFrontmatter = metadata?.frontmatter;
const lastKnownFrontmatter = this.lastKnownFrontmatter.get(file.path);

// Check if this file was previously a task (even if it's not anymore)
const wasTask = this.lastKnownTaskInfo.has(file.path);
const isTask = currentFrontmatter && this.isTaskFile(currentFrontmatter);

// Early exit: Only process if file is currently a task OR was previously a task
if (!isTask && !wasTask) {
return;
}

// Check if frontmatter actually changed by comparing raw frontmatter objects
const currentFrontmatter = metadata.frontmatter;
const lastKnownFrontmatter = this.lastKnownFrontmatter.get(file.path);

if (this.frontmatterEquals(currentFrontmatter, lastKnownFrontmatter)) {
// Frontmatter hasn't changed - this was likely just a content change or mtime update
return;
Expand Down Expand Up @@ -1402,6 +1410,10 @@ export class MinimalNativeCache extends Events {
const metadata = this.app.metadataCache.getFileCache(file);
if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) {
await this.indexTaskFile(file, metadata.frontmatter);
} else {
// File is no longer a task - clean up cached data (issue #953)
this.lastKnownTaskInfo.delete(file.path);
this.lastKnownFrontmatter.delete(file.path);
}

this.trigger("file-updated", { path: file.path, file });
Expand Down Expand Up @@ -1644,6 +1656,10 @@ export class MinimalNativeCache extends Events {
private extractTaskInfoFromNative(path: string, frontmatter: any): TaskInfo | null {
if (!this.fieldMapper) return null;

// Validate that the file is actually a task based on identification settings
// This ensures we return null when a file stops being a task
if (!this.isTaskFile(frontmatter)) return null;

try {
const mappedTask = this.fieldMapper.mapFromFrontmatter(
frontmatter,
Expand Down
163 changes: 163 additions & 0 deletions tests/unit/main.cacheEventBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Tests for cache event bridging to plugin emitter
* Ensures that cache events trigger EVENT_DATA_CHANGED for view refreshes
*/

import { EVENT_DATA_CHANGED } from '../../src/types';

describe('Main Plugin - Cache Event Bridge', () => {
let mockPlugin: any;
let mockCacheManager: any;
let eventDataChangedTriggered: boolean;

beforeEach(() => {
eventDataChangedTriggered = false;

// Create a mock cache manager with event emitter capabilities
mockCacheManager = {
on: jest.fn(),
off: jest.fn(),
trigger: jest.fn(),
initialize: jest.fn(),
clearAllCaches: jest.fn(),
updateConfig: jest.fn(),
};

// Create a mock plugin with emitter
mockPlugin = {
cacheManager: mockCacheManager,
emitter: {
on: jest.fn(),
trigger: jest.fn((eventName: string) => {
if (eventName === EVENT_DATA_CHANGED) {
eventDataChangedTriggered = true;
}
}),
},
registerEvent: jest.fn(),
};
});

describe('Event Bridging Setup', () => {
test('should set up listener for file-updated events from cache manager', () => {
// This test verifies that the plugin sets up a listener for cache events
// The actual implementation will be in main.ts setupCacheEventBridge()

// Simulate what the plugin should do during initialization
const setupCacheEventBridge = (plugin: any) => {
plugin.cacheManager.on('file-updated', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
};

// Act
setupCacheEventBridge(mockPlugin);

// Assert
expect(mockCacheManager.on).toHaveBeenCalledWith(
'file-updated',
expect.any(Function)
);
});

test('should trigger EVENT_DATA_CHANGED when file-updated event fires', () => {
// Arrange
let fileUpdatedCallback: Function | null = null;
mockCacheManager.on.mockImplementation((eventName: string, callback: Function) => {
if (eventName === 'file-updated') {
fileUpdatedCallback = callback;
}
});

const setupCacheEventBridge = (plugin: any) => {
plugin.cacheManager.on('file-updated', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
};

setupCacheEventBridge(mockPlugin);

// Act - Simulate cache manager triggering file-updated
if (fileUpdatedCallback) {
fileUpdatedCallback({ path: 'test.md' });
}

// Assert
expect(eventDataChangedTriggered).toBe(true);
});

test('should set up listener for file-deleted events from cache manager', () => {
const setupCacheEventBridge = (plugin: any) => {
plugin.cacheManager.on('file-deleted', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
};

// Act
setupCacheEventBridge(mockPlugin);

// Assert
expect(mockCacheManager.on).toHaveBeenCalledWith(
'file-deleted',
expect.any(Function)
);
});

test('should set up listener for file-renamed events from cache manager', () => {
const setupCacheEventBridge = (plugin: any) => {
plugin.cacheManager.on('file-renamed', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
};

// Act
setupCacheEventBridge(mockPlugin);

// Assert
expect(mockCacheManager.on).toHaveBeenCalledWith(
'file-renamed',
expect.any(Function)
);
});
});

describe('Event Bridging Behavior', () => {
test('should trigger EVENT_DATA_CHANGED for all cache events', () => {
// Arrange
const callbacks: { [key: string]: Function } = {};
mockCacheManager.on.mockImplementation((eventName: string, callback: Function) => {
callbacks[eventName] = callback;
});

const setupCacheEventBridge = (plugin: any) => {
plugin.cacheManager.on('file-updated', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
plugin.cacheManager.on('file-deleted', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
plugin.cacheManager.on('file-renamed', () => {
plugin.emitter.trigger(EVENT_DATA_CHANGED);
});
};

setupCacheEventBridge(mockPlugin);

// Act & Assert - file-updated
eventDataChangedTriggered = false;
callbacks['file-updated']({ path: 'test.md' });
expect(eventDataChangedTriggered).toBe(true);

// Act & Assert - file-deleted
eventDataChangedTriggered = false;
callbacks['file-deleted']({ path: 'test.md' });
expect(eventDataChangedTriggered).toBe(true);

// Act & Assert - file-renamed
eventDataChangedTriggered = false;
callbacks['file-renamed']({ oldPath: 'old.md', newPath: 'new.md' });
expect(eventDataChangedTriggered).toBe(true);
});
});
});

Loading
Loading