Files
makelore/electron/coding-projects/project-store.ts

191 lines
5.8 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { ProjectType } from '../../shared/project-config';
import {
createCodingProjectMetadata,
type CodingProjectConfigV2,
} from './project-config';
export interface CodingProject {
id: string;
path: string;
name: string;
createdAt: string;
updatedAt: string;
lastOpenedAt: string;
}
export interface CodingProjectStoreData {
projects: Record<string, CodingProject>;
activeProjectId: string | null;
}
export interface CodingProjectStorage {
read(): Promise<CodingProjectStoreData | undefined>;
write(data: CodingProjectStoreData): Promise<void>;
}
export interface CodingProjectKeyValueStore {
get(key: string): unknown;
set(key: string, value: CodingProjectStoreData): unknown;
}
export type CodingProjectStore = ReturnType<typeof createCodingProjectStore>;
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<CodingProjectStorage> {
const Store = (await import('electron-store')).default;
const store = new Store<{ projects?: CodingProjectStoreData }>({
// Preserve the installed-user storage identity while replacing the
// OpenCode-owned service and types with the product-owned store.
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();
async function readData(): Promise<CodingProjectStoreData> {
return (await storage.read()) ?? emptyStoreData();
}
function mutate<T>(operation: () => Promise<T>): Promise<T> {
const result = mutationTail.then(operation, operation);
mutationTail = result.then(() => undefined, () => undefined);
return result;
}
async function upsertProject(input: string, activate: boolean): Promise<CodingProject> {
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);
return project;
});
}
return {
async openFolder(input: string): Promise<CodingProject> {
return await upsertProject(input, true);
},
async rememberProject(input: string): Promise<CodingProject> {
return await upsertProject(input, false);
},
async listProjects(): Promise<CodingProject[]> {
await mutationTail;
return Object.values((await readData()).projects)
.sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt));
},
async getActiveProject(): Promise<CodingProject | null> {
await mutationTail;
const data = await readData();
return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null;
},
async setActiveProject(projectId: string | null): Promise<CodingProject | null> {
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<void> {
await mutate(async () => {
const data = await readData();
delete data.projects[projectId];
if (data.activeProjectId === projectId) data.activeProjectId = null;
await storage.write(data);
});
},
};
}
export async function createLocalCodingProject(
input: {
projectPath: string;
projectType?: ProjectType;
now?: string;
},
store: CodingProjectStore,
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
const config = await createCodingProjectMetadata(input.projectPath, {
projectType: input.projectType,
now: input.now,
});
const project = await store.openFolder(input.projectPath);
return { project, config };
}