UNPKG

@firesystem/workspace

Version:

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

1,487 lines (1,255 loc) 42 kB
import { IReactiveFileSystem, FileEntry, FileStat, FileSystemEventPayloads, FileSystemEvents, FSEvent, Disposable, TypedEventEmitter, withEvents, } from "@firesystem/core"; import type { Project, ProjectConfig, ProjectSource, ProjectState, WorkspaceConfig, WorkspaceSettings, WorkspaceEventPayloads, WorkspaceStats, ProjectMetrics, SyncOptions, ProjectDiff, OptimizationReport, } from "./types"; /** * Options for deleting a project */ export interface DeleteProjectOptions { deleteData?: boolean; skipConfirmation?: boolean; } import type { SourceProvider, ProviderRegistry } from "./interfaces/SourceProvider"; // Source loaders removed - will use provider pattern instead import { WorkspaceDatabase, StoredProject, WorkspaceState } from "./WorkspaceDatabase"; import { WorkspaceImporter } from "./import-export/WorkspaceImporter"; import { WorkspaceExporter, ExportOptions } from "./import-export/WorkspaceExporter"; import { CredentialManager, BrowserCredentialProvider, EnvCredentialProvider } from "./credentials/CredentialManager"; import { SourceConfigBuilder } from "./credentials/SourceConfigBuilder"; /** * Multi-project workspace manager for Firesystem */ export class WorkspaceFileSystem implements IReactiveFileSystem, ProviderRegistry { private projects = new Map<string, Project>(); private activeProjectId: string | null = null; private providers = new Map<string, SourceProvider>(); private database: WorkspaceDatabase; private credentialManager: CredentialManager; private recentProjectIds: string[] = []; private autoDisableTimers = new Map<string, NodeJS.Timeout>(); private settings: WorkspaceSettings = { maxActiveProjects: 10, autoDisableAfter: 30 * 60 * 1000, // 30 minutes keepFocusedActive: true, autoSave: false, autoSaveInterval: 60000, // 1 minute memoryThreshold: 500 * 1024 * 1024, // 500MB }; /** * 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(); this.setupCredentialProviders(); // Providers are now registered by the user, not built-in // Apply settings if provided if (config?.settings) { this.settings = { ...this.settings, ...config.settings }; } } /** * Initialize workspace with optional config */ async initialize(config?: WorkspaceConfig): Promise<void> { this.events.emit("workspace:initializing", undefined); // Open database await this.database.open(); // Try to restore previous state const restored = await this.restoreWorkspaceState(); 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); } } // No default project creation - workspace starts empty this.events.emit("workspace:initialized", { projectCount: this.projects.size, }); } /** * Load a project into the workspace */ async loadProject(config: ProjectConfig): Promise<Project> { this.events.emit("project:loading", { projectId: config.id }); try { // Check if project already exists if (this.projects.has(config.id)) { throw new Error(`Project ${config.id} already loaded`); } // Get provider for this source type first const provider = this.getProvider(config.source.type); if (!provider) { throw new Error(`No provider registered for type: ${config.source.type}`); } // Validate configuration using provider's validator if (provider.validateConfiguration) { const validation = await provider.validateConfiguration(config.source.config); if (!validation.valid) { throw new Error(`Invalid source config: ${validation.errors?.join(", ") || "Validation failed"}`); } } // Note: If provider doesn't have validateConfiguration, we trust it can handle any config // Get credentials for this source const credentials = await this.credentialManager.getCredentials(config.id, config.source); // Merge credentials with source config const sourceWithCredentials = { ...config.source, config: { ...config.source.config, ...credentials } }; // Create file system using provider const fs = await provider.createFileSystem(sourceWithCredentials.config); // Create project const project: Project = { id: config.id, name: config.name, source: config.source, fs, metadata: { created: new Date(), modified: new Date(), lastOpened: new Date(), ...config.metadata, }, state: "loaded", lastAccessed: new Date(), accessCount: 0, memoryUsage: await this.estimateProjectMemoryUsage(fs), }; // Setup event forwarding this.setupEventForwarding(project); // Store project this.projects.set(project.id, project); // Save to database const storedProject: StoredProject = { id: project.id, name: project.name, source: project.source, metadata: project.metadata, lastAccessed: new Date() }; await this.database.saveProject(storedProject); // Emit loaded event this.events.emit("project:loaded", { project }); // If no active project, set this as active if (!this.activeProjectId) { await this.setActiveProject(project.id); } return project; } catch (error) { this.events.emit("project:error", { projectId: config.id, error: error as Error, }); throw error; } } /** * Unload a project from the workspace */ async unloadProject(projectId: string): Promise<void> { const project = this.projects.get(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } this.events.emit("project:unloading", { projectId }); // If this is the active project, deactivate it if (this.activeProjectId === projectId) { this.activeProjectId = null; this.events.emit("project:deactivated", { projectId }); } // Clean up event listeners if (project.fs.events) { // Remove all listeners (implementation would need cleanup method) } // Remove from projects map this.projects.delete(projectId); this.events.emit("project:unloaded", { projectId }); } /** * Get a project by ID */ getProject(projectId: string): Project | null { return this.projects.get(projectId) || null; } /** * Get all loaded projects */ getProjects(): Project[] { return Array.from(this.projects.values()); } /** * Set the active project */ async setActiveProject(projectId: string): Promise<void> { const project = this.projects.get(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } const previousId = this.activeProjectId; this.activeProjectId = projectId; // Update recent projects this.recentProjectIds = [projectId, ...this.recentProjectIds.filter(id => id !== projectId)].slice(0, 10); // Update database await this.database.touchProject(projectId); 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.projects.get(this.activeProjectId) || null; } /** * Disable a project (unload but keep configuration) */ async disableProject(projectId: string): Promise<void> { const project = this.projects.get(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.database.updateProjectState(projectId, { enabled: false, disabledAt: new Date() }); // 3. If this was the active project, switch to another one if (wasActive) { const remainingProjects = Array.from(this.projects.keys()); if (remainingProjects.length > 0) { await this.setActiveProject(remainingProjects[0]); } } // 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.projects.has(projectId)) { return; } const storedProject = await this.database.getProject(projectId); if (!storedProject) { throw new Error(`Project ${projectId} not found in database`); } // 1. Recreate filesystem via provider const provider = this.getProvider(storedProject.source.type); if (!provider) { throw new Error(`Provider ${storedProject.source.type} not registered`); } const fs = await provider.createFileSystem(storedProject.source.config); // 2. Reload in memory const project: Project = { id: storedProject.id, name: storedProject.name, source: storedProject.source, fs, metadata: storedProject.metadata || {}, state: "loaded" as ProjectState, lastAccessed: new Date(), accessCount: 0, memoryUsage: 0 }; this.projects.set(projectId, project); // 3. Update state in database await this.database.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.database.listProjects(); return allProjects.filter(p => p.enabled === false); } /** * Check if project is enabled */ isProjectEnabled(projectId: string): boolean { return this.projects.has(projectId); } /** * Copy files between projects */ async copyFiles( sourceId: string, pattern: string, targetId: string, targetPath: string ): Promise<void> { const sourceProject = this.projects.get(sourceId); const targetProject = this.projects.get(targetId); if (!sourceProject) throw new Error(`Source project ${sourceId} not found`); if (!targetProject) throw new Error(`Target project ${targetId} not found`); const files = await sourceProject.fs.glob(pattern); let copied = 0; for (const file of files) { const stat = await sourceProject.fs.stat(file); if (stat.type === "file") { const content = await sourceProject.fs.readFile(file); const targetFilePath = (targetPath + file).replace(/\/+/g, '/'); await targetProject.fs.writeFile(targetFilePath, content.content, content.metadata); copied++; } } this.events.emit("project:files-copied", { sourceId, targetId, fileCount: copied }); } /** * Sync projects */ async syncProjects( sourceId: string, targetId: string, options: SyncOptions = {} ): Promise<void> { const sourceProject = this.projects.get(sourceId); const targetProject = this.projects.get(targetId); if (!sourceProject) throw new Error(`Source project ${sourceId} not found`); if (!targetProject) throw new Error(`Target project ${targetId} not found`); const files = await sourceProject.fs.glob("**/*"); const total = files.length; let copied = 0; for (const file of files) { if (options.filter && !options.filter(file)) continue; const stat = await sourceProject.fs.stat(file); if (stat.type === "file") { const exists = await targetProject.fs.exists(file); if (!exists || options.overwrite) { const content = await sourceProject.fs.readFile(file); await targetProject.fs.writeFile(file, content.content, content.metadata); copied++; if (options.progress) { options.progress(copied, total); } } } } } /** * Compare projects */ async compareProjects(projectId1: string, projectId2: string): Promise<ProjectDiff> { const project1 = this.projects.get(projectId1); const project2 = this.projects.get(projectId2); if (!project1) throw new Error(`Project ${projectId1} not found`); if (!project2) throw new Error(`Project ${projectId2} not found`); const files1 = new Set(await project1.fs.glob("**/*")); const files2 = new Set(await project2.fs.glob("**/*")); const diff: ProjectDiff = { added: [], modified: [], deleted: [], unchanged: [], }; // Files in project2 but not in project1 (added) for (const file of files2) { if (!files1.has(file)) { diff.added.push(file); } } // Files in project1 but not in project2 (deleted) for (const file of files1) { if (!files2.has(file)) { diff.deleted.push(file); } } // Files in both - check if modified for (const file of files1) { if (files2.has(file)) { const stat1 = await project1.fs.stat(file); const stat2 = await project2.fs.stat(file); if (stat1.type === "file" && stat2.type === "file") { const content1 = await project1.fs.readFile(file); const content2 = await project2.fs.readFile(file); if (content1.content !== content2.content) { diff.modified.push(file); } else { diff.unchanged.push(file); } } } } return diff; } /** * Get workspace statistics */ async getProjectStats(): Promise<WorkspaceStats> { const allProjects = await this.database.listProjects(); const connections: Record<string, number> = {}; // Count connections by type for (const project of this.projects.values()) { const type = project.source.type; connections[type] = (connections[type] || 0) + 1; } // Calculate total memory usage let totalMemory = 0; for (const project of this.projects.values()) { totalMemory += project.memoryUsage || 0; } const disabledCount = allProjects.filter(p => p.enabled === false).length; return { total: allProjects.length, active: this.projects.size, disabled: disabledCount, focused: this.activeProjectId, memoryUsage: this.formatBytes(totalMemory), connections, }; } /** * Get project metrics */ async getProjectMetrics(projectId: string): Promise<ProjectMetrics> { const project = this.projects.get(projectId); if (!project) throw new Error(`Project ${projectId} not found`); const files = await project.fs.glob("**/*"); let totalSize = 0; let fileCount = 0; let largestFile = { path: "", size: 0 }; let lastModified = new Date(0); for (const file of files) { const stat = await project.fs.stat(file); if (stat.type === "file") { fileCount++; totalSize += stat.size; if (stat.size > largestFile.size) { largestFile = { path: file, size: stat.size }; } if (stat.modified > lastModified) { lastModified = stat.modified; } } } return { fileCount, totalSize, lastModified, accessCount: project.accessCount, averageFileSize: fileCount > 0 ? Math.round(totalSize / fileCount) : 0, largestFile, }; } /** * Optimize memory usage */ async optimizeMemoryUsage(): Promise<OptimizationReport> { const report: OptimizationReport = { projectsDisabled: [], memoryFreed: 0, connectionsReleased: 0, }; // Get current memory usage const stats = await this.getProjectStats(); const currentMemory = await this.calculateTotalMemoryUsage(); if (currentMemory < (this.settings.memoryThreshold || Infinity)) { return report; // No optimization needed } // Sort projects by last accessed (oldest first) const projectList = Array.from(this.projects.values()) .filter(p => p.id !== this.activeProjectId) // Never disable active project .sort((a, b) => a.lastAccessed.getTime() - b.lastAccessed.getTime()); // Disable projects until under threshold for (const project of projectList) { if (currentMemory - report.memoryFreed < (this.settings.memoryThreshold || Infinity)) { break; } const memoryBefore = project.memoryUsage || 0; await this.disableProject(project.id); report.projectsDisabled.push(project.id); report.memoryFreed += memoryBefore; report.connectionsReleased++; } return report; } /** * Get active project's file system (for proxying) */ private getActiveFS(): IReactiveFileSystem { const project = this.getActiveProject(); if (!project) { throw new Error("No active project"); } // Track access project.lastAccessed = new Date(); project.accessCount++; this.resetAutoDisableTimer(project.id); return project.fs; } /** * Reset auto-disable timer for a project */ private resetAutoDisableTimer(projectId: string): void { // Clear existing timer const existingTimer = this.autoDisableTimers.get(projectId); if (existingTimer) { clearTimeout(existingTimer); } // Don't set timer for focused project if configured if (this.settings.keepFocusedActive && this.activeProjectId === projectId) { return; } // Set new timer if auto-disable is configured if (this.settings.autoDisableAfter && this.settings.autoDisableAfter > 0) { const timer = setTimeout(() => { this.disableProject(projectId).catch(error => { console.error(`Failed to auto-disable project ${projectId}:`, error); }); }, this.settings.autoDisableAfter); this.autoDisableTimers.set(projectId, timer); } } /** * Estimate project memory usage */ private async estimateProjectMemoryUsage(fs: IReactiveFileSystem): Promise<number> { try { // Basic estimation - can be improved based on FS type const size = await fs.size(); // Add overhead for metadata and structures (rough estimate) return size * 1.2; } catch { return 0; } } /** * Calculate total memory usage */ private async calculateTotalMemoryUsage(): Promise<number> { let total = 0; for (const project of this.projects.values()) { total += project.memoryUsage || 0; } return total; } /** * Format bytes to human readable */ private formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } // Methods registerSourceLoader and getSourceLoader removed // Now using Provider Registry methods above /** * Setup credential providers */ private setupCredentialProviders(): void { // Register default providers const isNode = typeof window === "undefined"; if (isNode) { // Node.js environment - use environment variables this.credentialManager.registerProvider("s3", new EnvCredentialProvider()); this.credentialManager.registerProvider("github", new EnvCredentialProvider()); this.credentialManager.registerProvider("api", new EnvCredentialProvider()); } else { // Browser environment - use browser provider const browserProvider = new BrowserCredentialProvider(); this.credentialManager.registerProvider("s3", browserProvider); this.credentialManager.registerProvider("github", browserProvider); this.credentialManager.registerProvider("api", browserProvider); } } /** * 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()); } /** * Setup event forwarding from project to workspace */ private setupEventForwarding(project: Project): void { if (!project.fs.events) return; // Forward file system events with project context const events = [ FileSystemEvents.FILE_READING, FileSystemEvents.FILE_READ, FileSystemEvents.FILE_WRITING, FileSystemEvents.FILE_WRITTEN, FileSystemEvents.FILE_DELETING, FileSystemEvents.FILE_DELETED, FileSystemEvents.DIR_CREATING, FileSystemEvents.DIR_CREATED, FileSystemEvents.DIR_DELETING, FileSystemEvents.DIR_DELETED, ]; events.forEach((event) => { project.fs.events!.on(event, (payload: any) => { // Map to project-specific event const projectEvent = `project:${event.toLowerCase().replace(/_/g, ":")}` as keyof WorkspaceEventPayloads; // Add projectId to payload const enrichedPayload = { projectId: project.id, ...payload }; // Emit on workspace events (this.events as any).emit(projectEvent, enrichedPayload); }); }); } /** * Convert a project to a different source */ async convertProject( projectId: string, targetSource: ProjectSource, ): Promise<void> { const project = this.getProject(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } this.events.emit("project:converting", { projectId, targetSource }); // Get target provider const provider = this.getProvider(targetSource.type); if (!provider) { throw new Error(`No provider registered for type: ${targetSource.type}`); } // Create new file system const newFs = await provider.createFileSystem(targetSource.config); // Copy all files const paths = await project.fs.glob("**/*"); for (const path of paths) { const stat = await project.fs.stat(path); if (stat.type === "directory") { // Skip root directory if (path !== "/") { await newFs.mkdir(path, true); } } else { const file = await project.fs.readFile(path); await newFs.writeFile(path, file.content, file.metadata); } } // Replace project's file system project.fs = newFs; project.source = targetSource; // Re-setup event forwarding this.setupEventForwarding(project); this.events.emit("project:converted", { projectId, source: targetSource }); } /** * 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[] = Array.from(this.projects.values()).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 }; } // 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 = Array.from(this.projects.keys()); for (const projectId of projectIds) { await this.unloadProject(projectId); } // Clear all projects from database const allStoredProjects = await this.database.listProjects(); for (const project of allStoredProjects) { await this.database.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.getActiveFS().readFile(path); } async writeFile( path: string, content: any, metadata?: Record<string, any>, ): Promise<FileEntry> { return this.getActiveFS().writeFile(path, content, metadata); } async deleteFile(path: string): Promise<void> { return this.getActiveFS().deleteFile(path); } async mkdir(path: string, recursive?: boolean): Promise<FileEntry> { return this.getActiveFS().mkdir(path, recursive); } async rmdir(path: string, recursive?: boolean): Promise<void> { return this.getActiveFS().rmdir(path, recursive); } async exists(path: string): Promise<boolean> { return this.getActiveFS().exists(path); } async stat(path: string): Promise<FileStat> { return this.getActiveFS().stat(path); } async readDir(path: string): Promise<FileEntry[]> { return this.getActiveFS().readDir(path); } async rename(oldPath: string, newPath: string): Promise<FileEntry> { return this.getActiveFS().rename(oldPath, newPath); } async copy(sourcePath: string, targetPath: string): Promise<FileEntry> { return this.getActiveFS().copy(sourcePath, targetPath); } async move(sourcePaths: string[], targetPath: string): Promise<void> { return this.getActiveFS().move(sourcePaths, targetPath); } async glob(pattern: string): Promise<string[]> { return this.getActiveFS().glob(pattern); } watch(path: string, callback: (event: FSEvent) => void): Disposable { return this.getActiveFS().watch(path, callback); } watchGlob?(pattern: string, callback: (event: FSEvent) => void): Disposable { const fs = this.getActiveFS(); if ('watchGlob' in fs && typeof fs.watchGlob === 'function') { return fs.watchGlob(pattern, callback); } throw new Error('Active file system does not support watchGlob'); } async size(): Promise<number> { return this.getActiveFS().size(); } // Delegate permission methods to active FS async canModify(path: string): Promise<boolean> { return this.getActiveFS().canModify(path); } async canCreateIn(parentPath: string): Promise<boolean> { return this.getActiveFS().canCreateIn(parentPath); } /** * Check if a project exists and can be accessed */ private async isProjectAccessible(projectId: string): Promise<boolean> { try { const storedProject = await this.database.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; } } // Persistence methods /** * Restore workspace state from database */ private async restoreWorkspaceState(): Promise<boolean> { try { // Get stored state const state = await this.database.getWorkspaceState(); if (!state) return false; // Restore settings (but constructor settings take precedence) if (state.settings) { this.settings = { ...state.settings, ...this.settings }; } // Validate and clean orphaned references let stateChanged = false; // Validate activeProjectId if (state.activeProjectId) { const activeProjectAccessible = await this.isProjectAccessible(state.activeProjectId); if (!activeProjectAccessible) { console.warn(`Removing orphaned activeProjectId: ${state.activeProjectId}`); state.activeProjectId = null; stateChanged = true; } } // Validate recentProjectIds if (state.recentProjectIds && state.recentProjectIds.length > 0) { const validRecentIds: string[] = []; for (const projectId of state.recentProjectIds) { const projectAccessible = await this.isProjectAccessible(projectId); if (projectAccessible) { validRecentIds.push(projectId); } else { console.warn(`Removing orphaned recentProjectId: ${projectId}`); stateChanged = true; } } state.recentProjectIds = validRecentIds; } // Save cleaned state if any orphaned references were found if (stateChanged) { await this.database.saveWorkspaceState(state); console.info("Workspace state cleaned: removed orphaned project references"); } // Restore recent projects this.recentProjectIds = state.recentProjectIds || []; // Get stored projects const storedProjects = await this.database.listProjects(); // Load active project if still valid if (state.activeProjectId) { const activeProject = storedProjects.find(p => p.id === state.activeProjectId); if (activeProject) { try { await this.loadProjectFromStored(activeProject); 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; state.activeProjectId = null; await this.database.saveWorkspaceState(state); } } } return true; } catch (error) { console.error("Failed to restore workspace state:", error); return false; } } /** * Save current workspace state to database */ private async saveWorkspaceState(): Promise<void> { const state: WorkspaceState = { id: "current", activeProjectId: this.activeProjectId, recentProjectIds: this.recentProjectIds, settings: this.settings }; await this.database.saveWorkspaceState(state); } /** * Load a project from stored configuration */ private async loadProjectFromStored(stored: StoredProject): Promise<Project> { const config: ProjectConfig = { id: stored.id, name: stored.name, source: stored.source, metadata: stored.metadata }; return this.loadProject(config); } /** * List all registered projects from database */ async listStoredProjects(): Promise<StoredProject[]> { return this.database.listProjects(); } /** * List recent projects */ async listRecentProjects(limit = 10): Promise<StoredProject[]> { return this.database.listRecentProjects(limit); } /** * Delete a project from workspace and optionally delete its data */ async deleteProject(projectId: string, options: DeleteProjectOptions = {}): Promise<void> { const project = this.projects.get(projectId); if (!project && !await this.database.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.database.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.database.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.projects.has(projectId)) { await this.unloadProject(projectId); } // 4. Remove from database await this.database.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[]> { const dbNames = await this.database.discoverIndexedDBProjects(); return dbNames.map(dbName => ({ id: `discovered-${dbName}`, name: dbName.replace(/^(firesystem-|@firesystem\/)/i, ""), source: { type: "indexeddb", config: { dbName } } })); } /** * 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 }; } // 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 = Array.from(this.projects.values()); 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.database.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); } // Helper methods for creating projects with common patterns /** * Create a new IndexedDB project */ async createIndexedDBProject(name: string, dbName?: string): Promise<Project> { const source = SourceConfigBuilder.indexedDB(dbName); const config: ProjectConfig = { id: `idb-${Date.now()}`, name, source }; return this.loadProject(config); } /** * Create a new Memory project */ async createMemoryProject(name: string, initialData?: any): Promise<Project> { const source = SourceConfigBuilder.memory(initialData); const config: ProjectConfig = { id: `mem-${Date.now()}`, name, source }; return this.loadProject(config); } /** * Create a new S3 project */ async createS3Project( name: string, bucket: string, options?: { prefix?: string; region?: string; credentials?: { accessKeyId: string; secretAccessKey: string }; } ): Promise<Project> { const source = SourceConfigBuilder.s3({ bucket, prefix: options?.prefix, region: options?.region, credentials: options?.credentials }); const config: ProjectConfig = { id: `s3-${bucket}-${Date.now()}`, name, source }; return this.loadProject(config); } /** * Create a new GitHub project */ async createGitHubProject( name: string, owner: string, repo: string, options?: { branch?: string; path?: string; token?: string; } ): Promise<Project> { const source = SourceConfigBuilder.github({ owner, repo, branch: options?.branch, path: options?.path, token: options?.token }); const config: ProjectConfig = { id: `gh-${owner}-${repo}-${Date.now()}`, name, source }; return this.loadProject(config); } /** * Close workspace and database connection */ async close(): Promise<void> { await this.clear(); this.credentialManager.clearCache(); this.database.close(); } }