import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { ProjectType } from '../../shared/project-config'; import type { CodingProjectSummary } from '../../shared/coding-project-contracts'; import { createCodingProjectMetadata, type CodingProjectConfigV2, } from './project-config'; export interface CodingProject extends CodingProjectSummary { path: string; } export interface CodingProjectStoreData { projects: Record; activeProjectId: string | null; } export type CodingProjectStoreChange = | { type: 'upsert'; project: CodingProject } | { type: 'remove'; projectId: string }; export type CodingProjectStoreListener = (change: CodingProjectStoreChange) => void; export interface CodingProjectStorage { read(): Promise; write(data: CodingProjectStoreData): Promise; } export interface CodingProjectKeyValueStore { get(key: string): unknown; set(key: string, value: CodingProjectStoreData): unknown; } export type CodingProjectStore = ReturnType; const DEFAULT_PROJECT_STORE_KEY = 'coding-projects-v2'; export function normalizeCodingProjectPath(input: string): string { const normalized = path.normalize(path.resolve(input.trim())); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } export function createMemoryCodingProjectStorage( initial?: CodingProjectStoreData, ): CodingProjectStorage { let stored = initial ? structuredClone(initial) : undefined; return { async read() { return stored ? structuredClone(stored) : undefined; }, async write(data) { stored = structuredClone(data); }, }; } export function createCodingProjectStorageFromStore( store: CodingProjectKeyValueStore, key = DEFAULT_PROJECT_STORE_KEY, ): CodingProjectStorage { return { async read() { const data = store.get(key); return data ? structuredClone(data) as CodingProjectStoreData : undefined; }, async write(data) { store.set(key, structuredClone(data)); }, }; } export async function createElectronCodingProjectStorage(): Promise { const Store = (await import('electron-store')).default; const store = new Store<{ projects?: CodingProjectStoreData }>({ // Legacy installed-user storage identity. Renaming it would orphan projects. name: 'opencode-projects', }); return createCodingProjectStorageFromStore(store, 'projects'); } function emptyStoreData(): CodingProjectStoreData { return { projects: {}, activeProjectId: null }; } export function createCodingProjectStore( storage: CodingProjectStorage, options: { createId?: () => string; now?: () => string; } = {}, ) { const createId = options.createId ?? randomUUID; const now = options.now ?? (() => new Date().toISOString()); let mutationTail = Promise.resolve(); const listeners = new Set(); function emit(change: CodingProjectStoreChange): void { for (const listener of listeners) { try { listener(change); } catch { // Persistence must not fail because an observer failed. } } } async function readData(): Promise { return (await storage.read()) ?? emptyStoreData(); } function mutate(operation: () => Promise): Promise { const result = mutationTail.then(operation, operation); mutationTail = result.then(() => undefined, () => undefined); return result; } async function upsertProject(input: string, activate: boolean): Promise { return await mutate(async () => { const data = await readData(); const normalizedPath = normalizeCodingProjectPath(input); const existing = Object.values(data.projects) .find((project) => project.path === normalizedPath); const timestamp = now(); const project: CodingProject = existing ? { ...existing, name: path.basename(normalizedPath), updatedAt: timestamp, lastOpenedAt: timestamp, } : { id: createId(), path: normalizedPath, name: path.basename(normalizedPath), createdAt: timestamp, updatedAt: timestamp, lastOpenedAt: timestamp, }; data.projects[project.id] = project; if (activate) data.activeProjectId = project.id; await storage.write(data); emit({ type: 'upsert', project }); return project; }); } return { async openFolder(input: string): Promise { return await upsertProject(input, true); }, async rememberProject(input: string): Promise { return await upsertProject(input, false); }, async listProjects(): Promise { await mutationTail; return Object.values((await readData()).projects) .sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt)); }, async getActiveProject(): Promise { await mutationTail; const data = await readData(); return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null; }, async setActiveProject(projectId: string | null): Promise { return await mutate(async () => { const data = await readData(); data.activeProjectId = projectId && data.projects[projectId] ? projectId : null; await storage.write(data); return data.activeProjectId ? data.projects[data.activeProjectId] : null; }); }, async removeProject(projectId: string): Promise { await mutate(async () => { const data = await readData(); delete data.projects[projectId]; if (data.activeProjectId === projectId) data.activeProjectId = null; await storage.write(data); emit({ type: 'remove', projectId }); }); }, subscribe(listener: CodingProjectStoreListener): () => void { listeners.add(listener); return () => listeners.delete(listener); }, }; } export async function createLocalCodingProject( input: { projectPath: string; projectType?: ProjectType; projectId?: string; now?: string; }, store: CodingProjectStore, ): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> { const config = await createCodingProjectMetadata(input.projectPath, { projectType: input.projectType, projectId: input.projectId, now: input.now, }); const project = await store.openFolder(input.projectPath); return { project, config }; }