@firesystem/workspace
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
1 lines • 111 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/WorkspaceFileSystem.ts","../src/WorkspaceDatabase.ts","../src/import-export/WorkspaceImporter.ts","../src/credentials/SourceConfigBuilder.ts","../src/import-export/WorkspaceExporter.ts","../src/credentials/CredentialManager.ts"],"sourcesContent":["// Main exports\nexport { WorkspaceFileSystem } from \"./WorkspaceFileSystem\";\nexport { WorkspaceDatabase } from \"./WorkspaceDatabase\";\n\n// Types\nexport * from \"./types\";\n\n// Provider interfaces\nexport type { \n SourceProvider,\n PersistableSourceProvider,\n ConvertibleSourceProvider,\n ProviderRegistry\n} from \"./interfaces/SourceProvider\";\n\n// Import/Export\nexport * from \"./import-export\";\n\n// Credential Management\nexport { \n CredentialManager, \n BrowserCredentialProvider, \n EnvCredentialProvider,\n InteractiveCredentialProvider\n} from \"./credentials/CredentialManager\";\nexport { SourceConfigBuilder } from \"./credentials/SourceConfigBuilder\";\n\n// Source loaders removed - now using provider pattern\n\n// Re-export core types for convenience\nexport type {\n IFileSystem,\n IReactiveFileSystem,\n FileEntry,\n FileStat,\n FileMetadata,\n FSEvent,\n Disposable,\n FileSystemEventPayloads,\n} from \"@firesystem/core\";\n\n// Re-export values\nexport { FileSystemEvents } from \"@firesystem/core\";\n\n// Test suite for providers - removed from main export to avoid vitest dependency\n","import {\n IReactiveFileSystem,\n FileEntry,\n FileStat,\n FileSystemEventPayloads,\n FileSystemEvents,\n FSEvent,\n Disposable,\n TypedEventEmitter,\n withEvents,\n} from \"@firesystem/core\";\nimport type {\n Project,\n ProjectConfig,\n ProjectSource,\n ProjectState,\n WorkspaceConfig,\n WorkspaceSettings,\n WorkspaceEventPayloads,\n WorkspaceStats,\n ProjectMetrics,\n SyncOptions,\n ProjectDiff,\n OptimizationReport,\n} from \"./types\";\n\n/**\n * Options for deleting a project\n */\nexport interface DeleteProjectOptions {\n deleteData?: boolean;\n skipConfirmation?: boolean;\n}\nimport type { SourceProvider, ProviderRegistry } from \"./interfaces/SourceProvider\";\n// Source loaders removed - will use provider pattern instead\nimport { WorkspaceDatabase, StoredProject, WorkspaceState } from \"./WorkspaceDatabase\";\nimport { WorkspaceImporter } from \"./import-export/WorkspaceImporter\";\nimport { WorkspaceExporter, ExportOptions } from \"./import-export/WorkspaceExporter\";\nimport { \n CredentialManager, \n BrowserCredentialProvider, \n EnvCredentialProvider \n} from \"./credentials/CredentialManager\";\nimport { SourceConfigBuilder } from \"./credentials/SourceConfigBuilder\";\n\n/**\n * Multi-project workspace manager for Firesystem\n */\nexport class WorkspaceFileSystem implements IReactiveFileSystem, ProviderRegistry {\n private projects = new Map<string, Project>();\n private activeProjectId: string | null = null;\n private providers = new Map<string, SourceProvider>();\n private database: WorkspaceDatabase;\n private credentialManager: CredentialManager;\n private recentProjectIds: string[] = [];\n private autoDisableTimers = new Map<string, NodeJS.Timeout>();\n private settings: WorkspaceSettings = {\n maxActiveProjects: 10,\n autoDisableAfter: 30 * 60 * 1000, // 30 minutes\n keepFocusedActive: true,\n autoSave: false,\n autoSaveInterval: 60000, // 1 minute\n memoryThreshold: 500 * 1024 * 1024, // 500MB\n };\n\n /**\n * Event system for workspace events\n */\n public readonly events = new TypedEventEmitter<WorkspaceEventPayloads>();\n\n constructor(config?: WorkspaceConfig) {\n // Initialize database\n this.database = new WorkspaceDatabase();\n \n // Initialize credential manager\n this.credentialManager = new CredentialManager();\n this.setupCredentialProviders();\n \n // Providers are now registered by the user, not built-in\n\n // Apply settings if provided\n if (config?.settings) {\n this.settings = { ...this.settings, ...config.settings };\n }\n }\n\n /**\n * Initialize workspace with optional config\n */\n async initialize(config?: WorkspaceConfig): Promise<void> {\n this.events.emit(\"workspace:initializing\", undefined);\n\n // Open database\n await this.database.open();\n\n // Try to restore previous state\n const restored = await this.restoreWorkspaceState();\n\n if (config) {\n // Load projects from config\n for (const projectConfig of config.projects) {\n await this.loadProject(projectConfig);\n }\n\n // Set active project\n if (config.activeProjectId) {\n await this.setActiveProject(config.activeProjectId);\n }\n }\n // No default project creation - workspace starts empty\n\n this.events.emit(\"workspace:initialized\", {\n projectCount: this.projects.size,\n });\n }\n\n /**\n * Load a project into the workspace\n */\n async loadProject(config: ProjectConfig): Promise<Project> {\n this.events.emit(\"project:loading\", { projectId: config.id });\n\n try {\n // Check if project already exists\n if (this.projects.has(config.id)) {\n throw new Error(`Project ${config.id} already loaded`);\n }\n\n // Get provider for this source type first\n const provider = this.getProvider(config.source.type);\n if (!provider) {\n throw new Error(`No provider registered for type: ${config.source.type}`);\n }\n\n // Validate configuration using provider's validator\n if (provider.validateConfiguration) {\n const validation = await provider.validateConfiguration(config.source.config);\n if (!validation.valid) {\n throw new Error(`Invalid source config: ${validation.errors?.join(\", \") || \"Validation failed\"}`);\n }\n }\n // Note: If provider doesn't have validateConfiguration, we trust it can handle any config\n\n // Get credentials for this source\n const credentials = await this.credentialManager.getCredentials(config.id, config.source);\n\n // Merge credentials with source config\n const sourceWithCredentials = {\n ...config.source,\n config: {\n ...config.source.config,\n ...credentials\n }\n };\n\n // Create file system using provider\n const fs = await provider.createFileSystem(sourceWithCredentials.config);\n\n // Create project\n const project: Project = {\n id: config.id,\n name: config.name,\n source: config.source,\n fs,\n metadata: {\n created: new Date(),\n modified: new Date(),\n lastOpened: new Date(),\n ...config.metadata,\n },\n state: \"loaded\",\n lastAccessed: new Date(),\n accessCount: 0,\n memoryUsage: await this.estimateProjectMemoryUsage(fs),\n };\n\n // Setup event forwarding\n this.setupEventForwarding(project);\n\n // Store project\n this.projects.set(project.id, project);\n\n // Save to database\n const storedProject: StoredProject = {\n id: project.id,\n name: project.name,\n source: project.source,\n metadata: project.metadata,\n lastAccessed: new Date()\n };\n await this.database.saveProject(storedProject);\n\n // Emit loaded event\n this.events.emit(\"project:loaded\", { project });\n\n // If no active project, set this as active\n if (!this.activeProjectId) {\n await this.setActiveProject(project.id);\n }\n\n return project;\n } catch (error) {\n this.events.emit(\"project:error\", {\n projectId: config.id,\n error: error as Error,\n });\n throw error;\n }\n }\n\n /**\n * Unload a project from the workspace\n */\n async unloadProject(projectId: string): Promise<void> {\n const project = this.projects.get(projectId);\n if (!project) {\n throw new Error(`Project ${projectId} not found`);\n }\n\n this.events.emit(\"project:unloading\", { projectId });\n\n // If this is the active project, deactivate it\n if (this.activeProjectId === projectId) {\n this.activeProjectId = null;\n this.events.emit(\"project:deactivated\", { projectId });\n }\n\n // Clean up event listeners\n if (project.fs.events) {\n // Remove all listeners (implementation would need cleanup method)\n }\n\n // Remove from projects map\n this.projects.delete(projectId);\n\n this.events.emit(\"project:unloaded\", { projectId });\n }\n\n /**\n * Get a project by ID\n */\n getProject(projectId: string): Project | null {\n return this.projects.get(projectId) || null;\n }\n\n /**\n * Get all loaded projects\n */\n getProjects(): Project[] {\n return Array.from(this.projects.values());\n }\n\n /**\n * Set the active project\n */\n async setActiveProject(projectId: string): Promise<void> {\n const project = this.projects.get(projectId);\n if (!project) {\n throw new Error(`Project ${projectId} not found`);\n }\n\n const previousId = this.activeProjectId;\n this.activeProjectId = projectId;\n\n // Update recent projects\n this.recentProjectIds = [projectId, ...this.recentProjectIds.filter(id => id !== projectId)].slice(0, 10);\n\n // Update database\n await this.database.touchProject(projectId);\n await this.saveWorkspaceState();\n\n this.events.emit(\"project:activated\", { projectId, previousId: previousId || undefined });\n }\n\n /**\n * Get the active project\n */\n getActiveProject(): Project | null {\n if (!this.activeProjectId) return null;\n return this.projects.get(this.activeProjectId) || null;\n }\n\n /**\n * Disable a project (unload but keep configuration)\n */\n async disableProject(projectId: string): Promise<void> {\n const project = this.projects.get(projectId);\n if (!project) return;\n\n // Don't disable the focused project if configured\n if (this.settings.keepFocusedActive && this.activeProjectId === projectId) {\n throw new Error(\"Cannot disable the focused project when keepFocusedActive is true\");\n }\n\n this.events.emit(\"project:disabling\", { projectId });\n\n // Store provider info before unloading\n const provider = this.getProvider(project.source.type);\n const hasLocalData = provider?.hasLocalData?.() || false;\n\n // Check if this is the active project\n const wasActive = this.activeProjectId === projectId;\n\n // 1. Just unload from memory\n await this.unloadProject(projectId);\n\n // 2. Mark as disabled in database\n await this.database.updateProjectState(projectId, { \n enabled: false,\n disabledAt: new Date()\n });\n\n // 3. If this was the active project, switch to another one\n if (wasActive) {\n const remainingProjects = Array.from(this.projects.keys());\n if (remainingProjects.length > 0) {\n await this.setActiveProject(remainingProjects[0]);\n }\n }\n\n // 4. Emit event with useful information\n this.events.emit(\"project:disabled\", { \n projectId,\n hasLocalData,\n reason: \"manual\"\n });\n }\n\n /**\n * Enable a disabled project\n */\n async enableProject(projectId: string): Promise<void> {\n // If project is already enabled, return silently\n if (this.projects.has(projectId)) {\n return;\n }\n \n const storedProject = await this.database.getProject(projectId);\n if (!storedProject) {\n throw new Error(`Project ${projectId} not found in database`);\n }\n\n // 1. Recreate filesystem via provider\n const provider = this.getProvider(storedProject.source.type);\n if (!provider) {\n throw new Error(`Provider ${storedProject.source.type} not registered`);\n }\n\n const fs = await provider.createFileSystem(storedProject.source.config);\n\n // 2. Reload in memory\n const project: Project = {\n id: storedProject.id,\n name: storedProject.name,\n source: storedProject.source,\n fs,\n metadata: storedProject.metadata || {},\n state: \"loaded\" as ProjectState,\n lastAccessed: new Date(),\n accessCount: 0,\n memoryUsage: 0\n };\n\n this.projects.set(projectId, project);\n\n // 3. Update state in database\n await this.database.updateProjectState(projectId, { \n enabled: true,\n enabledAt: new Date()\n });\n\n this.events.emit(\"project:enabled\", { projectId });\n }\n\n /**\n * Batch disable projects\n */\n async disableProjects(projectIds: string[]): Promise<void> {\n for (const projectId of projectIds) {\n await this.disableProject(projectId);\n }\n }\n\n /**\n * Batch enable projects\n */\n async enableProjects(projectIds: string[]): Promise<void> {\n for (const projectId of projectIds) {\n await this.enableProject(projectId);\n }\n }\n\n /**\n * Get disabled projects\n */\n async getDisabledProjects(): Promise<StoredProject[]> {\n const allProjects = await this.database.listProjects();\n return allProjects.filter(p => p.enabled === false);\n }\n\n /**\n * Check if project is enabled\n */\n isProjectEnabled(projectId: string): boolean {\n return this.projects.has(projectId);\n }\n\n /**\n * Copy files between projects\n */\n async copyFiles(\n sourceId: string,\n pattern: string,\n targetId: string,\n targetPath: string\n ): Promise<void> {\n const sourceProject = this.projects.get(sourceId);\n const targetProject = this.projects.get(targetId);\n\n if (!sourceProject) throw new Error(`Source project ${sourceId} not found`);\n if (!targetProject) throw new Error(`Target project ${targetId} not found`);\n\n const files = await sourceProject.fs.glob(pattern);\n let copied = 0;\n\n for (const file of files) {\n const stat = await sourceProject.fs.stat(file);\n if (stat.type === \"file\") {\n const content = await sourceProject.fs.readFile(file);\n const targetFilePath = (targetPath + file).replace(/\\/+/g, '/');\n await targetProject.fs.writeFile(targetFilePath, content.content, content.metadata);\n copied++;\n }\n }\n\n this.events.emit(\"project:files-copied\", { \n sourceId, \n targetId, \n fileCount: copied \n });\n }\n\n /**\n * Sync projects\n */\n async syncProjects(\n sourceId: string,\n targetId: string,\n options: SyncOptions = {}\n ): Promise<void> {\n const sourceProject = this.projects.get(sourceId);\n const targetProject = this.projects.get(targetId);\n\n if (!sourceProject) throw new Error(`Source project ${sourceId} not found`);\n if (!targetProject) throw new Error(`Target project ${targetId} not found`);\n\n const files = await sourceProject.fs.glob(\"**/*\");\n const total = files.length;\n let copied = 0;\n\n for (const file of files) {\n if (options.filter && !options.filter(file)) continue;\n\n const stat = await sourceProject.fs.stat(file);\n if (stat.type === \"file\") {\n const exists = await targetProject.fs.exists(file);\n if (!exists || options.overwrite) {\n const content = await sourceProject.fs.readFile(file);\n await targetProject.fs.writeFile(file, content.content, content.metadata);\n copied++;\n \n if (options.progress) {\n options.progress(copied, total);\n }\n }\n }\n }\n }\n\n /**\n * Compare projects\n */\n async compareProjects(projectId1: string, projectId2: string): Promise<ProjectDiff> {\n const project1 = this.projects.get(projectId1);\n const project2 = this.projects.get(projectId2);\n\n if (!project1) throw new Error(`Project ${projectId1} not found`);\n if (!project2) throw new Error(`Project ${projectId2} not found`);\n\n const files1 = new Set(await project1.fs.glob(\"**/*\"));\n const files2 = new Set(await project2.fs.glob(\"**/*\"));\n\n const diff: ProjectDiff = {\n added: [],\n modified: [],\n deleted: [],\n unchanged: [],\n };\n\n // Files in project2 but not in project1 (added)\n for (const file of files2) {\n if (!files1.has(file)) {\n diff.added.push(file);\n }\n }\n\n // Files in project1 but not in project2 (deleted)\n for (const file of files1) {\n if (!files2.has(file)) {\n diff.deleted.push(file);\n }\n }\n\n // Files in both - check if modified\n for (const file of files1) {\n if (files2.has(file)) {\n const stat1 = await project1.fs.stat(file);\n const stat2 = await project2.fs.stat(file);\n \n if (stat1.type === \"file\" && stat2.type === \"file\") {\n const content1 = await project1.fs.readFile(file);\n const content2 = await project2.fs.readFile(file);\n \n if (content1.content !== content2.content) {\n diff.modified.push(file);\n } else {\n diff.unchanged.push(file);\n }\n }\n }\n }\n\n return diff;\n }\n\n /**\n * Get workspace statistics\n */\n async getProjectStats(): Promise<WorkspaceStats> {\n const allProjects = await this.database.listProjects();\n const connections: Record<string, number> = {};\n\n // Count connections by type\n for (const project of this.projects.values()) {\n const type = project.source.type;\n connections[type] = (connections[type] || 0) + 1;\n }\n\n // Calculate total memory usage\n let totalMemory = 0;\n for (const project of this.projects.values()) {\n totalMemory += project.memoryUsage || 0;\n }\n\n const disabledCount = allProjects.filter(p => p.enabled === false).length;\n\n return {\n total: allProjects.length,\n active: this.projects.size,\n disabled: disabledCount,\n focused: this.activeProjectId,\n memoryUsage: this.formatBytes(totalMemory),\n connections,\n };\n }\n\n /**\n * Get project metrics\n */\n async getProjectMetrics(projectId: string): Promise<ProjectMetrics> {\n const project = this.projects.get(projectId);\n if (!project) throw new Error(`Project ${projectId} not found`);\n\n const files = await project.fs.glob(\"**/*\");\n let totalSize = 0;\n let fileCount = 0;\n let largestFile = { path: \"\", size: 0 };\n let lastModified = new Date(0);\n\n for (const file of files) {\n const stat = await project.fs.stat(file);\n if (stat.type === \"file\") {\n fileCount++;\n totalSize += stat.size;\n \n if (stat.size > largestFile.size) {\n largestFile = { path: file, size: stat.size };\n }\n \n if (stat.modified > lastModified) {\n lastModified = stat.modified;\n }\n }\n }\n\n return {\n fileCount,\n totalSize,\n lastModified,\n accessCount: project.accessCount,\n averageFileSize: fileCount > 0 ? Math.round(totalSize / fileCount) : 0,\n largestFile,\n };\n }\n\n /**\n * Optimize memory usage\n */\n async optimizeMemoryUsage(): Promise<OptimizationReport> {\n const report: OptimizationReport = {\n projectsDisabled: [],\n memoryFreed: 0,\n connectionsReleased: 0,\n };\n\n // Get current memory usage\n const stats = await this.getProjectStats();\n const currentMemory = await this.calculateTotalMemoryUsage();\n\n if (currentMemory < (this.settings.memoryThreshold || Infinity)) {\n return report; // No optimization needed\n }\n\n // Sort projects by last accessed (oldest first)\n const projectList = Array.from(this.projects.values())\n .filter(p => p.id !== this.activeProjectId) // Never disable active project\n .sort((a, b) => a.lastAccessed.getTime() - b.lastAccessed.getTime());\n\n // Disable projects until under threshold\n for (const project of projectList) {\n if (currentMemory - report.memoryFreed < (this.settings.memoryThreshold || Infinity)) {\n break;\n }\n\n const memoryBefore = project.memoryUsage || 0;\n await this.disableProject(project.id);\n \n report.projectsDisabled.push(project.id);\n report.memoryFreed += memoryBefore;\n report.connectionsReleased++;\n }\n\n return report;\n }\n\n /**\n * Get active project's file system (for proxying)\n */\n private getActiveFS(): IReactiveFileSystem {\n const project = this.getActiveProject();\n if (!project) {\n throw new Error(\"No active project\");\n }\n \n // Track access\n project.lastAccessed = new Date();\n project.accessCount++;\n this.resetAutoDisableTimer(project.id);\n \n return project.fs;\n }\n\n /**\n * Reset auto-disable timer for a project\n */\n private resetAutoDisableTimer(projectId: string): void {\n // Clear existing timer\n const existingTimer = this.autoDisableTimers.get(projectId);\n if (existingTimer) {\n clearTimeout(existingTimer);\n }\n\n // Don't set timer for focused project if configured\n if (this.settings.keepFocusedActive && this.activeProjectId === projectId) {\n return;\n }\n\n // Set new timer if auto-disable is configured\n if (this.settings.autoDisableAfter && this.settings.autoDisableAfter > 0) {\n const timer = setTimeout(() => {\n this.disableProject(projectId).catch(error => {\n console.error(`Failed to auto-disable project ${projectId}:`, error);\n });\n }, this.settings.autoDisableAfter);\n\n this.autoDisableTimers.set(projectId, timer);\n }\n }\n\n /**\n * Estimate project memory usage\n */\n private async estimateProjectMemoryUsage(fs: IReactiveFileSystem): Promise<number> {\n try {\n // Basic estimation - can be improved based on FS type\n const size = await fs.size();\n // Add overhead for metadata and structures (rough estimate)\n return size * 1.2;\n } catch {\n return 0;\n }\n }\n\n /**\n * Calculate total memory usage\n */\n private async calculateTotalMemoryUsage(): Promise<number> {\n let total = 0;\n for (const project of this.projects.values()) {\n total += project.memoryUsage || 0;\n }\n return total;\n }\n\n /**\n * Format bytes to human readable\n */\n private formatBytes(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;\n }\n\n // Methods registerSourceLoader and getSourceLoader removed\n // Now using Provider Registry methods above\n\n /**\n * Setup credential providers\n */\n private setupCredentialProviders(): void {\n // Register default providers\n const isNode = typeof window === \"undefined\";\n \n if (isNode) {\n // Node.js environment - use environment variables\n this.credentialManager.registerProvider(\"s3\", new EnvCredentialProvider());\n this.credentialManager.registerProvider(\"github\", new EnvCredentialProvider());\n this.credentialManager.registerProvider(\"api\", new EnvCredentialProvider());\n } else {\n // Browser environment - use browser provider\n const browserProvider = new BrowserCredentialProvider();\n this.credentialManager.registerProvider(\"s3\", browserProvider);\n this.credentialManager.registerProvider(\"github\", browserProvider);\n this.credentialManager.registerProvider(\"api\", browserProvider);\n }\n }\n\n /**\n * Provider Registry Implementation\n */\n registerProvider(provider: SourceProvider): void {\n this.providers.set(provider.scheme, provider);\n }\n\n unregisterProvider(scheme: string): void {\n this.providers.delete(scheme);\n }\n\n getProvider(scheme: string): SourceProvider | undefined {\n return this.providers.get(scheme);\n }\n\n getRegisteredProviders(): SourceProvider[] {\n return Array.from(this.providers.values());\n }\n\n /**\n * Setup event forwarding from project to workspace\n */\n private setupEventForwarding(project: Project): void {\n if (!project.fs.events) return;\n\n // Forward file system events with project context\n const events = [\n FileSystemEvents.FILE_READING,\n FileSystemEvents.FILE_READ,\n FileSystemEvents.FILE_WRITING,\n FileSystemEvents.FILE_WRITTEN,\n FileSystemEvents.FILE_DELETING,\n FileSystemEvents.FILE_DELETED,\n FileSystemEvents.DIR_CREATING,\n FileSystemEvents.DIR_CREATED,\n FileSystemEvents.DIR_DELETING,\n FileSystemEvents.DIR_DELETED,\n ];\n\n events.forEach((event) => {\n project.fs.events!.on(event, (payload: any) => {\n // Map to project-specific event\n const projectEvent =\n `project:${event.toLowerCase().replace(/_/g, \":\")}` as keyof WorkspaceEventPayloads;\n\n // Add projectId to payload\n const enrichedPayload = { projectId: project.id, ...payload };\n\n // Emit on workspace events\n (this.events as any).emit(projectEvent, enrichedPayload);\n });\n });\n }\n\n /**\n * Convert a project to a different source\n */\n async convertProject(\n projectId: string,\n targetSource: ProjectSource,\n ): Promise<void> {\n const project = this.getProject(projectId);\n if (!project) {\n throw new Error(`Project ${projectId} not found`);\n }\n\n this.events.emit(\"project:converting\", { projectId, targetSource });\n\n // Get target provider\n const provider = this.getProvider(targetSource.type);\n if (!provider) {\n throw new Error(`No provider registered for type: ${targetSource.type}`);\n }\n\n // Create new file system\n const newFs = await provider.createFileSystem(targetSource.config);\n\n // Copy all files\n const paths = await project.fs.glob(\"**/*\");\n for (const path of paths) {\n const stat = await project.fs.stat(path);\n\n if (stat.type === \"directory\") {\n // Skip root directory\n if (path !== \"/\") {\n await newFs.mkdir(path, true);\n }\n } else {\n const file = await project.fs.readFile(path);\n await newFs.writeFile(path, file.content, file.metadata);\n }\n }\n\n // Replace project's file system\n project.fs = newFs;\n project.source = targetSource;\n\n // Re-setup event forwarding\n this.setupEventForwarding(project);\n\n this.events.emit(\"project:converted\", { projectId, source: targetSource });\n }\n\n /**\n * Convert to IndexedDB (convenience method)\n */\n async convertToIndexedDB(projectId: string): Promise<void> {\n await this.convertProject(projectId, {\n type: \"indexeddb\",\n config: {\n dbName: `firesystem-${projectId}`,\n },\n });\n }\n\n /**\n * Export workspace configuration\n */\n async export(): Promise<WorkspaceConfig> {\n const projects: ProjectConfig[] = Array.from(this.projects.values()).map(\n (p) => ({\n id: p.id,\n name: p.name,\n source: p.source,\n metadata: p.metadata,\n }),\n );\n\n return {\n version: \"1.0.0\",\n projects,\n activeProjectId: this.activeProjectId || undefined,\n settings: this.settings,\n };\n }\n\n /**\n * Import workspace configuration\n */\n async import(config: WorkspaceConfig): Promise<void> {\n // Clear existing workspace\n await this.clear();\n\n // Apply settings\n if (config.settings) {\n this.settings = { ...this.settings, ...config.settings };\n }\n\n // Load projects\n for (const projectConfig of config.projects) {\n await this.loadProject(projectConfig);\n }\n\n // Set active project\n if (config.activeProjectId) {\n this.setActiveProject(config.activeProjectId);\n }\n }\n\n /**\n * Clear all projects\n */\n async clear(): Promise<void> {\n this.events.emit(\"workspace:clearing\", undefined);\n\n // Unload all projects\n const projectIds = Array.from(this.projects.keys());\n for (const projectId of projectIds) {\n await this.unloadProject(projectId);\n }\n\n // Clear all projects from database\n const allStoredProjects = await this.database.listProjects();\n for (const project of allStoredProjects) {\n await this.database.deleteProject(project.id);\n }\n\n this.activeProjectId = null;\n this.recentProjectIds = [];\n\n // Clear workspace state\n await this.saveWorkspaceState();\n\n this.events.emit(\"workspace:cleared\", undefined);\n }\n\n // IReactiveFileSystem implementation (proxy to active project)\n\n async readFile(path: string): Promise<FileEntry> {\n return this.getActiveFS().readFile(path);\n }\n\n async writeFile(\n path: string,\n content: any,\n metadata?: Record<string, any>,\n ): Promise<FileEntry> {\n return this.getActiveFS().writeFile(path, content, metadata);\n }\n\n async deleteFile(path: string): Promise<void> {\n return this.getActiveFS().deleteFile(path);\n }\n\n async mkdir(path: string, recursive?: boolean): Promise<FileEntry> {\n return this.getActiveFS().mkdir(path, recursive);\n }\n\n async rmdir(path: string, recursive?: boolean): Promise<void> {\n return this.getActiveFS().rmdir(path, recursive);\n }\n\n async exists(path: string): Promise<boolean> {\n return this.getActiveFS().exists(path);\n }\n\n async stat(path: string): Promise<FileStat> {\n return this.getActiveFS().stat(path);\n }\n\n async readDir(path: string): Promise<FileEntry[]> {\n return this.getActiveFS().readDir(path);\n }\n\n async rename(oldPath: string, newPath: string): Promise<FileEntry> {\n return this.getActiveFS().rename(oldPath, newPath);\n }\n\n async copy(sourcePath: string, targetPath: string): Promise<FileEntry> {\n return this.getActiveFS().copy(sourcePath, targetPath);\n }\n\n async move(sourcePaths: string[], targetPath: string): Promise<void> {\n return this.getActiveFS().move(sourcePaths, targetPath);\n }\n\n async glob(pattern: string): Promise<string[]> {\n return this.getActiveFS().glob(pattern);\n }\n\n watch(path: string, callback: (event: FSEvent) => void): Disposable {\n return this.getActiveFS().watch(path, callback);\n }\n\n watchGlob?(pattern: string, callback: (event: FSEvent) => void): Disposable {\n const fs = this.getActiveFS();\n if ('watchGlob' in fs && typeof fs.watchGlob === 'function') {\n return fs.watchGlob(pattern, callback);\n }\n throw new Error('Active file system does not support watchGlob');\n }\n\n async size(): Promise<number> {\n return this.getActiveFS().size();\n }\n\n // Delegate permission methods to active FS\n async canModify(path: string): Promise<boolean> {\n return this.getActiveFS().canModify(path);\n }\n\n async canCreateIn(parentPath: string): Promise<boolean> {\n return this.getActiveFS().canCreateIn(parentPath);\n }\n\n /**\n * Check if a project exists and can be accessed\n */\n private async isProjectAccessible(projectId: string): Promise<boolean> {\n try {\n const storedProject = await this.database.getProject(projectId);\n if (!storedProject) return false;\n\n // For IndexedDB projects, also check if the database still exists\n if (storedProject.source.type === \"indexeddb\") {\n const dbName = storedProject.source.config.dbName;\n if (dbName) {\n try {\n // Check if IndexedDB is available and can list databases\n if (typeof indexedDB === \"undefined\" || !indexedDB.databases) {\n // Fallback: assume accessible if we can't check\n return true;\n }\n \n // Try to access the project's database\n const databases = await indexedDB.databases();\n const projectDbExists = databases.some(db => db.name === dbName);\n return projectDbExists;\n } catch (error) {\n console.warn(`Failed to check database existence for project ${projectId}:`, error);\n // Fallback: assume accessible if we can't check\n return true;\n }\n }\n }\n\n // For other project types (memory, api, github, etc), just check if stored\n return true;\n } catch (error) {\n console.warn(`Failed to check project accessibility for ${projectId}:`, error);\n return false;\n }\n }\n\n // Persistence methods\n\n /**\n * Restore workspace state from database\n */\n private async restoreWorkspaceState(): Promise<boolean> {\n try {\n // Get stored state\n const state = await this.database.getWorkspaceState();\n if (!state) return false;\n\n // Restore settings (but constructor settings take precedence)\n if (state.settings) {\n this.settings = { ...state.settings, ...this.settings };\n }\n\n // Validate and clean orphaned references\n let stateChanged = false;\n \n // Validate activeProjectId\n if (state.activeProjectId) {\n const activeProjectAccessible = await this.isProjectAccessible(state.activeProjectId);\n if (!activeProjectAccessible) {\n console.warn(`Removing orphaned activeProjectId: ${state.activeProjectId}`);\n state.activeProjectId = null;\n stateChanged = true;\n }\n }\n\n // Validate recentProjectIds\n if (state.recentProjectIds && state.recentProjectIds.length > 0) {\n const validRecentIds: string[] = [];\n for (const projectId of state.recentProjectIds) {\n const projectAccessible = await this.isProjectAccessible(projectId);\n if (projectAccessible) {\n validRecentIds.push(projectId);\n } else {\n console.warn(`Removing orphaned recentProjectId: ${projectId}`);\n stateChanged = true;\n }\n }\n state.recentProjectIds = validRecentIds;\n }\n\n // Save cleaned state if any orphaned references were found\n if (stateChanged) {\n await this.database.saveWorkspaceState(state);\n console.info(\"Workspace state cleaned: removed orphaned project references\");\n }\n\n // Restore recent projects\n this.recentProjectIds = state.recentProjectIds || [];\n\n // Get stored projects\n const storedProjects = await this.database.listProjects();\n\n // Load active project if still valid\n if (state.activeProjectId) {\n const activeProject = storedProjects.find(p => p.id === state.activeProjectId);\n if (activeProject) {\n try {\n await this.loadProjectFromStored(activeProject);\n this.activeProjectId = state.activeProjectId;\n } catch (error) {\n console.warn(\"Failed to restore active project:\", error);\n // Remove active project reference if it can't be loaded\n this.activeProjectId = null;\n state.activeProjectId = null;\n await this.database.saveWorkspaceState(state);\n }\n }\n }\n\n return true;\n } catch (error) {\n console.error(\"Failed to restore workspace state:\", error);\n return false;\n }\n }\n\n /**\n * Save current workspace state to database\n */\n private async saveWorkspaceState(): Promise<void> {\n const state: WorkspaceState = {\n id: \"current\",\n activeProjectId: this.activeProjectId,\n recentProjectIds: this.recentProjectIds,\n settings: this.settings\n };\n\n await this.database.saveWorkspaceState(state);\n }\n\n /**\n * Load a project from stored configuration\n */\n private async loadProjectFromStored(stored: StoredProject): Promise<Project> {\n const config: ProjectConfig = {\n id: stored.id,\n name: stored.name,\n source: stored.source,\n metadata: stored.metadata\n };\n\n return this.loadProject(config);\n }\n\n\n /**\n * List all registered projects from database\n */\n async listStoredProjects(): Promise<StoredProject[]> {\n return this.database.listProjects();\n }\n\n /**\n * List recent projects\n */\n async listRecentProjects(limit = 10): Promise<StoredProject[]> {\n return this.database.listRecentProjects(limit);\n }\n\n /**\n * Delete a project from workspace and optionally delete its data\n */\n async deleteProject(projectId: string, options: DeleteProjectOptions = {}): Promise<void> {\n const project = this.projects.get(projectId);\n if (!project && !await this.database.getProject(projectId)) {\n // Project doesn't exist, just return silently\n return;\n }\n\n // 1. Emit confirmation event if not skipped\n if (!options.skipConfirmation) {\n // Create a cancellable event\n const confirmEvent: any = {\n projectId,\n project: project || await this.database.getProject(projectId),\n cancelled: false\n };\n \n // Emit confirmation event - listeners can set cancelled to true\n this.events.emit(\"project:delete-confirm\", confirmEvent);\n \n // Check if any listener cancelled the deletion\n if (confirmEvent.cancelled) {\n return; // Deletion cancelled\n }\n }\n\n // 2. If requested, ask provider to delete data\n if (options.deleteData) {\n const projectData = project || await this.database.getProject(projectId);\n if (projectData) {\n const provider = this.getProvider(projectData.source.type);\n \n // Provider decides what to do based on type\n if (provider?.deleteProjectData) {\n try {\n await provider.deleteProjectData(projectData.source.config);\n } catch (error) {\n console.warn(`Failed to delete project data: ${error instanceof Error ? error.message : String(error)}`);\n // Continue with deletion even if data deletion fails\n }\n }\n }\n }\n\n // 3. Unload if loaded\n if (this.projects.has(projectId)) {\n await this.unloadProject(projectId);\n }\n\n // 4. Remove from database\n await this.database.deleteProject(projectId);\n\n // 5. Update state if this was the active project\n if (this.activeProjectId === projectId) {\n this.activeProjectId = null;\n await this.saveWorkspaceState();\n }\n\n // 6. Emit deleted event\n this.events.emit(\"project:deleted\", { \n projectId, \n deletedData: options.deleteData || false \n });\n }\n\n /**\n * Discover existing IndexedDB projects\n */\n async discoverIndexedDBProjects(): Promise<ProjectConfig[]> {\n const dbNames = await this.database.discoverIndexedDBProjects();\n \n return dbNames.map(dbName => ({\n id: `discovered-${dbName}`,\n name: dbName.replace(/^(firesystem-|@firesystem\\/)/i, \"\"),\n source: {\n type: \"indexeddb\",\n config: { dbName }\n }\n }));\n }\n\n /**\n * Import workspace configuration from URL\n */\n async importFromUrl(url: string): Promise<void> {\n this.events.emit(\"workspace:importing\", { source: url });\n\n try {\n const config = await WorkspaceImporter.fromJsonUrl(url);\n await this.importWorkspaceConfig(config);\n\n this.events.emit(\"workspace:imported\", { source: url });\n } catch (error) {\n this.events.emit(\"workspace:import-failed\", { source: url, error });\n throw error;\n }\n }\n\n /**\n * Import workspace from GitHub Gist\n */\n async importFromGitHubGist(gistId: string, token?: string): Promise<void> {\n this.events.emit(\"workspace:importing\", { source: `gist:${gistId}` });\n\n try {\n const config = await WorkspaceImporter.fromGitHubGist(gistId, token);\n await this.importWorkspaceConfig(config);\n\n this.events.emit(\"workspace:imported\", { source: `gist:${gistId}` });\n } catch (error) {\n this.events.emit(\"workspace:import-failed\", { source: `gist:${gistId}`, error });\n throw error;\n }\n }\n\n /**\n * Import workspace configuration\n */\n private async importWorkspaceConfig(config: WorkspaceConfig): Promise<void> {\n // Apply settings\n if (config.settings) {\n this.settings = { ...this.settings, ...config.settings };\n }\n\n // Load projects\n for (const projectConfig of config.projects) {\n await this.loadProject(projectConfig);\n }\n\n // Set active project\n if (config.activeProjectId) {\n await this.setActiveProject(config.activeProjectId);\n }\n\n // Save state\n await this.saveWorkspaceState();\n }\n\n /**\n * Export workspace configuration\n */\n async exportWorkspace(options: ExportOptions = {}): Promise<any> {\n const projects = Array.from(this.projects.values());\n const exportData = await WorkspaceExporter.toJson(\n projects,\n this.activeProjectId,\n this.settings,\n options\n );\n\n return exportData;\n }\n\n /**\n * Export workspace to GitHub Gist\n */\n async exportToGitHubGist(options: {\n token: string;\n description?: string;\n public?: boolean;\n includeFiles?: boolean;\n }): Promise<string> {\n const exportData = await this.exportWorkspace({\n includeFiles: options.includeFiles\n });\n\n const gistId = await WorkspaceExporter.toGitHubGist(exportData, options);\n return gistId;\n }\n\n /**\n * Export workspace to API endpoint\n */\n async exportToApi(url: string, options?: {\n headers?: Record<string, string>;\n method?: string;\n includeFiles?: boolean;\n }): Promise<void> {\n const exportData = await this.exportWorkspace({\n includeFiles: options?.includeFiles\n });\n\n await WorkspaceExporter.toApi(exportData, url, options);\n }\n\n // Credential Management\n\n /**\n * Register a custom credential provider\n */\n registerCredentialProvider(sourceType: string, provider: any): void {\n this.credentialManager.registerProvider(sourceType, provider);\n }\n\n /**\n * Get credentials for a project (useful for debugging)\n */\n async getProjectCredentials(projectId: string): Promise<any> {\n const stored = await this.database.getProject(projectId);\n if (!stored) throw new Error(\"Project not found\");\n \n return this.credentialManager.getCredentials(projectId, stored.source);\n }\n\n /**\n * Clear cached credentials\n */\n clearCredentialCache(projectId?: string): void {\n this.credentialManager.clearCache(projectId);\n }\n\n // Helper methods for creating projects with common patterns\n\n /**\n * Create a new IndexedDB project\n */\n async createIndexedDBProject(name: string, dbName?: string): Promise<Project> {\n const source = SourceConfigBuilder.indexedDB(dbName);\n const config: ProjectConfig = {\n id: `idb-${Date.now()}`,\n name,\n source\n };\n\n return this.loadProject(config);\n }\n\n /**\n * Create a new Memory project\n */\n async createMemoryProject(name: string, initialData?: any): Promise<Project> {\n const source = SourceConfigBuilder.memory(initialData);\n const config: ProjectConfig = {\n id: `mem-${Date.now()}`,\n name,\n source\n };\n\n return this.loadProject(config);\n }\n\n /**\n * Create a new S3 project\n */\n async createS3Project(\n name: string, \n bucket: string, \n options?: {\n prefix?: string;\n region?: string;\n credentials?: { accessKeyId: string; secretAccessKey: string };\n }\n ): Promise<Project> {\n const source = SourceConfigBuilder.s3({\n bucket,\n prefix: options?.prefix,\n region: options?.region,\n credentials: options?.credentials\n });\n\n const config: ProjectConfig = {\n id: `s3-${bucket}-${Date.now()}`,\n name,\n source\n };\n\n return this.loadProject(config);\n }\n\n /**\n * Create a new GitHub project\n */\n async createGitHubProject(\n name: string,\n owner: string,\n repo: string,\n options?: {\n branch?: string;\n path?: string;\n token?: string;\n }\n ): Promise<Project> {\n const source = SourceConfigBuilder.github({\n owner,\n repo,\n branch: options?.branch,\n path: options?.path,\n token: options?.token\n });\n\n const config: ProjectConfig = {\n id: `gh-${owner}-${repo}-${Date.now()}`,\n name,\n source\n };\n\n return this.loadProject(config);\n }\n\n /**\n * Close workspace and database connection\n */\n async close(): Promise<void> {\n await this.clear();\n this.credentialManager.clearCache();\n this.database.close();\n }\n}\n","import type { \n ProjectConfig, \n WorkspaceSettings,\n ProjectMetadata \n} from \"./types\";\n\nconst DB_NAME = \"@firesystem/workspace\";\nconst DB_VERSION = 1;\n\nexport interface StoredProject {\n id: string;\n name: string;\n source: any; // ProjectSource\n metadata: ProjectMetadata;\n lastAccessed: Date;\n enabled?: boolean;\n disabledAt?: Date;\n enabledAt?: Date;\n}\n\nexport interface WorkspaceState {\n id: \"current\"; // Single record key\n activeProjectId: string | null;\n recentProjectIds: string[];\n settings: WorkspaceSettings;\n}\n\n/**\n * Manages workspace persistence in IndexedDB\n */\nexport class WorkspaceDatabase {\n private db: IDBDatabase | null = null;\n private dbName: string;\n private dbVersion: number;\n\n constructor(dbName: string = DB_NAME, dbVersion: number = DB_VERSION) {\n this.dbName = dbName;\n this.dbVersion = dbVersion;\n }\n\n /**\n * Open or create the workspace database\n */\n async open(): Promise<void> {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open(this.dbName, this.dbVersion);\n\n request.onerror = () => {\n reject(new Error(`Failed to open database: ${request.error}`));\n };\n\n request.onsuccess = () => {\n this.db = request.result;\n resolve();\n };\n\n request.onupgradeneeded = (event) => {\n const db = request.result;\n const oldVersion = event.oldVersion;\n\n // Create object stores for v1\n if (oldVersion < 1) {\n // Projects store\n if (!db.objectStoreNames.contains(\"projects\")) {\n const projectStore = db.createObjectStore(\"projects\", { \n keyPath: \"id\" \n });\n projectStore.createIndex(\"name\", \"name\", { unique: false });\n projectStore.createIndex(\"lastAccessed\", \"lastAccessed\", { unique: false });\n }\n\n // Workspace state store\n if (!db.objectStoreNames.contains(\"workspaceState\")) {\n db.createObjectStore(\"workspaceState\", { keyPath: \"id\" });\n }\n }\n };\n });\n }\n\n /**\n * Ensure database is open\n */\n private ensureOpen(): void {\n if (!this.db) {\n throw new Error(\"Database not opened. Call open() first.\");\n }\n }\n\n /**\n * Close the database\n */\n close(): void {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n }\n\n // Project CRUD operations\n\n /**\n * Save or update a project configuration\n */\n async saveProject(project: StoredProject): Promise<void> {\n this.ensureOpen();\n \n const tx = this.db!.transaction([\"projects\"], \"readwrite\");\n const store = tx.objectStore(\"projects\");\n \n await new Promise<void>((resolve, reject) => {\n const request = store.put(project);\n request.onsuccess = () => resolve();\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * Get a project by ID\n */\n async getProject(id: string): Promise<StoredProject | null> {\n this.ensureOpen();\n \n const tx = this.db!.transaction([\"projects\"], \"readonly\");\n const store = tx.objectStore(\"projects\");\n \n return new Promise((resolve, reject) => {\n const request = store.get(id);\n request.onsuccess = () => resolve(request.result || null);\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * List all projects\n */\n async listProjects(): Promise<StoredProject[]> {\n this.ensureOpen();\n \n const tx = this.db!.transaction([\"projects\"], \"readonly\");\n const store = tx.objectStore(\"projects\");\n \n return new Promise((resolve, reject) => {\n const request = store.getAll();\n request.onsuccess = () => resolve(request.result || []);\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * List recent projects\n */\n async listRecentProjects(limit = 10): Promise<StoredProject[]> {\n this.ensureOpen();\n \n const tx = this.db!.transaction([\"projects\"], \"readonly\");\n const index = tx.objectStore(\"projects\").index(\"lastAccessed\");\n \n const projects: StoredProject[] = [];\n \n return new Promise((resolve, reject) => {\n // Open cursor in reverse order (most recent first)\n const request = index.openCursor(null, \"prev\");\n \n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor && projects.length < limit) {\n projects.push(cursor.value);\n cursor.continue();\n } else {\n resolve(projects);\n }\n };\n \n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * Delete a project\n */\n async deleteProject(id: string): Promise<void> {\n this.ensureOpen();\n \n const tx = this.db!.transaction([\"projects\"]