revert: revert gantt component

This commit is contained in:
Quorafind 2025-06-23 15:06:36 +08:00
parent d20df838de
commit 3a0dd7f4ff
9 changed files with 2349 additions and 410 deletions

View file

@ -10,6 +10,7 @@ module.exports = {
"<rootDir>/src/__mocks__/codemirror-language.ts",
"^@codemirror/search$": "<rootDir>/src/__mocks__/codemirror-search.ts",
"\\.(css|less|scss|sass)$": "<rootDir>/src/__mocks__/styleMock.js",
".*\\.worker$": "<rootDir>/src/__mocks__/ProjectData.worker.ts",
},
transform: {
"^.+\\.tsx?$": [

View file

@ -0,0 +1,47 @@
/**
* Mock for ProjectData.worker.ts in test environment
*/
export default class MockProjectDataWorker {
private messageHandler: ((event: MessageEvent) => void) | null = null;
constructor() {
// Mock worker constructor
}
postMessage(message: any) {
// Mock postMessage - simulate immediate response
setTimeout(() => {
if (this.messageHandler) {
const mockResponse = {
data: {
type: "projectDataResult",
requestId: message.requestId,
success: true,
data: {
filePath: message.filePath || "test.md",
tgProject: {
type: "test",
name: "Test Project",
source: "mock",
readonly: true,
},
enhancedMetadata: {},
timestamp: Date.now(),
},
},
};
this.messageHandler(mockResponse as MessageEvent);
}
}, 0);
}
set onmessage(handler: (event: MessageEvent) => void) {
this.messageHandler = handler;
}
terminate() {
// Mock terminate
this.messageHandler = null;
}
}

View file

@ -0,0 +1,198 @@
/**
* Test for ProjectDataWorkerManager
*/
import { ProjectDataWorkerManager } from "../utils/ProjectDataWorkerManager";
import { ProjectConfigManager } from "../utils/ProjectConfigManager";
import { Vault, MetadataCache } from "obsidian";
// Mock the worker
jest.mock("../utils/workers/ProjectData.worker");
describe("ProjectDataWorkerManager", () => {
let vault: Vault;
let metadataCache: MetadataCache;
let projectConfigManager: ProjectConfigManager;
let workerManager: ProjectDataWorkerManager;
beforeEach(() => {
vault = {
getAbstractFileByPath: jest.fn(),
read: jest.fn(),
} as any;
metadataCache = {
getFileCache: jest.fn(),
} as any;
projectConfigManager = new ProjectConfigManager({
vault,
metadataCache,
configFileName: "task-genius.config.md",
searchRecursively: true,
metadataKey: "project",
pathMappings: [],
metadataMappings: [],
defaultProjectNaming: {
strategy: "filename",
stripExtension: true,
enabled: false,
},
enhancedProjectEnabled: true,
});
workerManager = new ProjectDataWorkerManager({
vault,
metadataCache,
projectConfigManager,
maxWorkers: 2,
enableWorkers: true,
});
});
afterEach(() => {
workerManager.destroy();
});
describe("Worker Management", () => {
it("should initialize workers when enabled", () => {
expect(workerManager.isWorkersEnabled()).toBe(true);
const stats = workerManager.getMemoryStats();
expect(stats.workersEnabled).toBe(true);
});
it("should not initialize workers when disabled", () => {
const disabledWorkerManager = new ProjectDataWorkerManager({
vault,
metadataCache,
projectConfigManager,
maxWorkers: 2,
enableWorkers: false,
});
expect(disabledWorkerManager.isWorkersEnabled()).toBe(false);
const stats = disabledWorkerManager.getMemoryStats();
expect(stats.workersEnabled).toBe(false);
disabledWorkerManager.destroy();
});
it("should enable/disable workers dynamically", () => {
workerManager.setWorkersEnabled(false);
expect(workerManager.isWorkersEnabled()).toBe(false);
workerManager.setWorkersEnabled(true);
expect(workerManager.isWorkersEnabled()).toBe(true);
});
});
describe("Project Data Computation", () => {
it("should get project data using cache first", async () => {
const filePath = "test.md";
// Mock file metadata
(projectConfigManager.getFileMetadata as jest.Mock) = jest
.fn()
.mockReturnValue({
project: "Test Project",
});
const result = await workerManager.getProjectData(filePath);
expect(result).toBeDefined();
});
it("should handle batch project data requests", async () => {
const filePaths = ["test1.md", "test2.md", "test3.md"];
// Mock file metadata for all files
(projectConfigManager.getFileMetadata as jest.Mock) = jest
.fn()
.mockReturnValue({
project: "Test Project",
});
const results = await workerManager.getBatchProjectData(filePaths);
expect(results).toBeInstanceOf(Map);
});
it("should fallback to sync computation when workers fail", async () => {
// Disable workers to force sync computation
workerManager.setWorkersEnabled(false);
const filePath = "test.md";
// Mock the sync computation methods
(projectConfigManager.determineTgProject as jest.Mock) = jest
.fn()
.mockResolvedValue({
type: "test",
name: "Test Project",
source: "mock",
readonly: true,
});
(projectConfigManager.getEnhancedMetadata as jest.Mock) = jest
.fn()
.mockResolvedValue({
project: "Test Project",
});
const result = await workerManager.getProjectData(filePath);
expect(result).toBeDefined();
expect(result?.tgProject?.name).toBe("Test Project");
});
});
describe("Cache Management", () => {
it("should clear cache when requested", () => {
workerManager.clearCache();
const stats = workerManager.getCacheStats();
expect(stats).toBeDefined();
});
it("should handle file events", async () => {
const filePath = "test.md";
await workerManager.onFileCreated(filePath);
await workerManager.onFileModified(filePath);
await workerManager.onFileRenamed("old.md", "new.md");
workerManager.onFileDeleted(filePath);
// Should not throw errors
expect(true).toBe(true);
});
});
describe("Settings Management", () => {
it("should handle settings changes", () => {
workerManager.onSettingsChange();
expect(true).toBe(true);
});
it("should handle enhanced project setting changes", () => {
workerManager.onEnhancedProjectSettingChange(false);
workerManager.onEnhancedProjectSettingChange(true);
expect(true).toBe(true);
});
});
describe("Memory Management", () => {
it("should provide memory statistics", () => {
const stats = workerManager.getMemoryStats();
expect(stats).toHaveProperty("fileCacheSize");
expect(stats).toHaveProperty("directoryCacheSize");
expect(stats).toHaveProperty("pendingRequests");
expect(stats).toHaveProperty("activeWorkers");
expect(stats).toHaveProperty("workersEnabled");
});
it("should cleanup resources on destroy", () => {
const stats1 = workerManager.getMemoryStats();
workerManager.destroy();
const stats2 = workerManager.getMemoryStats();
expect(stats2.activeWorkers).toBe(0);
expect(stats2.pendingRequests).toBe(0);
});
});
});

View file

@ -0,0 +1,393 @@
/**
* Gantt Chart Virtualization Manager
*
* Handles virtual scrolling for grouped content, efficient group rendering,
* lazy loading of group content, and memory management for complex group hierarchies.
*/
import { Component } from "obsidian";
import { Task } from "../../types/task";
import { TaskGroup } from "../../types/gantt-grouping";
// Import GanttTaskItem from the main gantt file since it's not a grouping-specific type
interface GanttTaskItem {
task: Task;
y: number;
startX?: number;
endX?: number;
width?: number;
isMilestone: boolean;
level: number;
}
export interface VirtualizationConfig {
// Viewport settings
viewportHeight: number;
viewportWidth: number;
// Item dimensions
rowHeight: number;
groupHeaderHeight: number;
// Performance settings
overscanCount: number; // Number of items to render outside viewport
bufferSize: number; // Buffer size for smooth scrolling
lazyLoadThreshold: number; // Distance from viewport to start loading
// Memory management
maxCachedItems: number;
cleanupInterval: number; // Cleanup interval in ms
}
export interface VirtualItem {
index: number;
y: number;
height: number;
type: "task" | "group-header";
data: Task | TaskGroup;
groupId?: string;
level?: number;
visible: boolean;
}
export interface ViewportInfo {
startIndex: number;
endIndex: number;
visibleItems: VirtualItem[];
totalHeight: number;
scrollTop: number;
}
export class VirtualizationManager extends Component {
private config: VirtualizationConfig;
private virtualItems: VirtualItem[] = [];
private cachedItems: Map<number, VirtualItem> = new Map();
private viewportInfo: ViewportInfo;
private cleanupTimer: number | null = null;
// Performance tracking
private renderCount = 0;
private lastRenderTime = 0;
constructor(config: VirtualizationConfig) {
super();
this.config = config;
this.viewportInfo = {
startIndex: 0,
endIndex: 0,
visibleItems: [],
totalHeight: 0,
scrollTop: 0,
};
}
onload(): void {
this.startCleanupTimer();
}
onunload(): void {
this.stopCleanupTimer();
this.cachedItems.clear();
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<VirtualizationConfig>): void {
this.config = { ...this.config, ...newConfig };
this.invalidateCache();
}
/**
* Build virtual items from task groups
*/
buildVirtualItems(groups: TaskGroup[]): VirtualItem[] {
const items: VirtualItem[] = [];
let currentY = 0;
let index = 0;
const processGroup = (group: TaskGroup, level: number = 0) => {
// Add group header
const headerItem: VirtualItem = {
index: index++,
y: currentY,
height: this.config.groupHeaderHeight,
type: "group-header",
data: group,
groupId: group.id,
level: level,
visible: true,
};
items.push(headerItem);
currentY += this.config.groupHeaderHeight;
// Add tasks if group is expanded
if (group.expanded) {
// Process subgroups first
if (group.subGroups && group.subGroups.length > 0) {
for (const subGroup of group.subGroups) {
processGroup(subGroup, level + 1);
}
} else {
// Add tasks
for (const task of group.tasks) {
const taskItem: VirtualItem = {
index: index++,
y: currentY,
height: this.config.rowHeight,
type: "task",
data: task,
groupId: group.id,
level: level + 1,
visible: true,
};
items.push(taskItem);
currentY += this.config.rowHeight;
}
}
}
};
for (const group of groups) {
processGroup(group);
}
this.virtualItems = items;
this.viewportInfo.totalHeight = currentY;
return items;
}
/**
* Calculate visible items based on scroll position
*/
calculateVisibleItems(scrollTop: number): ViewportInfo {
const startTime = performance.now();
this.viewportInfo.scrollTop = scrollTop;
// Find start and end indices
const viewportStart = scrollTop;
const viewportEnd = scrollTop + this.config.viewportHeight;
// Add overscan
const overscanStart = Math.max(
0,
viewportStart - this.config.bufferSize
);
const overscanEnd = viewportEnd + this.config.bufferSize;
let startIndex = 0;
let endIndex = this.virtualItems.length - 1;
// Binary search for start index
let left = 0;
let right = this.virtualItems.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = this.virtualItems[mid];
if (item.y + item.height < overscanStart) {
left = mid + 1;
} else {
right = mid - 1;
startIndex = mid;
}
}
// Binary search for end index
left = startIndex;
right = this.virtualItems.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = this.virtualItems[mid];
if (item.y > overscanEnd) {
right = mid - 1;
} else {
left = mid + 1;
endIndex = mid;
}
}
// Add overscan items
startIndex = Math.max(0, startIndex - this.config.overscanCount);
endIndex = Math.min(
this.virtualItems.length - 1,
endIndex + this.config.overscanCount
);
// Get visible items
const visibleItems = this.virtualItems.slice(startIndex, endIndex + 1);
// Update viewport info
this.viewportInfo = {
startIndex,
endIndex,
visibleItems,
totalHeight: this.viewportInfo.totalHeight,
scrollTop,
};
// Performance tracking
this.renderCount++;
this.lastRenderTime = performance.now() - startTime;
return this.viewportInfo;
}
/**
* Get cached item or create new one
*/
getCachedItem(index: number): VirtualItem | null {
if (this.cachedItems.has(index)) {
return this.cachedItems.get(index)!;
}
if (index >= 0 && index < this.virtualItems.length) {
const item = this.virtualItems[index];
this.cacheItem(index, item);
return item;
}
return null;
}
/**
* Cache an item
*/
private cacheItem(index: number, item: VirtualItem): void {
// Implement LRU cache
if (this.cachedItems.size >= this.config.maxCachedItems) {
// Remove oldest item
const firstKey = this.cachedItems.keys().next().value;
this.cachedItems.delete(firstKey);
}
this.cachedItems.set(index, { ...item });
}
/**
* Invalidate cache
*/
private invalidateCache(): void {
this.cachedItems.clear();
}
/**
* Start cleanup timer
*/
private startCleanupTimer(): void {
this.cleanupTimer = window.setInterval(() => {
this.cleanup();
}, this.config.cleanupInterval);
}
/**
* Stop cleanup timer
*/
private stopCleanupTimer(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
/**
* Cleanup unused cached items
*/
private cleanup(): void {
const currentTime = Date.now();
const maxAge = 30000; // 30 seconds
// Remove items that are far from current viewport
const { startIndex, endIndex } = this.viewportInfo;
const keepRange = this.config.overscanCount * 2;
for (const [index] of this.cachedItems) {
if (
index < startIndex - keepRange ||
index > endIndex + keepRange
) {
this.cachedItems.delete(index);
}
}
}
/**
* Get performance metrics
*/
getPerformanceMetrics(): {
renderCount: number;
lastRenderTime: number;
cachedItemsCount: number;
virtualItemsCount: number;
averageRenderTime: number;
} {
return {
renderCount: this.renderCount,
lastRenderTime: this.lastRenderTime,
cachedItemsCount: this.cachedItems.size,
virtualItemsCount: this.virtualItems.length,
averageRenderTime:
this.renderCount > 0
? this.lastRenderTime / this.renderCount
: 0,
};
}
/**
* Reset performance counters
*/
resetPerformanceMetrics(): void {
this.renderCount = 0;
this.lastRenderTime = 0;
}
/**
* Get current viewport info
*/
getViewportInfo(): ViewportInfo {
return { ...this.viewportInfo };
}
/**
* Check if lazy loading should be triggered
*/
shouldLazyLoad(itemIndex: number): boolean {
const { startIndex, endIndex } = this.viewportInfo;
const distance = Math.min(
Math.abs(itemIndex - startIndex),
Math.abs(itemIndex - endIndex)
);
return distance <= this.config.lazyLoadThreshold;
}
/**
* Estimate total height without building all items
*/
estimateTotalHeight(groups: TaskGroup[]): number {
let totalHeight = 0;
const estimateGroup = (group: TaskGroup): number => {
let height = this.config.groupHeaderHeight;
if (group.expanded) {
if (group.subGroups && group.subGroups.length > 0) {
height += group.subGroups.reduce(
(sum, subGroup) => sum + estimateGroup(subGroup),
0
);
} else {
height += group.tasks.length * this.config.rowHeight;
}
}
return height;
};
for (const group of groups) {
totalHeight += estimateGroup(group);
}
return totalHeight;
}
}

View file

@ -616,4 +616,34 @@ export class ProjectConfigManager {
// Clear cache when options change
this.clearCache();
}
/**
* Get worker configuration for project data computation
*/
getWorkerConfig(): {
pathMappings: Array<{
pathPattern: string;
projectName: string;
enabled: boolean;
}>;
metadataMappings: MetadataMapping[];
defaultProjectNaming: ProjectNamingStrategy;
metadataKey: string;
} {
return {
pathMappings: this.pathMappings,
metadataMappings: this.metadataMappings,
defaultProjectNaming: this.defaultProjectNaming,
metadataKey: this.metadataKey,
};
}
/**
* Get project config data for a file (alias for getProjectConfig for compatibility)
*/
async getProjectConfigData(
filePath: string
): Promise<ProjectConfigData | null> {
return await this.getProjectConfig(filePath);
}
}

