@workspace-fs/core
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
1 lines • 128 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","../src/managers/ProjectManager.ts","../src/managers/PerformanceManager.ts","../src/managers/PersistenceManager.ts","../src/managers/EventManager.ts","../src/operations/ProjectOperations.ts","../src/operations/FileSystemProxy.ts"],"sourcesContent":["// Main exports\nexport { WorkspaceFileSystem } from \"./WorkspaceFileSystem\";\nexport { WorkspaceDatabase } from \"./WorkspaceDatabase\";\nexport type { DeleteProjectOptions } from \"./WorkspaceFileSystem\";\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 FSEvent,\n Disposable,\n TypedEventEmitter,\n} from \"@firesystem/core\";\nimport type {\n Project,\n ProjectConfig,\n ProjectSource,\n WorkspaceConfig,\n WorkspaceSettings,\n WorkspaceStats,\n ProjectMetrics,\n SyncOptions,\n ProjectDiff,\n OptimizationReport,\n WorkspaceEventPayloads,\n} from \"./types\";\n\n/**\n * Options for deleting a project\n */\nexport interface DeleteProjectOptions {\n deleteData?: boolean;\n skipConfirmation?: boolean;\n}\n\nimport type {\n SourceProvider,\n ProviderRegistry,\n} from \"./interfaces/SourceProvider\";\nimport {\n WorkspaceDatabase,\n StoredProject,\n} from \"./WorkspaceDatabase\";\nimport { WorkspaceImporter } from \"./import-export/WorkspaceImporter\";\nimport {\n WorkspaceExporter,\n ExportOptions,\n} from \"./import-export/WorkspaceExporter\";\nimport { CredentialManager } from \"./credentials/CredentialManager\";\n\n// Import managers\nimport { ProjectManager } from \"./managers/ProjectManager\";\nimport { PerformanceManager } from \"./managers/PerformanceManager\";\nimport { PersistenceManager } from \"./managers/PersistenceManager\";\nimport { EventManager } from \"./managers/EventManager\";\nimport { ProjectOperations } from \"./operations/ProjectOperations\";\nimport { FileSystemProxy } from \"./operations/FileSystemProxy\";\n\n/**\n * Multi-project workspace manager for Firesystem\n */\nexport class WorkspaceFileSystem\n implements IReactiveFileSystem, ProviderRegistry\n{\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 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 // Managers\n private projectManager: ProjectManager;\n private performanceManager: PerformanceManager;\n private persistenceManager: PersistenceManager;\n private eventManager: EventManager;\n private projectOperations: ProjectOperations;\n private fileSystemProxy: FileSystemProxy;\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\n // Apply settings if provided\n if (config?.settings) {\n this.settings = { ...this.settings, ...config.settings };\n }\n\n // Initialize managers\n this.projectManager = new ProjectManager(\n this.events,\n (scheme) => this.getProvider(scheme),\n this.credentialManager,\n (project) => this.persistenceManager.saveProject(project),\n (projectId) => this.persistenceManager.touchProject(projectId),\n (fs) => this.performanceManager.estimateProjectMemoryUsage(fs),\n );\n\n this.performanceManager = new PerformanceManager(\n this.settings,\n () => this.projectManager.getProjects(),\n () => this.activeProjectId,\n (projectId) => this.disableProject(projectId),\n );\n\n this.persistenceManager = new PersistenceManager(\n this.database,\n (projectId) => this.isProjectAccessible(projectId),\n );\n\n this.eventManager = new EventManager(this.events);\n\n this.projectOperations = new ProjectOperations(\n (projectId) => this.projectManager.getProject(projectId),\n this.events,\n );\n\n this.fileSystemProxy = new FileSystemProxy(\n () => this.getActiveProject(),\n (project) => this.projectManager.trackProjectAccess(project),\n (projectId) => this.projectManager.resetAutoDisableTimer(\n projectId,\n this.settings.autoDisableAfter,\n this.settings.keepFocusedActive || false,\n this.activeProjectId,\n (id) => this.disableProject(id),\n ),\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.persistenceManager.open();\n\n // Try to restore previous state\n const { state, activeProjectToLoad } = await this.persistenceManager.restoreWorkspaceState();\n\n if (state) {\n // Restore settings (but constructor settings take precedence)\n if (state.settings) {\n this.settings = { ...state.settings, ...this.settings };\n this.performanceManager.updateSettings(this.settings);\n }\n\n // Restore recent projects\n this.recentProjectIds = state.recentProjectIds || [];\n\n // Load active project if still valid\n if (activeProjectToLoad) {\n try {\n await this.projectManager.loadProjectFromStored(activeProjectToLoad);\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 await this.saveWorkspaceState();\n }\n }\n }\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\n this.events.emit(\"workspace:initialized\", {\n projectCount: this.projectManager.getProjects().length,\n });\n }\n\n /**\n * Load a project into the workspace\n */\n async loadProject(config: ProjectConfig): Promise<Project> {\n const project = await this.projectManager.loadProject(config);\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 }\n\n /**\n * Unload a project from the workspace\n */\n async unloadProject(projectId: string): Promise<void> {\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 await this.projectManager.unloadProject(projectId);\n }\n\n /**\n * Get a project by ID\n */\n getProject(projectId: string): Project | null {\n return this.projectManager.getProject(projectId);\n }\n\n /**\n * Get all loaded projects\n */\n getProjects(): Project[] {\n return this.projectManager.getProjects();\n }\n\n /**\n * Set the active project\n */\n async setActiveProject(projectId: string): Promise<void> {\n const project = this.projectManager.getProject(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 = await this.projectManager.updateRecentProjects(\n projectId,\n this.recentProjectIds,\n );\n\n // Save state\n await this.saveWorkspaceState();\n\n this.events.emit(\"project:activated\", {\n projectId,\n previousId: previousId || undefined,\n });\n }\n\n /**\n * Get the active project\n */\n getActiveProject(): Project | null {\n if (!this.activeProjectId) return null;\n return this.projectManager.getProject(this.activeProjectId);\n }\n\n /**\n * Disable a project (unload but keep configuration)\n */\n async disableProject(projectId: string): Promise<void> {\n const project = this.projectManager.getProject(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(\n \"Cannot disable the focused project when keepFocusedActive is true\",\n );\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.persistenceManager.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 = this.projectManager.getProjects();\n if (remainingProjects.length > 0) {\n await this.setActiveProject(remainingProjects[0].id);\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.projectManager.hasProject(projectId)) {\n return;\n }\n\n const storedProject = await this.persistenceManager.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 fs = await this.projectManager.recreateFileSystem(storedProject);\n\n // 2. Reload in memory\n const project = this.projectManager.createProjectFromStored(storedProject, fs);\n\n // 3. Update state in database\n await this.persistenceManager.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.persistenceManager.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.projectManager.hasProject(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 await this.projectOperations.copyFiles(sourceId, pattern, targetId, targetPath);\n }\n\n /**\n * Sync projects\n */\n async syncProjects(\n sourceId: string,\n targetId: string,\n options: SyncOptions = {},\n ): Promise<void> {\n await this.projectOperations.syncProjects(sourceId, targetId, options);\n }\n\n /**\n * Compare projects\n */\n async compareProjects(\n projectId1: string,\n projectId2: string,\n ): Promise<ProjectDiff> {\n return this.projectOperations.compareProjects(projectId1, projectId2);\n }\n\n /**\n * Get workspace statistics\n */\n async getProjectStats(): Promise<WorkspaceStats> {\n const allProjects = await this.persistenceManager.listProjects();\n const disabledCount = allProjects.filter((p) => p.enabled === false).length;\n return this.performanceManager.getProjectStats(allProjects.length, disabledCount);\n }\n\n /**\n * Get project metrics\n */\n async getProjectMetrics(projectId: string): Promise<ProjectMetrics> {\n const project = this.projectManager.getProject(projectId);\n if (!project) throw new Error(`Project ${projectId} not found`);\n return this.performanceManager.getProjectMetrics(project);\n }\n\n /**\n * Optimize memory usage\n */\n async optimizeMemoryUsage(): Promise<OptimizationReport> {\n return this.performanceManager.optimizeMemoryUsage();\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 * Convert a project to a different source\n */\n async convertProject(\n projectId: string,\n targetSource: ProjectSource,\n ): Promise<void> {\n await this.projectManager.convertProject(projectId, 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[] = this.getProjects().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 this.performanceManager.updateSettings(this.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 = this.projectManager.getProjects().map(p => p.id);\n for (const projectId of projectIds) {\n await this.unloadProject(projectId);\n }\n\n // Clear all projects from database\n const allStoredProjects = await this.persistenceManager.listProjects();\n for (const project of allStoredProjects) {\n await this.persistenceManager.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.fileSystemProxy.readFile(path);\n }\n\n async writeFile(\n path: string,\n content: any,\n metadata?: Record<string, any>,\n ): Promise<FileEntry> {\n return this.fileSystemProxy.writeFile(path, content, metadata);\n }\n\n async deleteFile(path: string): Promise<void> {\n return this.fileSystemProxy.deleteFile(path);\n }\n\n async mkdir(path: string, recursive?: boolean): Promise<FileEntry> {\n return this.fileSystemProxy.mkdir(path, recursive);\n }\n\n async rmdir(path: string, recursive?: boolean): Promise<void> {\n return this.fileSystemProxy.rmdir(path, recursive);\n }\n\n async exists(path: string): Promise<boolean> {\n return this.fileSystemProxy.exists(path);\n }\n\n async stat(path: string): Promise<FileStat> {\n return this.fileSystemProxy.stat(path);\n }\n\n async readDir(path: string): Promise<FileEntry[]> {\n return this.fileSystemProxy.readDir(path);\n }\n\n async rename(oldPath: string, newPath: string): Promise<FileEntry> {\n return this.fileSystemProxy.rename(oldPath, newPath);\n }\n\n async copy(sourcePath: string, targetPath: string): Promise<FileEntry> {\n return this.fileSystemProxy.copy(sourcePath, targetPath);\n }\n\n async move(sourcePaths: string[], targetPath: string): Promise<void> {\n return this.fileSystemProxy.move(sourcePaths, targetPath);\n }\n\n async glob(pattern: string): Promise<string[]> {\n return this.fileSystemProxy.glob(pattern);\n }\n\n watch(path: string, callback: (event: FSEvent) => void): Disposable {\n return this.fileSystemProxy.watch(path, callback);\n }\n\n watchGlob?(pattern: string, callback: (event: FSEvent) => void): Disposable {\n return this.fileSystemProxy.watchGlob!(pattern, callback);\n }\n\n async size(): Promise<number> {\n return this.fileSystemProxy.size();\n }\n\n // Delegate permission methods to active FS\n async canModify(path: string): Promise<boolean> {\n return this.fileSystemProxy.canModify(path);\n }\n\n async canCreateIn(parentPath: string): Promise<boolean> {\n return this.fileSystemProxy.canCreateIn(parentPath);\n }\n\n // Persistence methods\n\n /**\n * Save current workspace state to database\n */\n private async saveWorkspaceState(): Promise<void> {\n await this.persistenceManager.saveWorkspaceState(\n this.activeProjectId,\n this.recentProjectIds,\n this.settings,\n );\n }\n\n /**\n * List all registered projects from database\n */\n async listStoredProjects(): Promise<StoredProject[]> {\n return this.persistenceManager.listProjects();\n }\n\n /**\n * List recent projects\n */\n async listRecentProjects(limit = 10): Promise<StoredProject[]> {\n return this.persistenceManager.listRecentProjects(limit);\n }\n\n /**\n * Delete a project from workspace and optionally delete its data\n */\n async deleteProject(\n projectId: string,\n options: DeleteProjectOptions = {},\n ): Promise<void> {\n const project = this.projectManager.getProject(projectId);\n if (!project && !(await this.persistenceManager.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.persistenceManager.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 =\n project || (await this.persistenceManager.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(\n `Failed to delete project data: ${error instanceof Error ? error.message : String(error)}`,\n );\n // Continue with deletion even if data deletion fails\n }\n }\n }\n }\n\n // 3. Unload if loaded\n if (this.projectManager.hasProject(projectId)) {\n await this.unloadProject(projectId);\n }\n\n // 4. Remove from database\n await this.persistenceManager.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 return this.persistenceManager.discoverIndexedDBProjects();\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\", {\n source: `gist:${gistId}`,\n error,\n });\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 this.performanceManager.updateSettings(this.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 = this.getProjects();\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(\n url: string,\n options?: {\n headers?: Record<string, string>;\n method?: string;\n includeFiles?: boolean;\n },\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.persistenceManager.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 /**\n * Close workspace and database connection\n */\n async close(): Promise<void> {\n await this.clear();\n this.credentialManager.clearCache();\n this.persistenceManager.close();\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.persistenceManager.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(\n `Failed to check database existence for project ${projectId}:`,\n error,\n );\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(\n `Failed to check project accessibility for ${projectId}:`,\n error,\n );\n return false;\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\", {\n unique: false,\n });\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\"], \"readwrite\");\n const store = tx.objectStore(\"projects\");\n\n await new Promise<void>((resolve, reject) => {\n const request = store.delete(id);\n request.onsuccess = () => resolve();\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * Update project last accessed time\n */\n async touchProject(id: string): Promise<void> {\n const project = await this.getProject(id);\n if (project) {\n project.lastAccessed = new Date();\n await this.saveProject(project);\n }\n }\n\n /**\n * Update project state\n */\n async updateProjectState(\n id: string,\n updates: Partial<\n Pick<StoredProject, \"enabled\" | \"disabledAt\" | \"enabledAt\">\n >,\n ): Promise<void> {\n const project = await this.getProject(id);\n if (project) {\n // Apply updates\n Object.assign(project, updates);\n await this.saveProject(project);\n }\n }\n\n // Workspace state operations\n\n /**\n * Save workspace state\n */\n async saveWorkspaceState(state: WorkspaceState): Promise<void> {\n this.ensureOpen();\n\n const tx = this.db!.transaction([\"workspaceState\"], \"readwrite\");\n const store = tx.objectStore(\"workspaceState\");\n\n await new Promise<void>((resolve, reject) => {\n const request = store.put(state);\n request.onsuccess = () => resolve();\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * Get workspace state\n */\n async getWorkspaceState(): Promise<WorkspaceState | null> {\n this.ensureOpen();\n\n const tx = this.db!.transaction([\"workspaceState\"], \"readonly\");\n const store = tx.objectStore(\"workspaceState\");\n\n return new Promise((resolve, reject) => {\n const request = store.get(\"current\");\n request.onsuccess = () => resolve(request.result || null);\n request.onerror = () => reject(request.error);\n });\n }\n\n /**\n * Clear all data\n */\n async clear(): Promise<void> {\n this.ensureOpen();\n\n const tx = this.db!.transaction(\n [\"projects\", \"workspaceState\"],\n \"readwrite\",\n );\n\n await Promise.all([\n new Promise<void>((resolve, reject) => {\n const request = tx.objectStore(\"projects\").clear();\n request.onsuccess = () => resolve();\n request.onerror = () => reject(request.error);\n }),\n new Promise<void>((resolve, reject) => {\n const request = tx.objectStore(\"workspaceState\").clear();\n request.onsuccess = () => resolve();\n request.onerror = () => reject(request.error);\n }),\n ]);\n }\n\n /**\n * Check if a project exists by checking IndexedDB databases\n */\n static async projectDatabaseExists(dbName: string): Promise<boolean> {\n if (!(\"databases\" in indexedDB)) {\n // Fallback for browsers that don't support databases()\n return new Promise((resolve) => {\n const testOpen = indexedDB.open(dbName);\n testOpen.onsuccess = () => {\n const db = testOpen.result;\n const exists = db.version > 0;\n db.close();\n resolve(exists);\n };\n testOpen.onerror = () => resolve(false);\n });\n }\n\n const databases = await indexedDB.databases();\n return databases.some((db) => db.name === dbName);\n }\n\n /**\n * Discover existing Firesystem IndexedDB databases\n */\n async discoverIndexedDBProjects(): Promise<string[]> {\n if (!(\"databases\" in indexedDB)) {\n console.warn(\"IndexedDB.databases() not supported in this browser\");\n return [];\n }\n\n const databases = await indexedDB.databases();\n\n // Filter databases that look like Firesystem projects\n return databases\n .filter((db) => {\n const name = db.name || \"\";\n return (\n name.startsWith(\"firesystem-\") ||\n name.startsWith(\"@firesystem/\") ||\n name.includes(\"-filesystem\")\n );\n })\n .map((db) => db.name!);\n }\n}\n","import type { WorkspaceConfig, ProjectConfig } from \"../types\";\n\nexport interface WorkspaceExport {\n version: string;\n exportedAt: Date;\n workspace: {\n settings?: any;\n activeProjectId?: string;\n };\n projects: Array<{\n id: string;\n name: string;\n type: \"memory\" | \"indexeddb\" | \"s3\";\n config?: any;\n files?: Array<{\n path: string;\n content: string;\n metadata?: Record<string, any>;\n }>;\n }>;\n}\n\n/**\n * Handles import/export of workspace configurations\n */\nexport class WorkspaceImporter {\n /**\n * Import workspace from JSON URL\n */\n static async fromJsonUrl(url: string): Promise<WorkspaceConfig> {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`Failed to fetch workspace: ${response.statusText}`);\n }\n\n const data: WorkspaceExport = await response.json();\n return this.parseExport(data);\n }\n\n /**\n * Import workspace from GitHub Gist\n */\n static async fromGitHubGist(\n gistId: string,\n token?: string,\n ): Promise<WorkspaceConfig> {\n const headers: Record<string, string> = {\n Accept: \"application/vnd.github.v3+json\",\n };\n\n if (token) {\n headers[\"Authorization\"] = `token ${token}`;\n }\n\n const response = await fetch(`https://api.github.com/gists/${gistId}`, {\n headers,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch gist: ${response.statusText}`);\n }\n\n const gist = await response.json();\n\n // Look for workspace.json file in the gist\n const workspaceFile = Object.values(gist.files).find(\n (file: any) => file.filename === \"workspace.json\",\n ) as any;\n\n if (!workspaceFile) {\n throw new Error(\"No workspace.json found in gist\");\n }\n\n const data: WorkspaceExport = JSON.parse(workspaceFile.content);\n return this.parseExport(data);\n }\n\n /**\n * Import workspace from API endpoint\n */\n static async fromApi(\n url: string,\n options?: {\n headers?: Record<string, string>;\n method?: string;\n body?: any;\n },\n ): Promise<WorkspaceConfig> {\n const response = await fetch(url, {\n method: options?.method || \"GET\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...options?.headers,\n },\n body: options?.body ? JSON.stringify(options.body) : undefined,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch from API: ${response.statusText}`);\n }\n\n const data: WorkspaceExport = await response.json();\n return this.parseExport(data);\n }\n\n /**\n * Parse export data into workspace config\n */\n private static parseExport(data: WorkspaceExport): WorkspaceConfig {\n // Validate version\n if (!data.version || !data.version.startsWith(\"1.\")) {\n throw new Error(`Unsupported workspace version: ${data.version}`);\n }\n\n const projects: ProjectConfig[] = data.projects.map((p) => {\n // Determine source based on type and presence of files\n let source: any;\n\n if (p.files && p.files.length > 0) {\n // If files are included, create memory source with initial data\n source = {\n type: \"memory\",\n config: {\n initialData: p.files.reduce(\n (acc, file) => {\n acc[file.path] = {\n content: file.content,\n metadata: file.metadata,\n };\n return acc;\n },\n {} as Record<string, any>,\n ),\n },\n };\n } else if (p.type === \"indexeddb\") {\n // IndexedDB reference\n source = {\n type: \"indexeddb\",\n config: {\n dbName: p.config?.dbName || `firesystem-${p.id}`,\n },\n };\n } else if (p.type === \"s3\") {\n // S3 reference\n source = {\n type: \"s3\",\n config: p.config,\n };\n } else {\n // Default to empty memory\n source = {\n type: \"memory\",\n config: {},\n };\n }\n\n return {\n id: p.id,\n name: p.name,\n source,\n };\n });\n\n return {\n version: data.version,\n projects,\n activeProjectId: data.workspace.activeProjectId,\n settings: data.workspace.settings,\n };\n }\n}\n","import type { ProjectSource } from \"../types\";\n\n/**\n * Validates and builds source configurations with credentials\n */\nexport class SourceConfigBuilder {\n /**\n * Build IndexedDB source config\n */\n static indexedDB(dbName?: string): ProjectSource {\n return {\n type: \"indexeddb\",\n config: {\n dbName: dbName || `firesystem-${Date.now()}`,\n },\n };\n }\n\n /**\n * Build Memory source config\n */\n static memory(initialData?: any): ProjectSource {\n return {\n type: \"memory\",\n config: {\n initialData,\n },\n };\n }\n\n /**\n * Build S3 source config\n */\n static s3(config: {\n bucket: string;\n prefix?: string;\n region?: string;\n credentials?: {\n accessKeyId: string;\n secretAccessKey: string;\n };\n }): ProjectSource {\n const source: ProjectSource = {\n type: \"s3\",\n config: {\n bucket: config.bucket,\n prefix: config.prefix || \"\",\n region: config.region || \"us-east-1\",\n },\n };\n\n // Add auth if credentials provided\n if (config.credentials) {\n source.auth = {\n type: \"token\",\n credentials: config.credentials,\n };\n }\n\n return source;\n }\n\n /**\n * Build GitHub source config\n */\n static github(config: {\n owner: string;\n repo: string;\n branch?: string;\n path?: string;\n token?: string;\n }): ProjectSource {\n const source: ProjectSource = {\n type: \"github\",\n config: {\n owner: config.owner,\n repo: config.repo,\n branch: config.branch || \"main\",\n path: config.path || \"/\",\n },\n };\n\n if (config.token) {\n source.auth = {\n type: \"bearer\",\n credentials: { token: config.token },\n };\n }\n\n return source;\n }\n\n /**\n * Build API source config\n */\n static api(config: {\n baseUrl: string;\n projectEndpoint?: string;\n headers?: Record<string, string>;\n apiKey?: string;\n }): ProjectSource {\n const source: ProjectSource = {\n type: \"api\",\n config: {\n baseUrl: config.baseUrl,\n projectEndpoint: config.projectEndpoint || \"/projects\",\n headers: config.headers,\n },\n };\n\n if (config.apiKey) {\n source.auth = {\n type: \"token\",\n credentials: { apiKey: config.apiKey },\n };\n }\n\n return source;\n }\n\n /**\n * Validate source configuration\n */\n static validate(source: ProjectSource): { valid: boolean; errors: string[] } {\n const errors: string[] = [];\n\n switch (source.type) {\n case \"indexeddb\":\n if (!source.config.dbName) {\n errors.push(\"IndexedDB source requires dbName\");\n }\n break;\n\n case \"s3\":\n if (!source.config.bucket) {\n errors.push(\"S3 source requires bucket\");\n }\n // For validation, we don't require credentials - they can be provided at runtime\n break;\n\n case \"github\":\n if (!source.config.owner || !source.config.repo) {\n errors.push(\"GitHub source requires owner and repo\");\n }\n break;\n\n case \"api\":\n if (!source.config.baseUrl) {\n errors.push(\"API source requires baseUrl\");\n }\n break;\n\n case \"memory\":\n // Memory source has no required fields\n break;\n\n default:\n errors.push(`Unknown source type: ${source.type}`);\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n }\n\n /**\n * Sanitize source for export (remove credentials)\n */\n static sanitize(source: ProjectSource): ProjectSource {\n const sanitized = { ...source };\n\n // Remove auth completely\n delete sanitized.auth;\n\n // Clean specific fields based on type\n switch (source.type) {\n case \"s3\":\n // Keep only bucket and prefix\n sanitized.config = {\n bucket: source.config.bucket,\n prefix: source.config.prefix,\n };\n break;\n\n case \"api\":\n // Remove headers that might contain auth\n const { headers, ...restConfig } = source.config;\n sanitized.config = restConfig;\n break;\n\n // For other types (memory, indexeddb, github), preserve config as-is\n default:\n // No special sanitization needed for other types\n break;\n }\n\n return sanitized;\n }\n}\n","import type { IReactiveFileSystem } from \"@firesystem/core\";\nimport type { Project, WorkspaceSettings } from \"../types\";\nimport type { WorkspaceExport } from \"./WorkspaceImporter\";\nimport { SourceConfigBuilder } from \"../credentials/SourceConfigBuilder\";\n\nexport interface ExportOptions {\n includeFiles?: boolean;\n includeCredentials?: boolean;\n projectIds?: string[];\n}\n\n/**\n * Handles exporting workspace configurations\n */\nexport class WorkspaceExporter {\n /**\n * Export workspace to JSON\n */\n static async toJson(\n projects: Project[],\n activeProjectId: string | null,\n settings: WorkspaceSettings,\n options: ExportOptions = {},\n ): Promise<WorkspaceExport> {\n const exportedProjects = await Promise.all(\n projects\n .filter((p) => !options.projectIds || options.projectIds.includes(p.id))\n .map(async (p) => {\n const exported: any = {\n id: p.id,\n name: p.name,\n type: p.source.type as any,\n };\n\n // Include config based on credentials option\n if (p.source.type === \"s3\") {\n if (options.includeCredentials) {\n // Include full config with credentials\n exported.config = { ...p.source.config };\n } else {\n // Strip credentials from S3 config\n exported.config = {\n bucket: p.source.config.bucket,\n prefix: p.source.config.prefix,\n };\n }\n } else if (p.source.type === \"indexeddb\") {\n exported.config = {\n dbName: p.source.config.dbName,\n };\n } else {\n // For other types, include full config unless it's sensitive\n exported.config = options.includeCredentials\n ? { ...p.source.config }\n : SourceConfigBuilder.sanitize(p.source).config;\n }\n\n // Include files if requested\n if (options.includeFiles && p.fs) {\n exported.files = await this.extractFiles(p.fs);\n }\n\n return exported;\n }),\n );\n\n return {\n version: \"1.0\",\n exportedAt: new Date(),\n workspace: {\n settings,\n activeProjectId: activeProjectId || undefined,\n },\n projects: exportedProjects,\n };\n }\n\n /**\n * Export to GitHub Gist\n */\n static async toGitHubGist(\n exportData: WorkspaceExport,\n options: {\n token: string;\n description?: string;\n public?: boolean;\n },\n ): Promise<string> {\n const response = await fetch(\"https://api.github.com/gists\", {\n method: \"POST\",\n headers: {\n Authorization: `token ${options.token}`,\n Accept: \"application/vnd.github.v3+json\",\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n description: options.description || \"Firesystem Workspace Export\",\n public: options.public ?? false,\n files: {\n \"workspace.json\": {\n content: JSON.stringify(exportData, null, 2),\n },\n },\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to create gist: ${response.statusText}`);\n }\n\n const gist = await response.json();\n return gist.id;\n }\n\n /**\n * Export to API endpoint\n */\n static async toApi(\n exportData: WorkspaceExport,\n url: string,\n options?: {\n headers?: Record<string, string>;\n method?: string;\n },\n ): Promise<void> {\n const response = await fetch(url, {\n method: options?.method || \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...options?.headers,\n },\n body: JSO