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

211 lines
6.5 KiB
TypeScript

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<string, CodingProject>;
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<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 }>({
name: 'makelore-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<CodingProjectStoreListener>();
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<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);
emit({ type: 'upsert', project });
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);
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 };
}