@firesystem/workspace
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
1,487 lines (1,255 loc) • 42 kB
text/typescript
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-|\/)/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();
}
}