UNPKG

@workspace-fs/core

Version:

Multi-project workspace manager for Firesystem with support for multiple sources

358 lines (307 loc) 9.74 kB
import { IReactiveFileSystem, TypedEventEmitter, FileSystemEvents, } from "@firesystem/core"; import type { Project, ProjectConfig, ProjectSource, ProjectState, WorkspaceEventPayloads, } from "../types"; import type { SourceProvider } from "../interfaces/SourceProvider"; import { StoredProject } from "../WorkspaceDatabase"; import { CredentialManager } from "../credentials/CredentialManager"; export class ProjectManager { private projects = new Map<string, Project>(); private autoDisableTimers = new Map<string, NodeJS.Timeout>(); constructor( private events: TypedEventEmitter<WorkspaceEventPayloads>, private getProvider: (scheme: string) => SourceProvider | undefined, private credentialManager: CredentialManager, private saveProjectToDb: (project: StoredProject) => Promise<void>, private touchProjectInDb: (projectId: string) => Promise<void>, private estimateProjectMemoryUsage: (fs: IReactiveFileSystem) => Promise<number>, ) {} /** * Load a project into the workspace */ async loadProject(config: ProjectConfig): Promise<Project> { this.events.emit("project:loading", { projectId: config.id }); try { // Check if project already exists if (this.projects.has(config.id)) { throw new Error(`Project ${config.id} already loaded`); } // Get provider for this source type first const provider = this.getProvider(config.source.type); if (!provider) { throw new Error( `No provider registered for type: ${config.source.type}`, ); } // Validate configuration using provider's validator if (provider.validateConfiguration) { const validation = await provider.validateConfiguration( config.source.config, ); if (!validation.valid) { throw new Error( `Invalid source config: ${validation.errors?.join(", ") || "Validation failed"}`, ); } } // Get credentials for this source const credentials = await this.credentialManager.getCredentials( config.id, config.source, ); // Merge credentials with source config const sourceWithCredentials = { ...config.source, config: { ...config.source.config, ...credentials, }, }; // Create file system using provider const fs = await provider.createFileSystem(sourceWithCredentials.config); // Create project const project: Project = { id: config.id, name: config.name, source: config.source, fs, metadata: { created: new Date(), modified: new Date(), lastOpened: new Date(), ...config.metadata, }, state: "loaded", lastAccessed: new Date(), accessCount: 0, memoryUsage: await this.estimateProjectMemoryUsage(fs), }; // Setup event forwarding this.setupEventForwarding(project); // Store project this.projects.set(project.id, project); // Save to database const storedProject: StoredProject = { id: project.id, name: project.name, source: project.source, metadata: project.metadata, lastAccessed: new Date(), }; await this.saveProjectToDb(storedProject); // Emit loaded event this.events.emit("project:loaded", { project }); return project; } catch (error) { this.events.emit("project:error", { projectId: config.id, error: error as Error, }); throw error; } } /** * Unload a project from the workspace */ async unloadProject(projectId: string): Promise<void> { const project = this.projects.get(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } this.events.emit("project:unloading", { projectId }); // Clean up event listeners if (project.fs.events) { // Remove all listeners (implementation would need cleanup method) } // Remove from projects map this.projects.delete(projectId); this.events.emit("project:unloaded", { projectId }); } /** * Get a project by ID */ getProject(projectId: string): Project | null { return this.projects.get(projectId) || null; } /** * Get all loaded projects */ getProjects(): Project[] { return Array.from(this.projects.values()); } /** * Check if project exists */ hasProject(projectId: string): boolean { return this.projects.has(projectId); } /** * Update recent projects */ async updateRecentProjects(projectId: string, recentProjectIds: string[]): Promise<string[]> { const updatedRecent = [ projectId, ...recentProjectIds.filter((id) => id !== projectId), ].slice(0, 10); // Update database await this.touchProjectInDb(projectId); return updatedRecent; } /** * Track project access */ trackProjectAccess(project: Project): void { project.lastAccessed = new Date(); project.accessCount++; } /** * Reset auto-disable timer for a project */ resetAutoDisableTimer( projectId: string, autoDisableAfter: number | undefined, keepFocusedActive: boolean, activeProjectId: string | null, onAutoDisable: (projectId: string) => Promise<void>, ): void { // Clear existing timer const existingTimer = this.autoDisableTimers.get(projectId); if (existingTimer) { clearTimeout(existingTimer); } // Don't set timer for focused project if configured if (keepFocusedActive && activeProjectId === projectId) { return; } // Set new timer if auto-disable is configured if (autoDisableAfter && autoDisableAfter > 0) { const timer = setTimeout(() => { onAutoDisable(projectId).catch((error) => { console.error(`Failed to auto-disable project ${projectId}:`, error); }); }, autoDisableAfter); this.autoDisableTimers.set(projectId, timer); } } /** * Setup event forwarding from project to workspace */ private setupEventForwarding(project: Project): void { if (!project.fs.events) return; // Forward file system events with project context const events = [ FileSystemEvents.FILE_READING, FileSystemEvents.FILE_READ, FileSystemEvents.FILE_WRITING, FileSystemEvents.FILE_WRITTEN, FileSystemEvents.FILE_DELETING, FileSystemEvents.FILE_DELETED, FileSystemEvents.DIR_CREATING, FileSystemEvents.DIR_CREATED, FileSystemEvents.DIR_DELETING, FileSystemEvents.DIR_DELETED, ]; events.forEach((event) => { project.fs.events!.on(event, (payload: any) => { // Map to project-specific event const projectEvent = `project:${event.toLowerCase().replace(/_/g, ":")}` as keyof WorkspaceEventPayloads; // Add projectId to payload const enrichedPayload = { projectId: project.id, ...payload }; // Emit on workspace events (this.events as any).emit(projectEvent, enrichedPayload); }); }); } /** * Convert a project to a different source */ async convertProject( projectId: string, targetSource: ProjectSource, ): Promise<void> { const project = this.getProject(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } this.events.emit("project:converting", { projectId, targetSource }); // Get target provider const provider = this.getProvider(targetSource.type); if (!provider) { throw new Error(`No provider registered for type: ${targetSource.type}`); } // Create new file system const newFs = await provider.createFileSystem(targetSource.config); // Copy all files const paths = await project.fs.glob("**/*"); for (const path of paths) { const stat = await project.fs.stat(path); if (stat.type === "directory") { // Skip root directory if (path !== "/") { await newFs.mkdir(path, true); } } else { const file = await project.fs.readFile(path); await newFs.writeFile(path, file.content, file.metadata); } } // Replace project's file system project.fs = newFs; project.source = targetSource; // Re-setup event forwarding this.setupEventForwarding(project); this.events.emit("project:converted", { projectId, source: targetSource }); } /** * Load a project from stored configuration */ async loadProjectFromStored(stored: StoredProject): Promise<Project> { const config: ProjectConfig = { id: stored.id, name: stored.name, source: stored.source, metadata: stored.metadata, }; return this.loadProject(config); } /** * Recreate filesystem via provider */ async recreateFileSystem(stored: StoredProject): Promise<IReactiveFileSystem> { const provider = this.getProvider(stored.source.type); if (!provider) { throw new Error(`Provider ${stored.source.type} not registered`); } return provider.createFileSystem(stored.source.config); } /** * Create project from stored with filesystem */ createProjectFromStored( stored: StoredProject, fs: IReactiveFileSystem, ): Project { const project: Project = { id: stored.id, name: stored.name, source: stored.source, fs, metadata: stored.metadata || {}, state: "loaded" as ProjectState, lastAccessed: new Date(), accessCount: 0, memoryUsage: 0, }; this.projects.set(project.id, project); return project; } }