189 lines
5.3 KiB
TypeScript
189 lines
5.3 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import path from 'node:path';
|
|
|
|
export interface OpencodeProject {
|
|
id: string;
|
|
path: string;
|
|
name: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastOpenedAt: string;
|
|
}
|
|
|
|
export interface ProjectStoreData {
|
|
projects: Record<string, OpencodeProject>;
|
|
activeProjectId: string | null;
|
|
}
|
|
|
|
export type ProjectStoreChange =
|
|
| { type: 'upsert'; project: OpencodeProject }
|
|
| { type: 'remove'; projectId: string };
|
|
|
|
export type ProjectStoreListener = (change: ProjectStoreChange) => void;
|
|
|
|
export interface ProjectStorage {
|
|
read(): Promise<ProjectStoreData | undefined>;
|
|
write(data: ProjectStoreData): Promise<void>;
|
|
}
|
|
|
|
export interface ProjectKeyValueStore {
|
|
get(key: string): unknown;
|
|
set(key: string, value: ProjectStoreData): unknown;
|
|
}
|
|
|
|
export type OpencodeProjectStore = ReturnType<typeof createProjectStore>;
|
|
|
|
const DEFAULT_PROJECT_STORE_KEY = 'projects';
|
|
|
|
export function normalizeProjectPath(input: string): string {
|
|
const normalized = path.normalize(path.resolve(input.trim()));
|
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
export function getProjectIdForPath(input: string): string {
|
|
const hash = createHash('sha256').update(normalizeProjectPath(input)).digest('hex');
|
|
return `prj_${hash.slice(0, 16)}`;
|
|
}
|
|
|
|
export function createMemoryProjectStorage(initial?: ProjectStoreData): ProjectStorage {
|
|
let stored = initial ? structuredClone(initial) : undefined;
|
|
|
|
return {
|
|
async read() {
|
|
return stored ? structuredClone(stored) : undefined;
|
|
},
|
|
async write(data) {
|
|
stored = structuredClone(data);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createProjectStorageFromStore(
|
|
store: ProjectKeyValueStore,
|
|
key = DEFAULT_PROJECT_STORE_KEY,
|
|
): ProjectStorage {
|
|
return {
|
|
async read() {
|
|
const data = store.get(key);
|
|
return data ? structuredClone(data) as ProjectStoreData : undefined;
|
|
},
|
|
async write(data) {
|
|
store.set(key, structuredClone(data));
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function createElectronProjectStorage(): Promise<ProjectStorage> {
|
|
const Store = (await import('electron-store')).default;
|
|
const store = new Store<{ projects?: ProjectStoreData }>({
|
|
name: 'opencode-projects',
|
|
});
|
|
return createProjectStorageFromStore(store);
|
|
}
|
|
|
|
function createEmptyData(): ProjectStoreData {
|
|
return {
|
|
projects: {},
|
|
activeProjectId: null,
|
|
};
|
|
}
|
|
|
|
async function readData(storage: ProjectStorage): Promise<ProjectStoreData> {
|
|
return (await storage.read()) ?? createEmptyData();
|
|
}
|
|
|
|
export function createProjectStore(storage: ProjectStorage) {
|
|
const listeners = new Set<ProjectStoreListener>();
|
|
|
|
function emit(change: ProjectStoreChange): void {
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener(change);
|
|
} catch {
|
|
// An observer must not break project persistence for other callers.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function upsertProjectRecord(
|
|
input: string,
|
|
options?: { activate?: boolean },
|
|
): Promise<OpencodeProject> {
|
|
const data = await readData(storage);
|
|
const normalizedPath = normalizeProjectPath(input);
|
|
const id = getProjectIdForPath(normalizedPath);
|
|
const now = new Date().toISOString();
|
|
const existing = data.projects[id];
|
|
const project: OpencodeProject = existing
|
|
? {
|
|
...existing,
|
|
path: normalizedPath,
|
|
name: path.basename(normalizedPath),
|
|
updatedAt: now,
|
|
lastOpenedAt: now,
|
|
}
|
|
: {
|
|
id,
|
|
path: normalizedPath,
|
|
name: path.basename(normalizedPath),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastOpenedAt: now,
|
|
};
|
|
|
|
data.projects[id] = project;
|
|
if (options?.activate) {
|
|
data.activeProjectId = id;
|
|
}
|
|
await storage.write(data);
|
|
emit({ type: 'upsert', project });
|
|
return project;
|
|
}
|
|
|
|
return {
|
|
async openFolder(input: string): Promise<OpencodeProject> {
|
|
return await upsertProjectRecord(input, { activate: true });
|
|
},
|
|
|
|
async rememberProject(input: string): Promise<OpencodeProject> {
|
|
return await upsertProjectRecord(input, { activate: false });
|
|
},
|
|
|
|
async listProjects(): Promise<OpencodeProject[]> {
|
|
const data = await readData(storage);
|
|
return Object.values(data.projects).sort((left, right) =>
|
|
right.lastOpenedAt.localeCompare(left.lastOpenedAt),
|
|
);
|
|
},
|
|
|
|
async getActiveProject(): Promise<OpencodeProject | null> {
|
|
const data = await readData(storage);
|
|
return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null;
|
|
},
|
|
|
|
async setActiveProject(projectId: string | null): Promise<OpencodeProject | null> {
|
|
const data = await readData(storage);
|
|
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> {
|
|
const data = await readData(storage);
|
|
delete data.projects[projectId];
|
|
if (data.activeProjectId === projectId) {
|
|
data.activeProjectId = null;
|
|
}
|
|
await storage.write(data);
|
|
emit({ type: 'remove', projectId });
|
|
},
|
|
|
|
subscribe(listener: ProjectStoreListener): () => void {
|
|
listeners.add(listener);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
},
|
|
};
|
|
}
|