@workspace-fs/core
Version:
Multi-project workspace manager for Firesystem with support for multiple sources
1,734 lines (1,722 loc) • 62.9 kB
JavaScript
// src/WorkspaceFileSystem.ts
import {
TypedEventEmitter as TypedEventEmitter3
} from "@firesystem/core";
// src/WorkspaceDatabase.ts
var DB_NAME = "@firesystem/workspace";
var DB_VERSION = 1;
var WorkspaceDatabase = class {
constructor(dbName = DB_NAME, dbVersion = DB_VERSION) {
this.db = null;
this.dbName = dbName;
this.dbVersion = dbVersion;
}
/**
* Open or create the workspace database
*/
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error(`Failed to open database: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
if (!db.objectStoreNames.contains("projects")) {
const projectStore = db.createObjectStore("projects", {
keyPath: "id"
});
projectStore.createIndex("name", "name", { unique: false });
projectStore.createIndex("lastAccessed", "lastAccessed", {
unique: false
});
}
if (!db.objectStoreNames.contains("workspaceState")) {
db.createObjectStore("workspaceState", { keyPath: "id" });
}
}
};
});
}
/**
* Ensure database is open
*/
ensureOpen() {
if (!this.db) {
throw new Error("Database not opened. Call open() first.");
}
}
/**
* Close the database
*/
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
// Project CRUD operations
/**
* Save or update a project configuration
*/
async saveProject(project) {
this.ensureOpen();
const tx = this.db.transaction(["projects"], "readwrite");
const store = tx.objectStore("projects");
await new Promise((resolve, reject) => {
const request = store.put(project);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get a project by ID
*/
async getProject(id) {
this.ensureOpen();
const tx = this.db.transaction(["projects"], "readonly");
const store = tx.objectStore("projects");
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
* List all projects
*/
async listProjects() {
this.ensureOpen();
const tx = this.db.transaction(["projects"], "readonly");
const store = tx.objectStore("projects");
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
/**
* List recent projects
*/
async listRecentProjects(limit = 10) {
this.ensureOpen();
const tx = this.db.transaction(["projects"], "readonly");
const index = tx.objectStore("projects").index("lastAccessed");
const projects = [];
return new Promise((resolve, reject) => {
const request = index.openCursor(null, "prev");
request.onsuccess = () => {
const cursor = request.result;
if (cursor && projects.length < limit) {
projects.push(cursor.value);
cursor.continue();
} else {
resolve(projects);
}
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete a project
*/
async deleteProject(id) {
this.ensureOpen();
const tx = this.db.transaction(["projects"], "readwrite");
const store = tx.objectStore("projects");
await new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Update project last accessed time
*/
async touchProject(id) {
const project = await this.getProject(id);
if (project) {
project.lastAccessed = /* @__PURE__ */ new Date();
await this.saveProject(project);
}
}
/**
* Update project state
*/
async updateProjectState(id, updates) {
const project = await this.getProject(id);
if (project) {
Object.assign(project, updates);
await this.saveProject(project);
}
}
// Workspace state operations
/**
* Save workspace state
*/
async saveWorkspaceState(state) {
this.ensureOpen();
const tx = this.db.transaction(["workspaceState"], "readwrite");
const store = tx.objectStore("workspaceState");
await new Promise((resolve, reject) => {
const request = store.put(state);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get workspace state
*/
async getWorkspaceState() {
this.ensureOpen();
const tx = this.db.transaction(["workspaceState"], "readonly");
const store = tx.objectStore("workspaceState");
return new Promise((resolve, reject) => {
const request = store.get("current");
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
* Clear all data
*/
async clear() {
this.ensureOpen();
const tx = this.db.transaction(
["projects", "workspaceState"],
"readwrite"
);
await Promise.all([
new Promise((resolve, reject) => {
const request = tx.objectStore("projects").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
new Promise((resolve, reject) => {
const request = tx.objectStore("workspaceState").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
]);
}
/**
* Check if a project exists by checking IndexedDB databases
*/
static async projectDatabaseExists(dbName) {
if (!("databases" in indexedDB)) {
return new Promise((resolve) => {
const testOpen = indexedDB.open(dbName);
testOpen.onsuccess = () => {
const db = testOpen.result;
const exists = db.version > 0;
db.close();
resolve(exists);
};
testOpen.onerror = () => resolve(false);
});
}
const databases = await indexedDB.databases();
return databases.some((db) => db.name === dbName);
}
/**
* Discover existing Firesystem IndexedDB databases
*/
async discoverIndexedDBProjects() {
if (!("databases" in indexedDB)) {
console.warn("IndexedDB.databases() not supported in this browser");
return [];
}
const databases = await indexedDB.databases();
return databases.filter((db) => {
const name = db.name || "";
return name.startsWith("firesystem-") || name.startsWith("@firesystem/") || name.includes("-filesystem");
}).map((db) => db.name);
}
};
// src/import-export/WorkspaceImporter.ts
var WorkspaceImporter = class {
/**
* Import workspace from JSON URL
*/
static async fromJsonUrl(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch workspace: ${response.statusText}`);
}
const data = await response.json();
return this.parseExport(data);
}
/**
* Import workspace from GitHub Gist
*/
static async fromGitHubGist(gistId, token) {
const headers = {
Accept: "application/vnd.github.v3+json"
};
if (token) {
headers["Authorization"] = `token ${token}`;
}
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers
});
if (!response.ok) {
throw new Error(`Failed to fetch gist: ${response.statusText}`);
}
const gist = await response.json();
const workspaceFile = Object.values(gist.files).find(
(file) => file.filename === "workspace.json"
);
if (!workspaceFile) {
throw new Error("No workspace.json found in gist");
}
const data = JSON.parse(workspaceFile.content);
return this.parseExport(data);
}
/**
* Import workspace from API endpoint
*/
static async fromApi(url, options) {
const response = await fetch(url, {
method: options?.method || "GET",
headers: {
"Content-Type": "application/json",
...options?.headers
},
body: options?.body ? JSON.stringify(options.body) : void 0
});
if (!response.ok) {
throw new Error(`Failed to fetch from API: ${response.statusText}`);
}
const data = await response.json();
return this.parseExport(data);
}
/**
* Parse export data into workspace config
*/
static parseExport(data) {
if (!data.version || !data.version.startsWith("1.")) {
throw new Error(`Unsupported workspace version: ${data.version}`);
}
const projects = data.projects.map((p) => {
let source;
if (p.files && p.files.length > 0) {
source = {
type: "memory",
config: {
initialData: p.files.reduce(
(acc, file) => {
acc[file.path] = {
content: file.content,
metadata: file.metadata
};
return acc;
},
{}
)
}
};
} else if (p.type === "indexeddb") {
source = {
type: "indexeddb",
config: {
dbName: p.config?.dbName || `firesystem-${p.id}`
}
};
} else if (p.type === "s3") {
source = {
type: "s3",
config: p.config
};
} else {
source = {
type: "memory",
config: {}
};
}
return {
id: p.id,
name: p.name,
source
};
});
return {
version: data.version,
projects,
activeProjectId: data.workspace.activeProjectId,
settings: data.workspace.settings
};
}
};
// src/credentials/SourceConfigBuilder.ts
var SourceConfigBuilder = class {
/**
* Build IndexedDB source config
*/
static indexedDB(dbName) {
return {
type: "indexeddb",
config: {
dbName: dbName || `firesystem-${Date.now()}`
}
};
}
/**
* Build Memory source config
*/
static memory(initialData) {
return {
type: "memory",
config: {
initialData
}
};
}
/**
* Build S3 source config
*/
static s3(config) {
const source = {
type: "s3",
config: {
bucket: config.bucket,
prefix: config.prefix || "",
region: config.region || "us-east-1"
}
};
if (config.credentials) {
source.auth = {
type: "token",
credentials: config.credentials
};
}
return source;
}
/**
* Build GitHub source config
*/
static github(config) {
const source = {
type: "github",
config: {
owner: config.owner,
repo: config.repo,
branch: config.branch || "main",
path: config.path || "/"
}
};
if (config.token) {
source.auth = {
type: "bearer",
credentials: { token: config.token }
};
}
return source;
}
/**
* Build API source config
*/
static api(config) {
const source = {
type: "api",
config: {
baseUrl: config.baseUrl,
projectEndpoint: config.projectEndpoint || "/projects",
headers: config.headers
}
};
if (config.apiKey) {
source.auth = {
type: "token",
credentials: { apiKey: config.apiKey }
};
}
return source;
}
/**
* Validate source configuration
*/
static validate(source) {
const errors = [];
switch (source.type) {
case "indexeddb":
if (!source.config.dbName) {
errors.push("IndexedDB source requires dbName");
}
break;
case "s3":
if (!source.config.bucket) {
errors.push("S3 source requires bucket");
}
break;
case "github":
if (!source.config.owner || !source.config.repo) {
errors.push("GitHub source requires owner and repo");
}
break;
case "api":
if (!source.config.baseUrl) {
errors.push("API source requires baseUrl");
}
break;
case "memory":
break;
default:
errors.push(`Unknown source type: ${source.type}`);
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Sanitize source for export (remove credentials)
*/
static sanitize(source) {
const sanitized = { ...source };
delete sanitized.auth;
switch (source.type) {
case "s3":
sanitized.config = {
bucket: source.config.bucket,
prefix: source.config.prefix
};
break;
case "api":
const { headers, ...restConfig } = source.config;
sanitized.config = restConfig;
break;
// For other types (memory, indexeddb, github), preserve config as-is
default:
break;
}
return sanitized;
}
};
// src/import-export/WorkspaceExporter.ts
var WorkspaceExporter = class {
/**
* Export workspace to JSON
*/
static async toJson(projects, activeProjectId, settings, options = {}) {
const exportedProjects = await Promise.all(
projects.filter((p) => !options.projectIds || options.projectIds.includes(p.id)).map(async (p) => {
const exported = {
id: p.id,
name: p.name,
type: p.source.type
};
if (p.source.type === "s3") {
if (options.includeCredentials) {
exported.config = { ...p.source.config };
} else {
exported.config = {
bucket: p.source.config.bucket,
prefix: p.source.config.prefix
};
}
} else if (p.source.type === "indexeddb") {
exported.config = {
dbName: p.source.config.dbName
};
} else {
exported.config = options.includeCredentials ? { ...p.source.config } : SourceConfigBuilder.sanitize(p.source).config;
}
if (options.includeFiles && p.fs) {
exported.files = await this.extractFiles(p.fs);
}
return exported;
})
);
return {
version: "1.0",
exportedAt: /* @__PURE__ */ new Date(),
workspace: {
settings,
activeProjectId: activeProjectId || void 0
},
projects: exportedProjects
};
}
/**
* Export to GitHub Gist
*/
static async toGitHubGist(exportData, options) {
const response = await fetch("https://api.github.com/gists", {
method: "POST",
headers: {
Authorization: `token ${options.token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json"
},
body: JSON.stringify({
description: options.description || "Firesystem Workspace Export",
public: options.public ?? false,
files: {
"workspace.json": {
content: JSON.stringify(exportData, null, 2)
}
}
})
});
if (!response.ok) {
throw new Error(`Failed to create gist: ${response.statusText}`);
}
const gist = await response.json();
return gist.id;
}
/**
* Export to API endpoint
*/
static async toApi(exportData, url, options) {
const response = await fetch(url, {
method: options?.method || "POST",
headers: {
"Content-Type": "application/json",
...options?.headers
},
body: JSON.stringify(exportData)
});
if (!response.ok) {
throw new Error(`Failed to export to API: ${response.statusText}`);
}
}
/**
* Extract all files from a file system
*/
static async extractFiles(fs) {
const files = [];
try {
if (!fs.capabilities?.supportsGlob) {
console.warn(
"File system does not support glob operations, skipping file extraction"
);
return files;
}
const paths = await fs.glob("**/*");
for (const path of paths) {
try {
const stat = await fs.stat(path);
if (stat.type === "file") {
const file = await fs.readFile(path);
let content = file.content;
if (typeof content !== "string") {
content = JSON.stringify(content);
}
files.push({
path,
content,
metadata: file.metadata
});
}
} catch (error) {
console.warn(`Failed to export file ${path}:`, error);
}
}
} catch (error) {
console.warn("Failed to glob files:", error);
}
return files;
}
};
// src/credentials/CredentialManager.ts
var CredentialManager = class {
constructor() {
this.providers = /* @__PURE__ */ new Map();
this.memoryCache = /* @__PURE__ */ new Map();
}
/**
* Register a credential provider
*/
registerProvider(name, provider) {
this.providers.set(name, provider);
}
/**
* Get credentials for a project source
*/
async getCredentials(projectId, source) {
const cacheKey = `${projectId}:${source.type}`;
if (this.memoryCache.has(cacheKey)) {
return this.memoryCache.get(cacheKey);
}
const provider = this.providers.get(source.type);
if (provider) {
const credentials = await provider.getCredentials(projectId, source);
this.memoryCache.set(cacheKey, credentials);
return credentials;
}
if (source.auth) {
return this.extractCredentials(source.auth);
}
return {};
}
/**
* Extract credentials from auth config
*/
extractCredentials(auth) {
switch (auth.type) {
case "bearer":
return { token: auth.credentials.token };
case "basic":
return {
username: auth.credentials.username,
password: auth.credentials.password
};
case "token":
return { apiKey: auth.credentials.apiKey };
case "oauth2":
return {
accessToken: auth.credentials.accessToken,
refreshToken: auth.credentials.refreshToken
};
default:
return auth.credentials;
}
}
/**
* Clear cached credentials
*/
clearCache(projectId) {
if (projectId) {
for (const [key] of this.memoryCache) {
if (key.startsWith(`${projectId}:`)) {
this.memoryCache.delete(key);
}
}
} else {
this.memoryCache.clear();
}
}
};
var BrowserCredentialProvider = class {
constructor() {
this.dbName = "@firesystem/credentials";
this.storeName = "credentials";
}
async getCredentials(projectId, source) {
return source.config;
}
async storeCredentials(projectId, credentials) {
console.warn(
"Storing credentials in browser - consider security implications"
);
}
async removeCredentials(projectId) {
}
};
var EnvCredentialProvider = class {
async getCredentials(projectId, source) {
const prefix = `FIRESYSTEM_${source.type.toUpperCase()}_`;
switch (source.type) {
case "s3":
return {
accessKeyId: process.env[`${prefix}ACCESS_KEY_ID`],
secretAccessKey: process.env[`${prefix}SECRET_ACCESS_KEY`],
region: process.env[`${prefix}REGION`] || "us-east-1",
bucket: source.config.bucket
// Bucket from config
};
case "github":
return {
token: process.env[`${prefix}TOKEN`] || process.env.GITHUB_TOKEN
};
case "api":
return {
apiKey: process.env[`${prefix}API_KEY`],
baseUrl: source.config.baseUrl
};
default:
return source.config;
}
}
async storeCredentials() {
throw new Error("Cannot store credentials in environment variables");
}
async removeCredentials() {
throw new Error("Cannot remove credentials from environment variables");
}
};
var InteractiveCredentialProvider = class {
constructor(prompter) {
this.prompter = prompter;
}
async getCredentials(projectId, source) {
console.log(`Credentials needed for ${source.type} project: ${projectId}`);
switch (source.type) {
case "s3":
return {
accessKeyId: await this.prompter("AWS Access Key ID:"),
secretAccessKey: await this.prompter("AWS Secret Access Key:", true),
region: await this.prompter("AWS Region (default: us-east-1):") || "us-east-1",
bucket: source.config.bucket
};
case "github":
return {
token: await this.prompter("GitHub Personal Access Token:", true)
};
case "api":
return {
apiKey: await this.prompter("API Key:", true),
baseUrl: source.config.baseUrl
};
default:
return source.config;
}
}
async storeCredentials() {
}
async removeCredentials() {
}
};
// src/managers/ProjectManager.ts
import {
FileSystemEvents
} from "@firesystem/core";
var ProjectManager = class {
constructor(events, getProvider, credentialManager, saveProjectToDb, touchProjectInDb, estimateProjectMemoryUsage) {
this.events = events;
this.getProvider = getProvider;
this.credentialManager = credentialManager;
this.saveProjectToDb = saveProjectToDb;
this.touchProjectInDb = touchProjectInDb;
this.estimateProjectMemoryUsage = estimateProjectMemoryUsage;
this.projects = /* @__PURE__ */ new Map();
this.autoDisableTimers = /* @__PURE__ */ new Map();
}
/**
* Load a project into the workspace
*/
async loadProject(config) {
this.events.emit("project:loading", { projectId: config.id });
try {
if (this.projects.has(config.id)) {
throw new Error(`Project ${config.id} already loaded`);
}
const provider = this.getProvider(config.source.type);
if (!provider) {
throw new Error(
`No provider registered for type: ${config.source.type}`
);
}
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"}`
);
}
}
const credentials = await this.credentialManager.getCredentials(
config.id,
config.source
);
const sourceWithCredentials = {
...config.source,
config: {
...config.source.config,
...credentials
}
};
const fs = await provider.createFileSystem(sourceWithCredentials.config);
const project = {
id: config.id,
name: config.name,
source: config.source,
fs,
metadata: {
created: /* @__PURE__ */ new Date(),
modified: /* @__PURE__ */ new Date(),
lastOpened: /* @__PURE__ */ new Date(),
...config.metadata
},
state: "loaded",
lastAccessed: /* @__PURE__ */ new Date(),
accessCount: 0,
memoryUsage: await this.estimateProjectMemoryUsage(fs)
};
this.setupEventForwarding(project);
this.projects.set(project.id, project);
const storedProject = {
id: project.id,
name: project.name,
source: project.source,
metadata: project.metadata,
lastAccessed: /* @__PURE__ */ new Date()
};
await this.saveProjectToDb(storedProject);
this.events.emit("project:loaded", { project });
return project;
} catch (error) {
this.events.emit("project:error", {
projectId: config.id,
error
});
throw error;
}
}
/**
* Unload a project from the workspace
*/
async unloadProject(projectId) {
const project = this.projects.get(projectId);
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
this.events.emit("project:unloading", { projectId });
if (project.fs.events) {
}
this.projects.delete(projectId);
this.events.emit("project:unloaded", { projectId });
}
/**
* Get a project by ID
*/
getProject(projectId) {
return this.projects.get(projectId) || null;
}
/**
* Get all loaded projects
*/
getProjects() {
return Array.from(this.projects.values());
}
/**
* Check if project exists
*/
hasProject(projectId) {
return this.projects.has(projectId);
}
/**
* Update recent projects
*/
async updateRecentProjects(projectId, recentProjectIds) {
const updatedRecent = [
projectId,
...recentProjectIds.filter((id) => id !== projectId)
].slice(0, 10);
await this.touchProjectInDb(projectId);
return updatedRecent;
}
/**
* Track project access
*/
trackProjectAccess(project) {
project.lastAccessed = /* @__PURE__ */ new Date();
project.accessCount++;
}
/**
* Reset auto-disable timer for a project
*/
resetAutoDisableTimer(projectId, autoDisableAfter, keepFocusedActive, activeProjectId, onAutoDisable) {
const existingTimer = this.autoDisableTimers.get(projectId);
if (existingTimer) {
clearTimeout(existingTimer);
}
if (keepFocusedActive && activeProjectId === projectId) {
return;
}
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
*/
setupEventForwarding(project) {
if (!project.fs.events) return;
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) => {
const projectEvent = `project:${event.toLowerCase().replace(/_/g, ":")}`;
const enrichedPayload = { projectId: project.id, ...payload };
this.events.emit(projectEvent, enrichedPayload);
});
});
}
/**
* Convert a project to a different source
*/
async convertProject(projectId, targetSource) {
const project = this.getProject(projectId);
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
this.events.emit("project:converting", { projectId, targetSource });
const provider = this.getProvider(targetSource.type);
if (!provider) {
throw new Error(`No provider registered for type: ${targetSource.type}`);
}
const newFs = await provider.createFileSystem(targetSource.config);
const paths = await project.fs.glob("**/*");
for (const path of paths) {
const stat = await project.fs.stat(path);
if (stat.type === "directory") {
if (path !== "/") {
await newFs.mkdir(path, true);
}
} else {
const file = await project.fs.readFile(path);
await newFs.writeFile(path, file.content, file.metadata);
}
}
project.fs = newFs;
project.source = targetSource;
this.setupEventForwarding(project);
this.events.emit("project:converted", { projectId, source: targetSource });
}
/**
* Load a project from stored configuration
*/
async loadProjectFromStored(stored) {
const config = {
id: stored.id,
name: stored.name,
source: stored.source,
metadata: stored.metadata
};
return this.loadProject(config);
}
/**
* Recreate filesystem via provider
*/
async recreateFileSystem(stored) {
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, fs) {
const project = {
id: stored.id,
name: stored.name,
source: stored.source,
fs,
metadata: stored.metadata || {},
state: "loaded",
lastAccessed: /* @__PURE__ */ new Date(),
accessCount: 0,
memoryUsage: 0
};
this.projects.set(project.id, project);
return project;
}
};
// src/managers/PerformanceManager.ts
var PerformanceManager = class {
constructor(settings, getProjects, getActiveProjectId, disableProject) {
this.settings = settings;
this.getProjects = getProjects;
this.getActiveProjectId = getActiveProjectId;
this.disableProject = disableProject;
}
/**
* Get workspace statistics
*/
async getProjectStats(totalProjectsCount, disabledCount) {
const connections = {};
for (const project of this.getProjects()) {
const type = project.source.type;
connections[type] = (connections[type] || 0) + 1;
}
let totalMemory = 0;
for (const project of this.getProjects()) {
totalMemory += project.memoryUsage || 0;
}
return {
total: totalProjectsCount,
active: this.getProjects().length,
disabled: disabledCount,
focused: this.getActiveProjectId(),
memoryUsage: this.formatBytes(totalMemory),
connections
};
}
/**
* Get project metrics
*/
async getProjectMetrics(project) {
const files = await project.fs.glob("**/*");
let totalSize = 0;
let fileCount = 0;
let largestFile = { path: "", size: 0 };
let lastModified = /* @__PURE__ */ 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() {
const report = {
projectsDisabled: [],
memoryFreed: 0,
connectionsReleased: 0
};
const currentMemory = await this.calculateTotalMemoryUsage();
if (currentMemory < (this.settings.memoryThreshold || Infinity)) {
return report;
}
const projectList = Array.from(this.getProjects()).filter((p) => p.id !== this.getActiveProjectId()).sort((a, b) => a.lastAccessed.getTime() - b.lastAccessed.getTime());
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;
}
/**
* Estimate project memory usage
*/
async estimateProjectMemoryUsage(fs) {
try {
const size = await fs.size();
return size * 1.2;
} catch {
return 0;
}
}
/**
* Calculate total memory usage
*/
async calculateTotalMemoryUsage() {
let total = 0;
for (const project of this.getProjects()) {
total += project.memoryUsage || 0;
}
return total;
}
/**
* Format bytes to human readable
*/
formatBytes(bytes) {
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]}`;
}
/**
* Update settings
*/
updateSettings(settings) {
Object.assign(this.settings, settings);
}
};
// src/managers/PersistenceManager.ts
var PersistenceManager = class {
constructor(database, checkProjectAccessible) {
this.database = database;
this.checkProjectAccessible = checkProjectAccessible;
}
/**
* Restore workspace state from database
*/
async restoreWorkspaceState() {
try {
const state = await this.database.getWorkspaceState();
if (!state) return { state: null, activeProjectToLoad: null };
let stateChanged = false;
if (state.activeProjectId) {
const activeProjectAccessible = await this.checkProjectAccessible(
state.activeProjectId
);
if (!activeProjectAccessible) {
console.warn(
`Removing orphaned activeProjectId: ${state.activeProjectId}`
);
state.activeProjectId = null;
stateChanged = true;
}
}
if (state.recentProjectIds && state.recentProjectIds.length > 0) {
const validRecentIds = [];
for (const projectId of state.recentProjectIds) {
const projectAccessible = await this.checkProjectAccessible(projectId);
if (projectAccessible) {
validRecentIds.push(projectId);
} else {
console.warn(`Removing orphaned recentProjectId: ${projectId}`);
stateChanged = true;
}
}
state.recentProjectIds = validRecentIds;
}
if (stateChanged) {
await this.database.saveWorkspaceState(state);
console.info(
"Workspace state cleaned: removed orphaned project references"
);
}
let activeProjectToLoad = null;
if (state.activeProjectId) {
const storedProjects = await this.database.listProjects();
activeProjectToLoad = storedProjects.find(
(p) => p.id === state.activeProjectId
) || null;
}
return { state, activeProjectToLoad };
} catch (error) {
console.error("Failed to restore workspace state:", error);
return { state: null, activeProjectToLoad: null };
}
}
/**
* Save current workspace state to database
*/
async saveWorkspaceState(activeProjectId, recentProjectIds, settings) {
const state = {
id: "current",
activeProjectId,
recentProjectIds,
settings
};
await this.database.saveWorkspaceState(state);
}
/**
* Save project to database
*/
async saveProject(project) {
await this.database.saveProject(project);
}
/**
* Touch project in database
*/
async touchProject(projectId) {
await this.database.touchProject(projectId);
}
/**
* Get project from database
*/
async getProject(projectId) {
return this.database.getProject(projectId);
}
/**
* Delete project from database
*/
async deleteProject(projectId) {
await this.database.deleteProject(projectId);
}
/**
* Update project state in database
*/
async updateProjectState(projectId, state) {
await this.database.updateProjectState(projectId, state);
}
/**
* List all projects from database
*/
async listProjects() {
return this.database.listProjects();
}
/**
* List recent projects
*/
async listRecentProjects(limit = 10) {
return this.database.listRecentProjects(limit);
}
/**
* Discover existing IndexedDB projects
*/
async discoverIndexedDBProjects() {
const dbNames = await this.database.discoverIndexedDBProjects();
return dbNames.map((dbName) => ({
id: `discovered-${dbName}`,
name: dbName.replace(/^(firesystem-|@firesystem\/)/i, ""),
source: {
type: "indexeddb",
config: { dbName }
}
}));
}
/**
* Open database
*/
async open() {
await this.database.open();
}
/**
* Close database
*/
close() {
this.database.close();
}
};
// src/managers/EventManager.ts
import {
FileSystemEvents as FileSystemEvents2
} from "@firesystem/core";
var EventManager = class {
constructor(events) {
this.events = events;
}
/**
* Setup event forwarding from project to workspace
*/
setupEventForwarding(project) {
if (!project.fs.events) return;
const events = [
FileSystemEvents2.FILE_READING,
FileSystemEvents2.FILE_READ,
FileSystemEvents2.FILE_WRITING,
FileSystemEvents2.FILE_WRITTEN,
FileSystemEvents2.FILE_DELETING,
FileSystemEvents2.FILE_DELETED,
FileSystemEvents2.DIR_CREATING,
FileSystemEvents2.DIR_CREATED,
FileSystemEvents2.DIR_DELETING,
FileSystemEvents2.DIR_DELETED
];
events.forEach((event) => {
project.fs.events.on(event, (payload) => {
const projectEvent = `project:${event.toLowerCase().replace(/_/g, ":")}`;
const enrichedPayload = { projectId: project.id, ...payload };
this.events.emit(projectEvent, enrichedPayload);
});
});
}
/**
* Emit workspace event
*/
emit(event, payload) {
this.events.emit(event, payload);
}
};
// src/operations/ProjectOperations.ts
var ProjectOperations = class {
constructor(getProject, events) {
this.getProject = getProject;
this.events = events;
}
/**
* Copy files between projects
*/
async copyFiles(sourceId, pattern, targetId, targetPath) {
const sourceProject = this.getProject(sourceId);
const targetProject = this.getProject(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, targetId, options = {}) {
const sourceProject = this.getProject(sourceId);
const targetProject = this.getProject(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, projectId2) {
const project1 = this.getProject(projectId1);
const project2 = this.getProject(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 = {
added: [],
modified: [],
deleted: [],
unchanged: []
};
for (const file of files2) {
if (!files1.has(file)) {
diff.added.push(file);
}
}
for (const file of files1) {
if (!files2.has(file)) {
diff.deleted.push(file);
}
}
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;
}
};
// src/operations/FileSystemProxy.ts
var FileSystemProxy = class {
constructor(getActiveProject, trackProjectAccess, resetAutoDisableTimer) {
this.getActiveProject = getActiveProject;
this.trackProjectAccess = trackProjectAccess;
this.resetAutoDisableTimer = resetAutoDisableTimer;
}
/**
* Get active project's file system (for proxying)
*/
getActiveFS() {
const project = this.getActiveProject();
if (!project) {
throw new Error("No active project");
}
this.trackProjectAccess(project);
this.resetAutoDisableTimer(project.id);
return project.fs;
}
// IReactiveFileSystem implementation (proxy to active project)
async readFile(path) {
return this.getActiveFS().readFile(path);
}
async writeFile(path, content, metadata) {
return this.getActiveFS().writeFile(path, content, metadata);
}
async deleteFile(path) {
return this.getActiveFS().deleteFile(path);
}
async mkdir(path, recursive) {
return this.getActiveFS().mkdir(path, recursive);
}
async rmdir(path, recursive) {
return this.getActiveFS().rmdir(path, recursive);
}
async exists(path) {
return this.getActiveFS().exists(path);
}
async stat(path) {
return this.getActiveFS().stat(path);
}
async readDir(path) {
return this.getActiveFS().readDir(path);
}
async rename(oldPath, newPath) {
return this.getActiveFS().rename(oldPath, newPath);
}
async copy(sourcePath, targetPath) {
return this.getActiveFS().copy(sourcePath, targetPath);
}
async move(sourcePaths, targetPath) {
return this.getActiveFS().move(sourcePaths, targetPath);
}
async glob(pattern) {
return this.getActiveFS().glob(pattern);
}
watch(path, callback) {
return this.getActiveFS().watch(path, callback);
}
watchGlob(pattern, callback) {
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() {
return this.getActiveFS().size();
}
// Delegate permission methods to active FS
async canModify(path) {
return this.getActiveFS().canModify(path);
}
async canCreateIn(parentPath) {
return this.getActiveFS().canCreateIn(parentPath);
}
};
// src/WorkspaceFileSystem.ts
var WorkspaceFileSystem = class {
constructor(config) {
this.activeProjectId = null;
this.providers = /* @__PURE__ */ new Map();
this.recentProjectIds = [];
this.settings = {
maxActiveProjects: 10,
autoDisableAfter: 30 * 60 * 1e3,
// 30 minutes
keepFocusedActive: true,
autoSave: false,
autoSaveInterval: 6e4,
// 1 minute
memoryThreshold: 500 * 1024 * 1024
// 500MB
};
/**
* Event system for workspace events
*/
this.events = new TypedEventEmitter3();
this.database = new WorkspaceDatabase();
this.credentialManager = new CredentialManager();
if (config?.settings) {
this.settings = { ...this.settings, ...config.settings };
}
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) {
this.events.emit("workspace:initializing", void 0);
await this.persistenceManager.open();
const { state, activeProjectToLoad } = await this.persistenceManager.restoreWorkspaceState();
if (state) {
if (state.settings) {
this.settings = { ...state.settings, ...this.settings };
this.performanceManager.updateSettings(this.settings);
}
this.recentProjectIds = state.recentProjectIds || [];
if (activeProjectToLoad) {
try {
await this.projectManager.loadProjectFromStored(activeProjectToLoad);
this.activeProjectId = state.activeProjectId;
} catch (error) {
console.warn("Failed to restore active project:", error);
this.activeProjectId = null;
await this.saveWorkspaceState();
}
}
}
if (config) {
for (const projectConfig of config.projects) {
await this.loadProject(projectConfig);
}
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) {
const project = await this.projectManager.loadProject(config);
if (!this.activeProjectId) {
await this.setActiveProject(project.id);
}
return project;
}
/**
* Unload a project from the workspace
*/
async unloadProject(projectId) {
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) {
return this.projectManager.getProject(projectId);
}
/**
* Get all loaded projects
*/
getProjects() {
return this.projectManager.getProjects();
}
/**
* Set the active project
*/
async setActiveProject(projectId) {
const project = this.projectManager.getProject(projectId);
if (!project) {
throw new Error(`Project ${projectId} not found`);
}
const previousId = this.activeProjectId;
this.activeProjectId = projectId;
this.recentProjectIds = await this.projectManager.updateRecentProjects(
projectId,
this.recentProjectIds
);
await this.saveWorkspaceState();
this.events.emit("project:activated", {
projectId,
previousId: previousId || void 0
});
}
/**
* Get the active project
*/
getActiveProject() {
if (!this.activeProjectId) return null;
return this.projectManager.getProject(this.activeProjectId);
}
/**
* Disable a project (unload but keep configuration)
*/
async disableProject(projectId) {
const project = this.projectManager.getProject(projectId);
if (!project) return;
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 });
const provider = this.getProvider(project.source.type);
const hasLocalData = provider?.hasLocalData?.() || false;
const wasActive = this.activeProjectId === projectId;
await this.unloadProject(projectId);
await this.persistenceManager.updateProjectState(projectId, {
enabled: false,
disabledAt: /* @__PURE__ */ new Date()
});
if (wasActive) {
const remainingProjects = this.projectManager.getProjects();
if (remainingProjects.length > 0) {
await this.setActiveProject(remainingProjects[0].id);
}
}
this.events.emit("project:disabled", {
projectId,
hasLocalData,
reason: "manual"
});
}
/**
* Enable a disabled project
*/
async enableProject(projectId) {
if (this.projectManager.hasProject(projectId)) {
return;
}
const storedProject = await this.persistenceManager.getProject(projectId);
if (!storedProject) {
throw new Error(`Project ${projectId} not found in database`);
}
const fs = await this.projectManager.recreateFileSystem(storedProject);
const project = this.projectM