View file

@ -1,13 +1,16 @@
/**
* Enhanced Project Data Cache Manager
*
*
* Provides high-performance caching for project data with directory-level optimizations
* and batch processing capabilities to reduce main thread blocking.
*/
import { TFile, Vault, MetadataCache } from "obsidian";
import { TgProject } from "../types/task";
import { ProjectConfigData, ProjectConfigManager } from "./ProjectConfigManager";
import {
ProjectConfigData,
ProjectConfigManager,
} from "./ProjectConfigManager";
export interface CachedProjectData {
tgProject?: TgProject;
@ -35,25 +38,25 @@ export class ProjectDataCache {
private vault: Vault;
private metadataCache: MetadataCache;
private projectConfigManager: ProjectConfigManager;
// File-level cache for computed project data
private fileCache = new Map<string, CachedProjectData>();
// Directory-level cache for project config files
private directoryCache = new Map<string, DirectoryCache>();
// Batch processing optimization
private pendingUpdates = new Set<string>();
private batchUpdateTimer?: NodeJS.Timeout;
private readonly BATCH_DELAY = 100; // ms
// Performance tracking
private stats: ProjectCacheStats = {
totalFiles: 0,
cachedFiles: 0,
directoryCacheHits: 0,
configCacheHits: 0,
lastUpdateTime: 0
lastUpdateTime: 0,
};
constructor(
@ -85,9 +88,11 @@ export class ProjectDataCache {
/**
* Batch get project data for multiple files with optimizations
*/
async getBatchProjectData(filePaths: string[]): Promise<Map<string, CachedProjectData>> {
async getBatchProjectData(
filePaths: string[]
): Promise<Map<string, CachedProjectData>> {
const result = new Map<string, CachedProjectData>();
if (!this.projectConfigManager.isEnhancedProjectEnabled()) {
return result;
}
@ -111,15 +116,18 @@ export class ProjectDataCache {
// Process uncached files in batches by directory for efficiency
if (uncachedPaths.length > 0) {
const batchedByDirectory = this.groupByDirectory(uncachedPaths);
for (const [directory, paths] of batchedByDirectory) {
const directoryData = await this.getOrCreateDirectoryCache(directory);
const directoryData = await this.getOrCreateDirectoryCache(
directory
);
for (const filePath of paths) {
const projectData = await this.computeProjectDataWithDirectoryCache(
filePath,
directoryData
);
const projectData =
await this.computeProjectDataWithDirectoryCache(
filePath,
directoryData
);
if (projectData) {
result.set(filePath, projectData);
}
@ -142,37 +150,45 @@ export class ProjectDataCache {
directoryCache: DirectoryCache
): Promise<CachedProjectData | null> {
try {
const tgProject = await this.projectConfigManager.determineTgProject(filePath);
const tgProject =
await this.projectConfigManager.determineTgProject(filePath);
// Get enhanced metadata efficiently using cached config data
let enhancedMetadata: Record<string, any> = {};
// Get file metadata
const fileMetadata = this.projectConfigManager.getFileMetadata(filePath) || {};
const fileMetadata =
this.projectConfigManager.getFileMetadata(filePath) || {};
// Use cached config data if available
const configData = directoryCache.configData || {};
// Merge and apply mappings
const mergedMetadata = { ...configData, ...fileMetadata };
enhancedMetadata = this.projectConfigManager.applyMappingsToMetadata(mergedMetadata);
enhancedMetadata =
this.projectConfigManager.applyMappingsToMetadata(
mergedMetadata
);
const projectData: CachedProjectData = {
tgProject,
enhancedMetadata,
timestamp: Date.now(),
configSource: directoryCache.configFile?.path
configSource: directoryCache.configFile?.path,
};
// Cache the result
this.fileCache.set(filePath, projectData);
// Update directory cache file tracking
directoryCache.paths.add(filePath);
return projectData;
} catch (error) {
console.warn(`Failed to compute project data for ${filePath}:`, error);
console.warn(
`Failed to compute project data for ${filePath}:`,
error
);
return null;
}
}
@ -180,9 +196,11 @@ export class ProjectDataCache {
/**
* Get or create directory cache for project config files
*/
private async getOrCreateDirectoryCache(directory: string): Promise<DirectoryCache> {
private async getOrCreateDirectoryCache(
directory: string
): Promise<DirectoryCache> {
let cached = this.directoryCache.get(directory);
if (cached) {
// Check if cache is still valid
if (cached.configFile) {
@ -200,7 +218,7 @@ export class ProjectDataCache {
// Create new directory cache
cached = {
configTimestamp: 0,
paths: new Set()
paths: new Set(),
};
// Look for config file in this directory
@ -208,24 +226,27 @@ export class ProjectDataCache {
if (configFile) {
cached.configFile = configFile;
cached.configTimestamp = configFile.stat.mtime;
// Read and cache config data
try {
const content = await this.vault.read(configFile);
const metadata = this.metadataCache.getFileCache(configFile);
let configData: ProjectConfigData = {};
if (metadata?.frontmatter) {
configData = { ...metadata.frontmatter };
}
// Parse additional config content
const contentConfig = this.parseConfigContent(content);
configData = { ...configData, ...contentConfig };
cached.configData = configData;
} catch (error) {
console.warn(`Failed to read config file ${configFile.path}:`, error);
console.warn(
`Failed to read config file ${configFile.path}:`,
error
);
}
}
@ -236,15 +257,18 @@ export class ProjectDataCache {
/**
* Find project config file in a specific directory (non-recursive)
*/
private async findConfigFileInDirectory(directory: string): Promise<TFile | null> {
private async findConfigFileInDirectory(
directory: string
): Promise<TFile | null> {
const abstractFile = this.vault.getAbstractFileByPath(directory);
if (!abstractFile || !("children" in abstractFile)) {
return null;
}
const configFileName = "task-genius.config.md"; // TODO: Make configurable
const configFile = abstractFile.children.find(
(child: any) => child && child.name === configFileName && "stat" in child
const configFile = (abstractFile as any).children.find(
(child: any) =>
child && child.name === configFileName && "stat" in child
) as TFile | undefined;
return configFile || null;
@ -255,14 +279,14 @@ export class ProjectDataCache {
*/
private groupByDirectory(filePaths: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const filePath of filePaths) {
const directory = this.getDirectoryPath(filePath);
const existing = groups.get(directory) || [];
existing.push(filePath);
groups.set(directory, existing);
}
return groups;
}
@ -291,7 +315,9 @@ export class ProjectDataCache {
// Check if config file has been modified
if (cached.configSource) {
const configFile = this.vault.getAbstractFileByPath(cached.configSource);
const configFile = this.vault.getAbstractFileByPath(
cached.configSource
);
if (configFile && "stat" in configFile) {
const configTimestamp = (configFile as TFile).stat.mtime;
const directory = this.getDirectoryPath(filePath);
@ -308,10 +334,15 @@ export class ProjectDataCache {
/**
* Compute and cache project data for a single file
*/
private async computeAndCacheProjectData(filePath: string): Promise<CachedProjectData | null> {
private async computeAndCacheProjectData(
filePath: string
): Promise<CachedProjectData | null> {
const directory = this.getDirectoryPath(filePath);
const directoryCache = await this.getOrCreateDirectoryCache(directory);
return await this.computeProjectDataWithDirectoryCache(filePath, directoryCache);
return await this.computeProjectDataWithDirectoryCache(
filePath,
directoryCache
);
}
/**
@ -323,7 +354,11 @@ export class ProjectDataCache {
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) {
if (
!trimmed ||
trimmed.startsWith("#") ||
trimmed.startsWith("//")
) {
continue;
}
@ -348,7 +383,7 @@ export class ProjectDataCache {
clearCache(filePath?: string): void {
if (filePath) {
this.fileCache.delete(filePath);
// Clear from directory cache tracking
const directory = this.getDirectoryPath(filePath);
const dirCache = this.directoryCache.get(directory);
@ -434,9 +469,12 @@ export class ProjectDataCache {
async onFileModified(filePath: string): Promise<void> {
// Clear cache for the modified file
this.clearCache(filePath);
// Check if it's a project config file
if (filePath.endsWith('.config.md') || filePath.includes('task-genius')) {
if (
filePath.endsWith(".config.md") ||
filePath.includes("task-genius")
) {
// Clear directory cache since config may have changed
const directory = this.getDirectoryPath(filePath);
this.clearDirectoryCache(directory);
@ -451,9 +489,12 @@ export class ProjectDataCache {
*/
onFileDeleted(filePath: string): void {
this.clearCache(filePath);
// Update directory cache if it was a config file
if (filePath.endsWith('.config.md') || filePath.includes('task-genius')) {
if (
filePath.endsWith(".config.md") ||
filePath.includes("task-genius")
) {
const directory = this.getDirectoryPath(filePath);
this.clearDirectoryCache(directory);
}
@ -464,7 +505,10 @@ export class ProjectDataCache {
*/
async onFileCreated(filePath: string): Promise<void> {
// If it's a config file, clear directory cache to pick up new config
if (filePath.endsWith('.config.md') || filePath.includes('task-genius')) {
if (
filePath.endsWith(".config.md") ||
filePath.includes("task-genius")
) {
const directory = this.getDirectoryPath(filePath);
this.clearDirectoryCache(directory);
}
@ -479,16 +523,16 @@ export class ProjectDataCache {
async onFileRenamed(oldPath: string, newPath: string): Promise<void> {
// Clear cache for old path
this.clearCache(oldPath);
// Update relevant directory caches
const oldDirectory = this.getDirectoryPath(oldPath);
const newDirectory = this.getDirectoryPath(newPath);
if (oldPath.endsWith('.config.md') || oldPath.includes('task-genius')) {
if (oldPath.endsWith(".config.md") || oldPath.includes("task-genius")) {
this.clearDirectoryCache(oldDirectory);
}
if (newPath.endsWith('.config.md') || newPath.includes('task-genius')) {
if (newPath.endsWith(".config.md") || newPath.includes("task-genius")) {
this.clearDirectoryCache(newDirectory);
}
@ -501,7 +545,7 @@ export class ProjectDataCache {
*/
async refreshStaleEntries(): Promise<void> {
const staleFiles: string[] = [];
for (const [filePath, cachedData] of this.fileCache.entries()) {
if (!this.isCacheValid(filePath, cachedData)) {
staleFiles.push(filePath);
@ -509,7 +553,9 @@ export class ProjectDataCache {
}
if (staleFiles.length > 0) {
console.log(`Refreshing ${staleFiles.length} stale project data cache entries`);
console.log(
`Refreshing ${staleFiles.length} stale project data cache entries`
);
await this.getBatchProjectData(staleFiles);
}
}
@ -518,11 +564,32 @@ export class ProjectDataCache {
* Preload project data for recently accessed files
*/
async preloadRecentFiles(filePaths: string[]): Promise<void> {
const uncachedFiles = filePaths.filter(path => !this.fileCache.has(path));
const uncachedFiles = filePaths.filter(
(path) => !this.fileCache.has(path)
);
if (uncachedFiles.length > 0) {
console.log(`Preloading project data for ${uncachedFiles.length} recent files`);
console.log(
`Preloading project data for ${uncachedFiles.length} recent files`
);
await this.getBatchProjectData(uncachedFiles);
}
}
}
/**
* Set project data in cache (for external updates)
*/
async setProjectData(
filePath: string,
projectData: CachedProjectData
): Promise<void> {
this.fileCache.set(filePath, projectData);
// Update directory cache tracking
const directory = this.getDirectoryPath(filePath);
const dirCache = this.directoryCache.get(directory);
if (dirCache) {
dirCache.paths.add(filePath);
}
}
}

View file

@ -1,6 +1,6 @@
/**
* Project Data Worker Manager
*
*
* Manages project data computation workers to avoid blocking the main thread
* during startup and project data operations.
*/
@ -8,19 +8,23 @@
import { Vault, MetadataCache } from "obsidian";
import { ProjectConfigManager } from "./ProjectConfigManager";
import { ProjectDataCache, CachedProjectData } from "./ProjectDataCache";
import {
ProjectDataResponse,
WorkerResponse
import {
ProjectDataResponse,
WorkerResponse,
UpdateConfigMessage,
ProjectDataMessage,
BatchProjectDataMessage,
} from "./workers/TaskIndexWorkerMessage";
// Worker import temporarily disabled due to test compatibility issues
// import ProjectWorker from "./workers/ProjectData.worker";
// @ts-ignore Ignore type error for worker import
import ProjectWorker from "./workers/ProjectData.worker";
export interface ProjectDataWorkerManagerOptions {
vault: Vault;
metadataCache: MetadataCache;
projectConfigManager: ProjectConfigManager;
maxWorkers?: number;
enableWorkers?: boolean; // Add option to enable/disable workers
}
export class ProjectDataWorkerManager {
@ -28,21 +32,31 @@ export class ProjectDataWorkerManager {
private metadataCache: MetadataCache;
private projectConfigManager: ProjectConfigManager;
private cache: ProjectDataCache;
private workers: Worker[] = [];
private maxWorkers: number;
private enableWorkers: boolean;
private requestId = 0;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (error: any) => void;
}>();
private pendingRequests = new Map<
string,
{
resolve: (value: any) => void;
reject: (error: any) => void;
}
>();
// Worker round-robin index for load balancing
private currentWorkerIndex = 0;
constructor(options: ProjectDataWorkerManagerOptions) {
this.vault = options.vault;
this.metadataCache = options.metadataCache;
this.projectConfigManager = options.projectConfigManager;
this.maxWorkers = options.maxWorkers || Math.max(1, Math.floor(navigator.hardwareConcurrency / 2));
this.maxWorkers =
options.maxWorkers ||
Math.max(1, Math.floor(navigator.hardwareConcurrency / 2));
this.enableWorkers = options.enableWorkers ?? true;
this.cache = new ProjectDataCache(
this.vault,
this.metadataCache,
@ -54,24 +68,76 @@ export class ProjectDataWorkerManager {
/**
* Initialize worker pool
*
* TEMPORARY: Workers disabled to avoid build issues with tests.
* The system will use cache-only optimization which still provides
* significant performance improvements.
*/
private initializeWorkers(): void {
console.log("ProjectDataWorkerManager: Workers temporarily disabled, using cache-only optimization");
// Workers are temporarily disabled to avoid test failures
// The caching system still provides significant performance improvements
this.workers = [];
if (!this.enableWorkers) {
console.log(
"ProjectDataWorkerManager: Workers disabled, using cache-only optimization"
);
return;
}
try {
console.log(
`ProjectDataWorkerManager: Initializing ${this.maxWorkers} workers`
);
for (let i = 0; i < this.maxWorkers; i++) {
const worker = new ProjectWorker();
worker.onmessage = (event: MessageEvent) => {
this.handleWorkerMessage(event.data);
};
worker.onerror = (error: ErrorEvent) => {
console.error(`Worker ${i} error:`, error);
};
this.workers.push(worker);
}
// Send initial configuration to all workers
this.updateWorkerConfig();
console.log(
`ProjectDataWorkerManager: Successfully initialized ${this.workers.length} workers`
);
} catch (error) {
console.warn(
"ProjectDataWorkerManager: Failed to initialize workers, falling back to synchronous processing",
error
);
this.enableWorkers = false;
this.workers = [];
}
}
/**
* Update worker configuration when settings change (DISABLED)
* Update worker configuration when settings change
*/
private updateWorkerConfig(): void {
// Workers are disabled, no configuration needed
console.log("ProjectDataWorkerManager: Worker config update skipped (workers disabled)");
if (!this.enableWorkers || this.workers.length === 0) {
return;
}
const config = this.projectConfigManager.getWorkerConfig();
const configMessage: UpdateConfigMessage = {
type: "updateConfig",
requestId: this.generateRequestId(),
config,
};
// Send configuration to all workers
for (const worker of this.workers) {
try {
worker.postMessage(configMessage);
} catch (error) {
console.warn("Failed to update worker config:", error);
}
}
console.log("ProjectDataWorkerManager: Updated worker configuration");
}
/**
@ -84,28 +150,44 @@ export class ProjectDataWorkerManager {
return cached;
}
// Fallback to synchronous computation if not cached
return await this.computeProjectDataSync(filePath);
// Use worker if available, otherwise fallback to synchronous computation
if (this.enableWorkers && this.workers.length > 0) {
return await this.computeProjectDataWithWorker(filePath);
} else {
return await this.computeProjectDataSync(filePath);
}
}
/**
* Get project data for multiple files with batch optimization
*/
async getBatchProjectData(filePaths: string[]): Promise<Map<string, CachedProjectData>> {
async getBatchProjectData(
filePaths: string[]
): Promise<Map<string, CachedProjectData>> {
if (!this.projectConfigManager.isEnhancedProjectEnabled()) {
return new Map();
}
// Use cache first for batch operation
const cacheResult = await this.cache.getBatchProjectData(filePaths);
// Find files that weren't in cache
const missingPaths = filePaths.filter(path => !cacheResult.has(path));
const missingPaths = filePaths.filter((path) => !cacheResult.has(path));
if (missingPaths.length > 0) {
// Compute missing data using workers
const workerResults = await this.computeBatchProjectDataWithWorkers(missingPaths);
// Compute missing data using workers or fallback
let workerResults: Map<string, CachedProjectData>;
if (this.enableWorkers && this.workers.length > 0) {
workerResults = await this.computeBatchProjectDataWithWorkers(
missingPaths
);
} else {
workerResults = await this.computeBatchProjectDataSync(
missingPaths
);
}
// Merge results
for (const [path, data] of workerResults) {
cacheResult.set(path, data);
@ -116,28 +198,260 @@ export class ProjectDataWorkerManager {
}
/**
* Compute project data for multiple files using fallback synchronous method
*
* TEMPORARY: Using synchronous fallback since workers are disabled
* Compute project data using worker
*/
private async computeBatchProjectDataWithWorkers(filePaths: string[]): Promise<Map<string, CachedProjectData>> {
private async computeProjectDataWithWorker(
filePath: string
): Promise<CachedProjectData | null> {
try {
const fileMetadata =
await this.projectConfigManager.getFileMetadata(filePath);
const configData =
await this.projectConfigManager.getProjectConfigData(filePath);
const worker = this.getNextWorker();
const requestId = this.generateRequestId();
const message: ProjectDataMessage = {
type: "computeProjectData",
requestId,
filePath,
fileMetadata: fileMetadata || {},
configData: configData || {},
};
const response = await this.sendWorkerMessage(worker, message);
if (response.success && response.data) {
const projectData: CachedProjectData = {
tgProject: response.data.tgProject,
enhancedMetadata: response.data.enhancedMetadata,
timestamp: response.data.timestamp,
};
// Cache the result
await this.cache.setProjectData(filePath, projectData);
return projectData;
} else {
throw new Error(response.error || "Worker computation failed");
}
} catch (error) {
console.warn(
`Failed to compute project data with worker for ${filePath}:`,
error
);
// Fallback to synchronous computation
return await this.computeProjectDataSync(filePath);
}
}
/**
* Compute project data for multiple files using workers
*/
private async computeBatchProjectDataWithWorkers(
filePaths: string[]
): Promise<Map<string, CachedProjectData>> {
const result = new Map<string, CachedProjectData>();
console.log(`ProjectDataWorkerManager: Computing project data for ${filePaths.length} files using fallback method`);
if (filePaths.length === 0) {
return result;
}
console.log(
`ProjectDataWorkerManager: Computing project data for ${filePaths.length} files using ${this.workers.length} workers`
);
try {
// Prepare file data for workers
const files = await Promise.all(
filePaths.map(async (filePath) => {
const fileMetadata =
await this.projectConfigManager.getFileMetadata(
filePath
);
const configData =
await this.projectConfigManager.getProjectConfigData(
filePath
);
return {
filePath,
fileMetadata: fileMetadata || {},
configData: configData || {},
};
})
);
// Distribute files across workers
const filesPerWorker = Math.ceil(
files.length / this.workers.length
);
const workerPromises: Promise<ProjectDataResponse[]>[] = [];
for (let i = 0; i < this.workers.length; i++) {
const startIndex = i * filesPerWorker;
const endIndex = Math.min(
startIndex + filesPerWorker,
files.length
);
const workerFiles = files.slice(startIndex, endIndex);
if (workerFiles.length > 0) {
workerPromises.push(
this.sendBatchRequestToWorker(i, workerFiles)
);
}
}
// Wait for all workers to complete
const workerResults = await Promise.all(workerPromises);
// Process results
for (const batchResults of workerResults) {
for (const response of batchResults) {
if (!response.error) {
const projectData: CachedProjectData = {
tgProject: response.tgProject,
enhancedMetadata: response.enhancedMetadata,
timestamp: response.timestamp,
};
result.set(response.filePath, projectData);
// Cache the result
await this.cache.setProjectData(
response.filePath,
projectData
);
} else {
console.warn(
`Worker failed to process ${response.filePath}:`,
response.error
);
}
}
}
console.log(
`ProjectDataWorkerManager: Successfully processed ${result.size}/${filePaths.length} files with workers`
);
} catch (error) {
console.warn(
"Failed to compute batch project data with workers:",
error
);
// Fallback to synchronous computation
return await this.computeBatchProjectDataSync(filePaths);
}
return result;
}
/**
* Send batch request to a specific worker
*/
private async sendBatchRequestToWorker(
workerIndex: number,
files: any[]
): Promise<ProjectDataResponse[]> {
const worker = this.workers[workerIndex];
const requestId = this.generateRequestId();
const message: BatchProjectDataMessage = {
type: "computeBatchProjectData",
requestId,
files,
};
const response = await this.sendWorkerMessage(worker, message);
if (response.success && response.data) {
return response.data;
} else {
throw new Error(
response.error || "Batch worker computation failed"
);
}
}
/**
* Send message to worker and wait for response
*/
private async sendWorkerMessage(
worker: Worker,
message: any
): Promise<WorkerResponse> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(message.requestId);
reject(new Error("Worker request timeout"));
}, 30000); // 30 second timeout
this.pendingRequests.set(message.requestId, {
resolve: (response) => {
clearTimeout(timeout);
resolve(response);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
try {
worker.postMessage(message);
} catch (error) {
clearTimeout(timeout);
this.pendingRequests.delete(message.requestId);
reject(error);
}
});
}
/**
* Get next worker for round-robin load balancing
*/
private getNextWorker(): Worker {
if (this.workers.length === 0) {
throw new Error("No workers available");
}
const worker = this.workers[this.currentWorkerIndex];
this.currentWorkerIndex =
(this.currentWorkerIndex + 1) % this.workers.length;
return worker;
}
/**
* Compute project data for multiple files using synchronous fallback
*/
private async computeBatchProjectDataSync(
filePaths: string[]
): Promise<Map<string, CachedProjectData>> {
const result = new Map<string, CachedProjectData>();
console.log(
`ProjectDataWorkerManager: Computing project data for ${filePaths.length} files using fallback method`
);
// Process files in parallel using Promise.all for better performance than sequential
const dataPromises = filePaths.map(async (filePath) => {
try {
const data = await this.computeProjectDataSync(filePath);
return { filePath, data };
} catch (error) {
console.warn(`Failed to compute project data for ${filePath}:`, error);
console.warn(
`Failed to compute project data for ${filePath}:`,
error
);
return { filePath, data: null };
}
});
const results = await Promise.all(dataPromises);
for (const { filePath, data } of results) {
if (data) {
result.set(filePath, data);
@ -147,30 +461,33 @@ export class ProjectDataWorkerManager {
return result;
}
/**
* Send batch request to a specific worker (DISABLED)
*/
private async sendBatchRequestToWorker(workerIndex: number, files: any[]): Promise<ProjectDataResponse[]> {
throw new Error("Workers are temporarily disabled");
}
/**
* Compute project data synchronously (fallback)
*/
private async computeProjectDataSync(filePath: string): Promise<CachedProjectData | null> {
private async computeProjectDataSync(
filePath: string
): Promise<CachedProjectData | null> {
try {
const tgProject = await this.projectConfigManager.determineTgProject(filePath);
const enhancedMetadata = await this.projectConfigManager.getEnhancedMetadata(filePath);
const tgProject =
await this.projectConfigManager.determineTgProject(filePath);
const enhancedMetadata =
await this.projectConfigManager.getEnhancedMetadata(filePath);
const data: CachedProjectData = {
tgProject,
enhancedMetadata,
timestamp: Date.now()
timestamp: Date.now(),
};
// Cache the result
await this.cache.setProjectData(filePath, data);
return data;
} catch (error) {
console.warn(`Failed to compute project data for ${filePath}:`, error);
console.warn(
`Failed to compute project data for ${filePath}:`,
error
);
return null;
}
}
@ -187,9 +504,11 @@ export class ProjectDataWorkerManager {
this.pendingRequests.delete(message.requestId);
if (message.success) {
pendingRequest.resolve(message.data);
pendingRequest.resolve(message);
} else {
pendingRequest.reject(new Error(message.error || 'Unknown worker error'));
pendingRequest.reject(
new Error(message.error || "Unknown worker error")
);
}
}
@ -200,7 +519,6 @@ export class ProjectDataWorkerManager {
return `req_${++this.requestId}_${Date.now()}`;
}
/**
* Clear cache
*/
@ -228,6 +546,35 @@ export class ProjectDataWorkerManager {
*/
onEnhancedProjectSettingChange(enabled: boolean): void {
this.cache.onEnhancedProjectSettingChange(enabled);
// Reinitialize workers if needed
if (enabled && this.enableWorkers && this.workers.length === 0) {
this.initializeWorkers();
}
}
/**
* Enable or disable workers
*/
setWorkersEnabled(enabled: boolean): void {
if (this.enableWorkers === enabled) {
return;
}
this.enableWorkers = enabled;
if (enabled) {
this.initializeWorkers();
} else {
this.destroy();
}
}
/**
* Check if workers are enabled and available
*/
isWorkersEnabled(): boolean {
return this.enableWorkers && this.workers.length > 0;
}
/**
@ -292,12 +639,14 @@ export class ProjectDataWorkerManager {
directoryCacheSize: number;
pendingRequests: number;
activeWorkers: number;
workersEnabled: boolean;
} {
return {
fileCacheSize: (this.cache as any).fileCache?.size || 0,
directoryCacheSize: (this.cache as any).directoryCache?.size || 0,
pendingRequests: this.pendingRequests.size,
activeWorkers: this.workers.length
activeWorkers: this.workers.length,
workersEnabled: this.enableWorkers,
};
}
@ -307,14 +656,18 @@ export class ProjectDataWorkerManager {
destroy(): void {
// Terminate all workers
for (const worker of this.workers) {
worker.terminate();
try {
worker.terminate();
} catch (error) {
console.warn("Error terminating worker:", error);
}
}
this.workers = [];
// Clear pending requests
for (const { reject } of this.pendingRequests.values()) {
reject(new Error('Worker manager destroyed'));
reject(new Error("Worker manager destroyed"));
}
this.pendingRequests.clear();
}
}
}

View file

@ -3,3 +3,9 @@ declare module "*/TaskIndex.worker" {
const WorkerFactory: new () => Worker;
export default WorkerFactory;
}
/** @hidden */
declare module "*/ProjectData.worker" {
const WorkerFactory: new () => Worker;
export default WorkerFactory;
}

1420
styles.css

File diff suppressed because one or more lines are too long