UNPKG

@workspace-fs/core

Version:

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

924 lines (784 loc) 25.4 kB
import { IReactiveFileSystem, FileEntry, FileStat, FSEvent, Disposable, TypedEventEmitter, } from "@firesystem/core"; import type { Project, ProjectConfig, ProjectSource, WorkspaceConfig, WorkspaceSettings, WorkspaceStats, ProjectMetrics, SyncOptions, ProjectDiff, OptimizationReport, WorkspaceEventPayloads, } from "./types"; /** * Options for deleting a project */ export interface DeleteProjectOptions { deleteData?: boolean; skipConfirmation?: boolean; } import type { SourceProvider, ProviderRegistry, } from "./interfaces/SourceProvider"; import { WorkspaceDatabase, StoredProject, } from "./WorkspaceDatabase"; import { WorkspaceImporter } from "./import-export/WorkspaceImporter"; import { WorkspaceExporter, ExportOptions, } from "./import-export/WorkspaceExporter"; import { CredentialManager } from "./credentials/CredentialManager"; // Import managers import { ProjectManager } from "./managers/ProjectManager"; import { PerformanceManager } from "./managers/PerformanceManager"; import { PersistenceManager } from "./managers/PersistenceManager"; import { EventManager } from "./managers/EventManager"; import { ProjectOperations } from "./operations/ProjectOperations"; import { FileSystemProxy } from "./operations/FileSystemProxy"; /** * Multi-project workspace manager for Firesystem */ export class WorkspaceFileSystem implements IReactiveFileSystem, ProviderRegistry { private activeProjectId: string | null = null; private providers = new Map<string, SourceProvider>(); private database: WorkspaceDatabase; private credentialManager: CredentialManager; private recentProjectIds: string[] = []; private settings: WorkspaceSettings = { maxActiveProjects: 10, autoDisableAfter: 30 * 60 * 1000, // 30 minutes keepFocusedActive: true, autoSave: false, autoSaveInterval: 60000, // 1 minute memoryThreshold: 500 * 1024 * 1024, // 500MB }; // Managers private projectManager: ProjectManager; private performanceManager: PerformanceManager; private persistenceManager: PersistenceManager; private eventManager: EventManager; private projectOperations: ProjectOperations; private fileSystemProxy: FileSystemProxy; /** * Event system for workspace events */ public readonly events = new TypedEventEmitter<WorkspaceEventPayloads>(); constructor(config?: WorkspaceConfig) { // Initialize database this.database = new WorkspaceDatabase(); // Initialize credential manager this.credentialManager = new CredentialManager(); // Apply settings if provided if (config?.settings) { this.settings = { ...this.settings, ...config.settings }; } // Initialize managers this.projectManager = new ProjectManager( this.events, (scheme) => this.getProvider(scheme), this.credentialManager, (project) => this.persistenceManager.saveProject(project), (projectId) => this.persistenceManager.touchProject(projectId), (fs) => this.performanceManager.estimateProjectMemoryUsage(fs), ); this.performanceManager = new PerformanceManager( this.settings, () => this.projectManager.getProjects(), () => this.activeProjectId, (projectId) => this.disableProject(projectId), ); this.persistenceManager = new PersistenceManager( this.database, (projectId) => this.isProjectAccessible(projectId), ); this.eventManager = new EventManager(this.events); this.projectOperations = new ProjectOperations( (projectId) => this.projectManager.getProject(projectId), this.events, ); this.fileSystemProxy = new FileSystemProxy( () => this.getActiveProject(), (project) => this.projectManager.trackProjectAccess(project), (projectId) => this.projectManager.resetAutoDisableTimer( projectId, this.settings.autoDisableAfter, this.settings.keepFocusedActive || false, this.activeProjectId, (id) => this.disableProject(id), ), ); } /** * Initialize workspace with optional config */ async initialize(config?: WorkspaceConfig): Promise<void> { this.events.emit("workspace:initializing", undefined); // Open database await this.persistenceManager.open(); // Try to restore previous state const { state, activeProjectToLoad } = await this.persistenceManager.restoreWorkspaceState(); if (state) { // Restore settings (but constructor settings take precedence) if (state.settings) { this.settings = { ...state.settings, ...this.settings }; this.performanceManager.updateSettings(this.settings); } // Restore recent projects this.recentProjectIds = state.recentProjectIds || []; // Load active project if still valid if (activeProjectToLoad) { try { await this.projectManager.loadProjectFromStored(activeProjectToLoad); this.activeProjectId = state.activeProjectId; } catch (error) { console.warn("Failed to restore active project:", error); // Remove active project reference if it can't be loaded this.activeProjectId = null; await this.saveWorkspaceState(); } } } if (config) { // Load projects from config for (const projectConfig of config.projects) { await this.loadProject(projectConfig); } // Set active project if (config.activeProjectId) { await this.setActiveProject(config.activeProjectId); } } this.events.emit("workspace:initialized", { projectCount: this.projectManager.getProjects().length, }); } /** * Load a project into the workspace */ async loadProject(config: ProjectConfig): Promise<Project> { const project = await this.projectManager.loadProject(config); // If no active project, set this as active if (!this.activeProjectId) { await this.setActiveProject(project.id); } return project; } /** * Unload a project from the workspace */ async unloadProject(projectId: string): Promise<void> { // If this is the active project, deactivate it if (this.activeProjectId === projectId) { this.activeProjectId = null; this.events.emit("project:deactivated", { projectId }); } await this.projectManager.unloadProject(projectId); } /** * Get a project by ID */ getProject(projectId: string): Project | null { return this.projectManager.getProject(projectId); } /** * Get all loaded projects */ getProjects(): Project[] { return this.projectManager.getProjects(); } /** * Set the active project */ async setActiveProject(projectId: string): Promise<void> { const project = this.projectManager.getProject(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } const previousId = this.activeProjectId; this.activeProjectId = projectId; // Update recent projects this.recentProjectIds = await this.projectManager.updateRecentProjects( projectId, this.recentProjectIds, ); // Save state await this.saveWorkspaceState(); this.events.emit("project:activated", { projectId, previousId: previousId || undefined, }); } /** * Get the active project */ getActiveProject(): Project | null { if (!this.activeProjectId) return null; return this.projectManager.getProject(this.activeProjectId); } /** * Disable a project (unload but keep configuration) */ async disableProject(projectId: string): Promise<void> { const project = this.projectManager.getProject(projectId); if (!project) return; // Don't disable the focused project if configured if (this.settings.keepFocusedActive && this.activeProjectId === projectId) { throw new Error( "Cannot disable the focused project when keepFocusedActive is true", ); } this.events.emit("project:disabling", { projectId }); // Store provider info before unloading const provider = this.getProvider(project.source.type); const hasLocalData = provider?.hasLocalData?.() || false; // Check if this is the active project const wasActive = this.activeProjectId === projectId; // 1. Just unload from memory await this.unloadProject(projectId); // 2. Mark as disabled in database await this.persistenceManager.updateProjectState(projectId, { enabled: false, disabledAt: new Date(), }); // 3. If this was the active project, switch to another one if (wasActive) { const remainingProjects = this.projectManager.getProjects(); if (remainingProjects.length > 0) { await this.setActiveProject(remainingProjects[0].id); } } // 4. Emit event with useful information this.events.emit("project:disabled", { projectId, hasLocalData, reason: "manual", }); } /** * Enable a disabled project */ async enableProject(projectId: string): Promise<void> { // If project is already enabled, return silently if (this.projectManager.hasProject(projectId)) { return; } const storedProject = await this.persistenceManager.getProject(projectId); if (!storedProject) { throw new Error(`Project ${projectId} not found in database`); } // 1. Recreate filesystem via provider const fs = await this.projectManager.recreateFileSystem(storedProject); // 2. Reload in memory const project = this.projectManager.createProjectFromStored(storedProject, fs); // 3. Update state in database await this.persistenceManager.updateProjectState(projectId, { enabled: true, enabledAt: new Date(), }); this.events.emit("project:enabled", { projectId }); } /** * Batch disable projects */ async disableProjects(projectIds: string[]): Promise<void> { for (const projectId of projectIds) { await this.disableProject(projectId); } } /** * Batch enable projects */ async enableProjects(projectIds: string[]): Promise<void> { for (const projectId of projectIds) { await this.enableProject(projectId); } } /** * Get disabled projects */ async getDisabledProjects(): Promise<StoredProject[]> { const allProjects = await this.persistenceManager.listProjects(); return allProjects.filter((p) => p.enabled === false); } /** * Check if project is enabled */ isProjectEnabled(projectId: string): boolean { return this.projectManager.hasProject(projectId); } /** * Copy files between projects */ async copyFiles( sourceId: string, pattern: string, targetId: string, targetPath: string, ): Promise<void> { await this.projectOperations.copyFiles(sourceId, pattern, targetId, targetPath); } /** * Sync projects */ async syncProjects( sourceId: string, targetId: string, options: SyncOptions = {}, ): Promise<void> { await this.projectOperations.syncProjects(sourceId, targetId, options); } /** * Compare projects */ async compareProjects( projectId1: string, projectId2: string, ): Promise<ProjectDiff> { return this.projectOperations.compareProjects(projectId1, projectId2); } /** * Get workspace statistics */ async getProjectStats(): Promise<WorkspaceStats> { const allProjects = await this.persistenceManager.listProjects(); const disabledCount = allProjects.filter((p) => p.enabled === false).length; return this.performanceManager.getProjectStats(allProjects.length, disabledCount); } /** * Get project metrics */ async getProjectMetrics(projectId: string): Promise<ProjectMetrics> { const project = this.projectManager.getProject(projectId); if (!project) throw new Error(`Project ${projectId} not found`); return this.performanceManager.getProjectMetrics(project); } /** * Optimize memory usage */ async optimizeMemoryUsage(): Promise<OptimizationReport> { return this.performanceManager.optimizeMemoryUsage(); } /** * Provider Registry Implementation */ registerProvider(provider: SourceProvider): void { this.providers.set(provider.scheme, provider); } unregisterProvider(scheme: string): void { this.providers.delete(scheme); } getProvider(scheme: string): SourceProvider | undefined { return this.providers.get(scheme); } getRegisteredProviders(): SourceProvider[] { return Array.from(this.providers.values()); } /** * Convert a project to a different source */ async convertProject( projectId: string, targetSource: ProjectSource, ): Promise<void> { await this.projectManager.convertProject(projectId, targetSource); } /** * Convert to IndexedDB (convenience method) */ async convertToIndexedDB(projectId: string): Promise<void> { await this.convertProject(projectId, { type: "indexeddb", config: { dbName: `firesystem-${projectId}`, }, }); } /** * Export workspace configuration */ async export(): Promise<WorkspaceConfig> { const projects: ProjectConfig[] = this.getProjects().map( (p) => ({ id: p.id, name: p.name, source: p.source, metadata: p.metadata, }), ); return { version: "1.0.0", projects, activeProjectId: this.activeProjectId || undefined, settings: this.settings, }; } /** * Import workspace configuration */ async import(config: WorkspaceConfig): Promise<void> { // Clear existing workspace await this.clear(); // Apply settings if (config.settings) { this.settings = { ...this.settings, ...config.settings }; this.performanceManager.updateSettings(this.settings); } // Load projects for (const projectConfig of config.projects) { await this.loadProject(projectConfig); } // Set active project if (config.activeProjectId) { this.setActiveProject(config.activeProjectId); } } /** * Clear all projects */ async clear(): Promise<void> { this.events.emit("workspace:clearing", undefined); // Unload all projects const projectIds = this.projectManager.getProjects().map(p => p.id); for (const projectId of projectIds) { await this.unloadProject(projectId); } // Clear all projects from database const allStoredProjects = await this.persistenceManager.listProjects(); for (const project of allStoredProjects) { await this.persistenceManager.deleteProject(project.id); } this.activeProjectId = null; this.recentProjectIds = []; // Clear workspace state await this.saveWorkspaceState(); this.events.emit("workspace:cleared", undefined); } // IReactiveFileSystem implementation (proxy to active project) async readFile(path: string): Promise<FileEntry> { return this.fileSystemProxy.readFile(path); } async writeFile( path: string, content: any, metadata?: Record<string, any>, ): Promise<FileEntry> { return this.fileSystemProxy.writeFile(path, content, metadata); } async deleteFile(path: string): Promise<void> { return this.fileSystemProxy.deleteFile(path); } async mkdir(path: string, recursive?: boolean): Promise<FileEntry> { return this.fileSystemProxy.mkdir(path, recursive); } async rmdir(path: string, recursive?: boolean): Promise<void> { return this.fileSystemProxy.rmdir(path, recursive); } async exists(path: string): Promise<boolean> { return this.fileSystemProxy.exists(path); } async stat(path: string): Promise<FileStat> { return this.fileSystemProxy.stat(path); } async readDir(path: string): Promise<FileEntry[]> { return this.fileSystemProxy.readDir(path); } async rename(oldPath: string, newPath: string): Promise<FileEntry> { return this.fileSystemProxy.rename(oldPath, newPath); } async copy(sourcePath: string, targetPath: string): Promise<FileEntry> { return this.fileSystemProxy.copy(sourcePath, targetPath); } async move(sourcePaths: string[], targetPath: string): Promise<void> { return this.fileSystemProxy.move(sourcePaths, targetPath); } async glob(pattern: string): Promise<string[]> { return this.fileSystemProxy.glob(pattern); } watch(path: string, callback: (event: FSEvent) => void): Disposable { return this.fileSystemProxy.watch(path, callback); } watchGlob?(pattern: string, callback: (event: FSEvent) => void): Disposable { return this.fileSystemProxy.watchGlob!(pattern, callback); } async size(): Promise<number> { return this.fileSystemProxy.size(); } // Delegate permission methods to active FS async canModify(path: string): Promise<boolean> { return this.fileSystemProxy.canModify(path); } async canCreateIn(parentPath: string): Promise<boolean> { return this.fileSystemProxy.canCreateIn(parentPath); } // Persistence methods /** * Save current workspace state to database */ private async saveWorkspaceState(): Promise<void> { await this.persistenceManager.saveWorkspaceState( this.activeProjectId, this.recentProjectIds, this.settings, ); } /** * List all registered projects from database */ async listStoredProjects(): Promise<StoredProject[]> { return this.persistenceManager.listProjects(); } /** * List recent projects */ async listRecentProjects(limit = 10): Promise<StoredProject[]> { return this.persistenceManager.listRecentProjects(limit); } /** * Delete a project from workspace and optionally delete its data */ async deleteProject( projectId: string, options: DeleteProjectOptions = {}, ): Promise<void> { const project = this.projectManager.getProject(projectId); if (!project && !(await this.persistenceManager.getProject(projectId))) { // Project doesn't exist, just return silently return; } // 1. Emit confirmation event if not skipped if (!options.skipConfirmation) { // Create a cancellable event const confirmEvent: any = { projectId, project: project || (await this.persistenceManager.getProject(projectId)), cancelled: false, }; // Emit confirmation event - listeners can set cancelled to true this.events.emit("project:delete-confirm", confirmEvent); // Check if any listener cancelled the deletion if (confirmEvent.cancelled) { return; // Deletion cancelled } } // 2. If requested, ask provider to delete data if (options.deleteData) { const projectData = project || (await this.persistenceManager.getProject(projectId)); if (projectData) { const provider = this.getProvider(projectData.source.type); // Provider decides what to do based on type if (provider?.deleteProjectData) { try { await provider.deleteProjectData(projectData.source.config); } catch (error) { console.warn( `Failed to delete project data: ${error instanceof Error ? error.message : String(error)}`, ); // Continue with deletion even if data deletion fails } } } } // 3. Unload if loaded if (this.projectManager.hasProject(projectId)) { await this.unloadProject(projectId); } // 4. Remove from database await this.persistenceManager.deleteProject(projectId); // 5. Update state if this was the active project if (this.activeProjectId === projectId) { this.activeProjectId = null; await this.saveWorkspaceState(); } // 6. Emit deleted event this.events.emit("project:deleted", { projectId, deletedData: options.deleteData || false, }); } /** * Discover existing IndexedDB projects */ async discoverIndexedDBProjects(): Promise<ProjectConfig[]> { return this.persistenceManager.discoverIndexedDBProjects(); } /** * Import workspace configuration from URL */ async importFromUrl(url: string): Promise<void> { this.events.emit("workspace:importing", { source: url }); try { const config = await WorkspaceImporter.fromJsonUrl(url); await this.importWorkspaceConfig(config); this.events.emit("workspace:imported", { source: url }); } catch (error) { this.events.emit("workspace:import-failed", { source: url, error }); throw error; } } /** * Import workspace from GitHub Gist */ async importFromGitHubGist(gistId: string, token?: string): Promise<void> { this.events.emit("workspace:importing", { source: `gist:${gistId}` }); try { const config = await WorkspaceImporter.fromGitHubGist(gistId, token); await this.importWorkspaceConfig(config); this.events.emit("workspace:imported", { source: `gist:${gistId}` }); } catch (error) { this.events.emit("workspace:import-failed", { source: `gist:${gistId}`, error, }); throw error; } } /** * Import workspace configuration */ private async importWorkspaceConfig(config: WorkspaceConfig): Promise<void> { // Apply settings if (config.settings) { this.settings = { ...this.settings, ...config.settings }; this.performanceManager.updateSettings(this.settings); } // Load projects for (const projectConfig of config.projects) { await this.loadProject(projectConfig); } // Set active project if (config.activeProjectId) { await this.setActiveProject(config.activeProjectId); } // Save state await this.saveWorkspaceState(); } /** * Export workspace configuration */ async exportWorkspace(options: ExportOptions = {}): Promise<any> { const projects = this.getProjects(); const exportData = await WorkspaceExporter.toJson( projects, this.activeProjectId, this.settings, options, ); return exportData; } /** * Export workspace to GitHub Gist */ async exportToGitHubGist(options: { token: string; description?: string; public?: boolean; includeFiles?: boolean; }): Promise<string> { const exportData = await this.exportWorkspace({ includeFiles: options.includeFiles, }); const gistId = await WorkspaceExporter.toGitHubGist(exportData, options); return gistId; } /** * Export workspace to API endpoint */ async exportToApi( url: string, options?: { headers?: Record<string, string>; method?: string; includeFiles?: boolean; }, ): Promise<void> { const exportData = await this.exportWorkspace({ includeFiles: options?.includeFiles, }); await WorkspaceExporter.toApi(exportData, url, options); } // Credential Management /** * Register a custom credential provider */ registerCredentialProvider(sourceType: string, provider: any): void { this.credentialManager.registerProvider(sourceType, provider); } /** * Get credentials for a project (useful for debugging) */ async getProjectCredentials(projectId: string): Promise<any> { const stored = await this.persistenceManager.getProject(projectId); if (!stored) throw new Error("Project not found"); return this.credentialManager.getCredentials(projectId, stored.source); } /** * Clear cached credentials */ clearCredentialCache(projectId?: string): void { this.credentialManager.clearCache(projectId); } /** * Close workspace and database connection */ async close(): Promise<void> { await this.clear(); this.credentialManager.clearCache(); this.persistenceManager.close(); } /** * Check if a project exists and can be accessed */ private async isProjectAccessible(projectId: string): Promise<boolean> { try { const storedProject = await this.persistenceManager.getProject(projectId); if (!storedProject) return false; // For IndexedDB projects, also check if the database still exists if (storedProject.source.type === "indexeddb") { const dbName = storedProject.source.config.dbName; if (dbName) { try { // Check if IndexedDB is available and can list databases if (typeof indexedDB === "undefined" || !indexedDB.databases) { // Fallback: assume accessible if we can't check return true; } // Try to access the project's database const databases = await indexedDB.databases(); const projectDbExists = databases.some((db) => db.name === dbName); return projectDbExists; } catch (error) { console.warn( `Failed to check database existence for project ${projectId}:`, error, ); // Fallback: assume accessible if we can't check return true; } } } // For other project types (memory, api, github, etc), just check if stored return true; } catch (error) { console.warn( `Failed to check project accessibility for ${projectId}:`, error, ); return false; } } }