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