Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { test } from 'node:test';
import { build } from 'esbuild';
import type { ProjectRecord } from '@maka/core/project';
import type * as ProjectActions from '../../renderer/app-shell-project-actions.js';

const REPO_ROOT = resolve(import.meta.dirname, '../../../../..');
Expand All @@ -49,7 +50,7 @@ function createTestProjectActions(
projects: [],
projectCapabilities: NO_PROJECT_CAPABILITIES,
onProjectSelected: () => {},
toastApi: { success: () => {}, error: () => {} },
toastApi: { success: () => {}, error: () => {}, confirm: async () => true },
...overrides,
});
}
Expand Down Expand Up @@ -98,6 +99,7 @@ test('Project errors preserve the Host authority of the failed operation', async
error: (_title: string, _description?: string, _details?: string, target?: unknown) => {
diagnosticTargets.push(target);
},
confirm: async () => false,
};
globalThis.window = {
maka: {
Expand Down Expand Up @@ -170,6 +172,91 @@ test('a Project mutation refresh stays bound to the operation Host', async () =>
}
});

test('Add on an archived directory: Host selection truth and UI claims must agree', async () => {
const actionsModule = await importProjectActions();
const previousWindow = globalThis.window;
const catalog = new Map<string, ProjectRecord>([
['project-a', { id: 'project-a', name: 'A', preferredPath: '/tmp/a', available: true } as ProjectRecord],
]);
// First-principles ground truth: the Host owns the selected project.
const host = { selectedProjectId: 'project-a' as string | null };
const successClaims: string[] = [];
const toastApi = {
success: (_title: string, description?: string) => {
successClaims.push(description ?? '');
},
error: () => {},
confirm: async () => true,
};
const fakeWindow = (hostSelects: boolean) => ({
maka: {
runtimeHostProfiles: {
getDefaultHost: async () => ({ profileId: 'default-profile', hostId: 'default-host' }),
},
projects: {
add: async () => ({
ok: false as const,
reason: 'archived' as const,
projectId: 'project-b',
}),
restore: async (projectId: string) => {
const restored = {
id: projectId,
name: 'B',
preferredPath: '/tmp/b',
available: true,
} as ProjectRecord;
catalog.set(projectId, restored);
return restored;
},
select: async (projectId: string | null) => {
if (!hostSelects) return { project: null, path: '' };
host.selectedProjectId = projectId;
const selected = projectId === null ? null : catalog.get(projectId) ?? null;
return { project: selected, path: selected?.preferredPath ?? '' };
},
},
app: {
resolveProjectGitInfo: async (projectPath: string) => ({
ok: true as const,
projectPath,
projectGit: { isGitRepo: false },
}),
},
},
});

try {
const actions = createTestProjectActions(actionsModule, {
projectCapabilities: { ...NO_PROJECT_CAPABILITIES, chooseClientDirectory: true },
refreshDefaultProjectState: async () => [],
toastApi,
});

// User path: A selected, Add picks archived B, user confirms restore.
globalThis.window = fakeWindow(true) as unknown as Window & typeof globalThis;
const project = await actions.addProject();

// Truth: the Host now runs B, not A.
assert.equal(host.selectedProjectId, 'project-b');
// Claims: the returned record and the success toast say the same thing.
assert.equal(project?.id, 'project-b');
assert.deepEqual(successClaims, ['B']);

// Counterfactual: the Host refuses selection, so truth stays A.
host.selectedProjectId = 'project-a';
catalog.delete('project-b');
successClaims.length = 0;
globalThis.window = fakeWindow(false) as unknown as Window & typeof globalThis;

assert.equal(await actions.addProject(), null);
assert.equal(host.selectedProjectId, 'project-a');
assert.deepEqual(successClaims, [], 'no success claim when Host truth did not change');
} finally {
globalThis.window = previousWindow;
}
});

async function importProjectActions(): Promise<typeof ProjectActions> {
const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/project-actions-'));
const outfile = resolve(outdir, 'app-shell-project-actions.mjs');
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/__tests__/project-management-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ test('adding a nested folder selects that folder instead of the parent project',
}
});

