blob: e649871b9ab0b7667737bbb5162f0ef0aec9c863 [file]
import { readFile, stat, writeFile } from 'node:fs/promises';
import { resolveProjectRoot } from '@maka/runtime';
export interface CurrentProjectSelection {
projectId: string | null | undefined;
path: string;
}
/**
* Owns the "current project root" selection shared across the app/window,
* git, workspace-search, and session-entry IPC surfaces. Project management
* updates the project id and path together, and
* every other surface reads that same selection snapshot.
*
* Extracted from the former `registerIpc()` closures so the state has one owner
* once the handlers are split across modules.
*/
export interface ProjectRootController {
/** Resolve the effective project root (selected → persisted → fallback). */
current(): Promise<string>;
/** Resolve the selected project association and its effective root together. */
currentSelection(): Promise<CurrentProjectSelection>;
/** Validate + resolve an explicit renderer-supplied path without selecting it. */
resolveExplicit(
projectPath: unknown,
): Promise<
| { ok: true; projectPath: string }
| { ok: false; reason: 'invalid-path' | 'not-found' }
>;
/** Adopt a project association and resolved root as one persisted selection. */
setSelection(projectId: string | null, projectPath: string): void;
}
export interface ProjectRootControllerDeps {
/** Absolute path of the JSON file that persists the last selected project. */
lastProjectPathFile: string;
/** Roots probed (in order) when nothing is selected or persisted. */
fallbackRoots: () => string[];
}
export function createProjectRootController(
deps: ProjectRootControllerDeps,
): ProjectRootController {
let selectedProject: CurrentProjectSelection | null = null;
async function loadPersistedProjectRoot(): Promise<CurrentProjectSelection | null> {
try {
const raw = await readFile(deps.lastProjectPathFile, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed.projectPath === 'string' && parsed.projectPath) {
await stat(parsed.projectPath);
return {
projectId:
typeof parsed.projectId === 'string' || parsed.projectId === null
? parsed.projectId
: undefined,
path: await resolveProjectRoot([parsed.projectPath]),
};
}
} catch {
// File missing, invalid, or points at a deleted directory.
}
return null;
}
const persistedProjectRootPromise = loadPersistedProjectRoot();
async function saveLastProjectPath(
projectPath: string,
projectId: string | null | undefined,
): Promise<void> {
try {
await writeFile(
deps.lastProjectPathFile,
JSON.stringify({
projectPath,
...(projectId !== undefined ? { projectId } : {}),
}),
'utf8',
);
} catch {
// Best-effort; failure should not block the selection.
}
}
async function current(): Promise<string> {
return (await currentSelection()).path;
}
async function currentSelection(): Promise<CurrentProjectSelection> {
if (selectedProject) return selectedProject;
const persistedProjectRoot = await persistedProjectRootPromise;
if (persistedProjectRoot) return (selectedProject = persistedProjectRoot);
return {
projectId: undefined,
path: await resolveProjectRoot(deps.fallbackRoots()),
};
}
async function resolveExplicit(projectPath: unknown): Promise<
| { ok: true; projectPath: string }
| { ok: false; reason: 'invalid-path' | 'not-found' }
> {
if (typeof projectPath !== 'string' || !projectPath) {
return { ok: false, reason: 'invalid-path' };
}
try {
await stat(projectPath);
} catch {
return { ok: false, reason: 'not-found' };
}
return { ok: true, projectPath: await resolveProjectRoot([projectPath]) };
}
function setSelection(projectId: string | null, projectPath: string): void {
selectedProject = { projectId, path: projectPath };
void saveLastProjectPath(projectPath, projectId);
}
return { current, currentSelection, resolveExplicit, setSelection };
}