singularity-forge/web/lib/project-store-manager.tsx
2026-05-05 14:46:18 +02:00

151 lines
4.3 KiB
TypeScript

"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { SFWorkspaceStore } from "./sf-workspace-store";
/**
* ProjectStoreManager maintains a Map<string, SFWorkspaceStore> of per-project
* stores with SSE lifecycle management. Only the active project's store keeps its
* SSE connection open — background stores are disconnected to save resources.
*
* Exposes a useSyncExternalStore-compatible interface for React components to
* reactively read the active project path.
*/
export class ProjectStoreManager {
private stores = new Map<string, SFWorkspaceStore>();
private activeProjectCwd: string | null = null;
private listeners = new Set<() => void>();
// ─── useSyncExternalStore interface ──────────────────────────────────────
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): string | null => this.activeProjectCwd;
// ─── Public API ──────────────────────────────────────────────────────────
getActiveStore(): SFWorkspaceStore | null {
if (!this.activeProjectCwd) return null;
return this.stores.get(this.activeProjectCwd) ?? null;
}
getActiveProjectCwd(): string | null {
return this.activeProjectCwd;
}
/**
* Switch to the given project. Disconnects SSE on the previous active store,
* creates a new store if needed (lazily), reconnects SSE on re-activated stores.
*/
switchProject(projectCwd: string): SFWorkspaceStore {
// Disconnect SSE on current active store
if (this.activeProjectCwd && this.activeProjectCwd !== projectCwd) {
const prev = this.stores.get(this.activeProjectCwd);
if (prev) prev.disconnectSSE();
}
// Get or create store for new project
let store = this.stores.get(projectCwd);
if (!store) {
store = new SFWorkspaceStore(projectCwd);
this.stores.set(projectCwd, store);
store.start();
} else {
// Reconnect SSE on re-activated store
store.reconnectSSE();
}
this.activeProjectCwd = projectCwd;
this.notify();
return store;
}
/** Dispose all stores and clear manager state. */
disposeAll(): void {
for (const store of this.stores.values()) {
store.dispose();
}
this.stores.clear();
this.activeProjectCwd = null;
this.notify();
}
/** Close a single project's store and switch to another if it was active. */
closeProject(projectCwd: string): void {
const store = this.stores.get(projectCwd);
if (!store) return;
store.dispose();
this.stores.delete(projectCwd);
// If we closed the active project, switch to another or clear
if (this.activeProjectCwd === projectCwd) {
const remaining = Array.from(this.stores.keys());
if (remaining.length > 0) {
// Switch to the first remaining project
const next = this.stores.get(remaining[0])!;
this.activeProjectCwd = remaining[0];
next.reconnectSSE();
} else {
this.activeProjectCwd = null;
}
}
this.notify();
}
/** Number of active project stores. */
getProjectCount(): number {
return this.stores.size;
}
/** Get all active project paths. */
getActiveProjectPaths(): string[] {
return Array.from(this.stores.keys());
}
private notify(): void {
for (const listener of this.listeners) listener();
}
}
// ─── React Context + Provider + Hook ──────────────────────────────────────
export const ProjectStoreManagerContext =
createContext<ProjectStoreManager | null>(null);
export function ProjectStoreManagerProvider({
children,
}: {
children: ReactNode;
}) {
const [manager] = useState(() => new ProjectStoreManager());
useEffect(() => {
return () => manager.disposeAll();
}, [manager]);
return (
<ProjectStoreManagerContext.Provider value={manager}>
{children}
</ProjectStoreManagerContext.Provider>
);
}
export function useProjectStoreManager(): ProjectStoreManager {
const mgr = useContext(ProjectStoreManagerContext);
if (!mgr)
throw new Error(
"useProjectStoreManager must be used within ProjectStoreManagerProvider",
);
return mgr;
}