209 lines
6.4 KiB
TypeScript
209 lines
6.4 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;
|
|
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 };
|
|
}
|