@workspace-fs/core
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
358 lines (307 loc) • 9.74 kB
text/typescript
import {
IReactiveFileSystem,
TypedEventEmitter,
FileSystemEvents,
} from "@firesystem/core";
import type {
Project,
ProjectConfig,
ProjectSource,
ProjectState,
WorkspaceEventPayloads,
} from "../types";
import type { SourceProvider } from "../interfaces/SourceProvider";
import { StoredProject } from "../WorkspaceDatabase";
import { CredentialManager } from "../credentials/CredentialManager";
export class ProjectManager {
private projects = new Map<string, Project>();
private autoDisableTimers = new Map<string, NodeJS.Timeout>();
constructor(
private events: TypedEventEmitter<WorkspaceEventPayloads>,
private getProvider: (scheme: string) => SourceProvider | undefined,
private credentialManager: CredentialManager,
private saveProjectToDb: (project: StoredProject) => Promise<void>,
private touchProjectInDb: (projectId: string) => Promise<void>,
private estimateProjectMemoryUsage: (fs: IReactiveFileSystem) => Promise<number>,
) {}
/**
* 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"}`,
);
}
}
// 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.saveProjectToDb(storedProject);
// Emit loaded event
this.events.emit("project:loaded", { project });
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 });
// 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());
}
/**
* Check if project exists
*/
hasProject(projectId: string): boolean {
return this.projects.has(projectId);
}
/**
* Update recent projects
*/
async updateRecentProjects(projectId: string, recentProjectIds: string[]): Promise<string[]> {
const updatedRecent = [
projectId,
...recentProjectIds.filter((id) => id !== projectId),
].slice(0, 10);
// Update database
await this.touchProjectInDb(projectId);
return updatedRecent;
}
/**
* Track project access
*/
trackProjectAccess(project: Project): void {
project.lastAccessed = new Date();
project.accessCount++;
}
/**
* Reset auto-disable timer for a project
*/
resetAutoDisableTimer(
projectId: string,
autoDisableAfter: number | undefined,
keepFocusedActive: boolean,
activeProjectId: string | null,
onAutoDisable: (projectId: string) => Promise<void>,
): void {
// Clear existing timer
const existingTimer = this.autoDisableTimers.get(projectId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Don't set timer for focused project if configured
if (keepFocusedActive && activeProjectId === projectId) {
return;
}
// Set new timer if auto-disable is configured
if (autoDisableAfter && autoDisableAfter > 0) {
const timer = setTimeout(() => {
onAutoDisable(projectId).catch((error) => {
console.error(`Failed to auto-disable project ${projectId}:`, error);
});
}, autoDisableAfter);
this.autoDisableTimers.set(projectId, timer);
}
}
/**
* 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 });
}
/**
* Load a project from stored configuration
*/
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);
}
/**
* Recreate filesystem via provider
*/
async recreateFileSystem(stored: StoredProject): Promise<IReactiveFileSystem> {
const provider = this.getProvider(stored.source.type);
if (!provider) {
throw new Error(`Provider ${stored.source.type} not registered`);
}
return provider.createFileSystem(stored.source.config);
}
/**
* Create project from stored with filesystem
*/
createProjectFromStored(
stored: StoredProject,
fs: IReactiveFileSystem,
): Project {
const project: Project = {
id: stored.id,
name: stored.name,
source: stored.source,
fs,
metadata: stored.metadata || {},
state: "loaded" as ProjectState,
lastAccessed: new Date(),
accessCount: 0,
memoryUsage: 0,
};
this.projects.set(project.id, project);
return project;
}
}