test('re-adding an archived project reports the archived project instead of failing', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-archived-add-'));
const projectPath = join(base, 'archived-project');
await mkdir(projectPath);
const selectedPaths: string[] = [];
const catalog = createProjectCatalog(join(base, 'storage'), {
now: () => 1_000,
createId: () => 'project-1',
});
const service = createProjectManagementService({
capabilities: LOCAL_CAPABILITIES,
catalog: managementCatalog(catalog),
chooseDirectory: async () => projectPath,
selection: {
currentSelection: async () => ({ projectId: undefined, path: base }),
setSelection: (_projectId, path) => selectedPaths.push(path),
},
});

try {
const first = await service.add();
assert.equal(first.ok, true);
await service.archive('project-1');

const second = await service.add();
assert.deepEqual(second, { ok: false, reason: 'archived', projectId: 'project-1' });
assert.equal(selectedPaths.length, 1);
} finally {
catalog.close();
await rm(base, { recursive: true, force: true });
}
});

test('can register a draft Project without changing the Host selection', async () => {
let selected = false;
const service = createProjectManagementService({
Expand Down
92 changes: 90 additions & 2 deletions apps/desktop/src/main/__tests__/task-entry-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { deferred } from '@maka/core/test-only/async-primitives';
import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import { LocaleProvider, ToastProvider } from '@maka/ui';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
createFakeTaskEntryServices,
Expand Down Expand Up @@ -97,10 +97,14 @@ function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog {
}
let latestController: TaskEntryController | undefined;

function ControllerProbe(props: { reportError(error: unknown): void }) {
function ControllerProbe(props: {
reportError(error: unknown): void;
confirm?(input: { title: string }): Promise<boolean>;
}) {
latestController = useTaskEntryController({
reportError: props.reportError,
manageProjects() {},
...(props.confirm ? { confirm: props.confirm } : {}),
});
return null;
}
Expand All @@ -114,17 +118,23 @@ function renderController(
root: ReturnType<typeof installReactRenderer>['root'],
services: TaskEntryServices,
errors: unknown[] = [],
confirm?: (input: { title: string }) => Promise<boolean>,
) {
root.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(
ToastProvider,
null,
createElement(
TaskEntryServicesProvider,
{ services },
createElement(ControllerProbe, {
reportError: (error: unknown) => errors.push(error),
confirm,
}),
),
),
}),
);
}
Expand Down Expand Up @@ -272,6 +282,84 @@ describe('useTaskEntryController', () => {
assert.equal(controller().selectors.workspacePicker.pending, false);
});

it('prompts to restore an archived Project and selects it after confirmation', async () => {
const { root } = installReactRenderer();
let reads = 0;
let restoreCalls = 0;
const refreshedHost = readyHost({
projects: [project('project-a'), project('project-b')],
selectedProjectId: 'project-a',
});
const services = createFakeTaskEntryServices({
catalog: {
...createFakeTaskEntryServices().catalog,
getCatalog: async () =>
catalog(++reads === 1 ? readyHost() : refreshedHost),
addProject: async () => ({
ok: false as const,
reason: 'archived' as const,
projectId: 'project-a',
}),
restoreProject: async () => {
restoreCalls += 1;
return { ok: true as const, project: project('project-a') };
},
},
});

await act(async () => renderController(root, services, [], async () => true));
await act(async () => {
controller().commands.addProject();
await Promise.resolve();
});
await act(async () => {});

assert.equal(restoreCalls, 1);
assert.equal(controller().selectors.target?.projectId, 'project-a');
});

it('reports restore failure and releases the pending state so the user can retry', async () => {
const { root } = installReactRenderer();
const restoration = deferred<{ ok: true; project: ReturnType<typeof project> }>();
const errors: unknown[] = [];
let restoreCalls = 0;
const services = createFakeTaskEntryServices({
catalog: {
...createFakeTaskEntryServices().catalog,
getCatalog: async () => catalog(),
addProject: async () => ({
ok: false,
reason: 'archived',
projectId: 'project-b',
}),
restoreProject: async () => {
restoreCalls += 1;
return restoreCalls === 1
? restoration.promise
: { ok: false, reason: 'cancelled' };
},
},
});

await act(async () => renderController(root, services, errors, async () => true));
await act(async () => controller().commands.addProject());
assert.equal(controller().selectors.workspacePicker.pending, true);

await act(async () => restoration.reject(new Error('restore failed')));

assert.deepEqual(errors, [{
title: 'Could not select working directory',
description: 'The project path is temporarily unavailable. Try again later.',
profileId: 'local',
}]);
assert.equal(controller().selectors.target?.projectId, 'project-a');
assert.equal(controller().selectors.workspacePicker.pending, false);

await act(async () => controller().commands.addProject());
assert.equal(restoreCalls, 2);
assert.equal(errors.length, 1);
});

it('deduplicates relink requests and selects the returned Project before refreshing', async () => {
const { root } = installReactRenderer();
const relinked = deferred<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ test('discards a pending Project projection after the default Host changes', asy
uiLocale: 'en',
rendererMountedRef: { current: true },
onProjectSelected: () => {},
toastApi: { success: () => {}, error: () => {} },
toastApi: { success: () => {}, error: () => {}, confirm: async () => true },
});
projects = context.projects;
selectedProjectId = context.selectedProjectId;
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main/project-management-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ type DirectoryActionResult =
| { ok: false; reason: 'cancelled' };
type SelectedDirectoryActionResult =
| { ok: true; project: ProjectRecord; path: string }
| { ok: false; reason: 'cancelled' };
| { ok: false; reason: 'cancelled' }
| { ok: false; reason: 'archived'; projectId: string };

export interface ProjectManagementService {
current(): Promise<CurrentProjectSelection>;
Expand Down Expand Up @@ -122,6 +123,9 @@ export function createProjectManagementService(deps: {
const path = await deps.chooseDirectory();
if (!path) return { ok: false, reason: 'cancelled' };
const project = await deps.catalog.register(path);
if (project.archivedAt !== undefined) {
return { ok: false, reason: 'archived', projectId: project.id };
}
const selected = requireSelectableProject(project);
if (options?.select !== false) {
deps.selection.setSelection(selected.id, selected.preferredPath);
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,11 @@ export interface MakaBridge {
getCatalog(): Promise<DesktopNewTaskCatalog>;
subscribeChanges(handler: () => void): () => void;
addProject(host: DesktopNewTaskHostRef): Promise<
| { ok: true; project: ProjectRecord }
| { ok: false; reason: 'cancelled' }
| { ok: false; reason: 'archived'; projectId: string }
>;
restoreProject(host: DesktopNewTaskHostRef, projectId: string): Promise<
{ ok: true; project: ProjectRecord } | { ok: false; reason: 'cancelled' }
>;
relinkProject(host: DesktopNewTaskHostRef, projectId: string): Promise<
Expand Down Expand Up @@ -1288,7 +1293,9 @@ export interface MakaBridge {
getLocalSnapshot(): Promise<DesktopProjectSnapshot>;
subscribeLocalChanges(handler: () => void): () => void;
add(host?: DesktopRuntimeHostRef): Promise<
{ ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' }
| { ok: true; project: ProjectRecord; path: string }
| { ok: false; reason: 'cancelled' }
| { ok: false; reason: 'archived'; projectId: string }
>;
getDirectoryRoots(host: DesktopRuntimeHostRef): Promise<readonly DesktopProjectDirectoryRoot[]>;
listDirectory(
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,9 +1800,18 @@ const makaBridge = {
{ select: false },
) as
| { ok: true; project: ProjectRecord; path: string }
| { ok: false; reason: 'cancelled' };
| { ok: false; reason: 'cancelled' }
| { ok: false; reason: 'archived'; projectId: string };
return result.ok ? { ok: true as const, project: result.project } : result;
},
async restoreProject(host: DesktopNewTaskHostRef, projectId: string) {
const project = await ipcRenderer.invoke(
'projects:restore',
await runtimeHostScope(host),
projectId,
) as ProjectRecord;
return { ok: true as const, project };
},
async relinkProject(host: DesktopNewTaskHostRef, projectId: string) {
return ipcRenderer.invoke(
'projects:relink',
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/app-shell-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ type ToastApi = {
diagnosticDetails?: string,
diagnosticTarget?: { sessionId: string } | { profileId: string },
): void;
confirm(input: {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
}): Promise<boolean>;
};

export interface AppShellProjectActions {
Expand Down Expand Up @@ -160,6 +167,18 @@ export function createAppShellProjectActions(deps: {
try {
const result = await runOnDefaultRuntimeHost(async (host) => {
const added = await window.maka.projects.add(host);
if (!added.ok && added.reason === 'archived') {
const confirmed = await toastApi.confirm({
title: copy.archivedProjectTitle,
description: copy.archivedProjectDescription,
confirmLabel: copy.archivedProjectRestore,
cancelLabel: copy.archivedProjectCancel,
});
if (!confirmed) return added;
const restored = await window.maka.projects.restore(added.projectId, host);
if (!(await selectProjectRecord(restored, true, host))) return added;
return { ok: true as const, project: restored, path: restored.preferredPath ?? '' };
Comment thread
faga295 marked this conversation as resolved.
}
if (!added.ok) return added;
await applySelectedProject(added.project, added.path, true, host);
return added;
Expand Down
Loading