diff --git a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts index cc9fe153ad..1fad6c173b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts @@ -61,10 +61,6 @@ test('remote Project capabilities do not dispatch Client-local actions', async ( globalThis.window = { maka: { projects: { - add: async () => { - clientActionCalls += 1; - return { ok: false, reason: 'cancelled' }; - }, select: async () => { clientActionCalls += 1; return { project: null, path: '' }; @@ -80,7 +76,6 @@ test('remote Project capabilities do not dispatch Client-local actions', async ( try { const actions = createTestProjectActions(actionsModule); - assert.equal(await actions.addProject(), null); await actions.selectNoProject(); assert.equal(await actions.relinkProject('remote'), null); assert.equal(clientActionCalls, 0); @@ -98,6 +93,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: { diff --git a/apps/desktop/src/main/__tests__/project-add-restore.test.ts b/apps/desktop/src/main/__tests__/project-add-restore.test.ts new file mode 100644 index 0000000000..a1435d2c5c --- /dev/null +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { after, afterEach, before, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { parseHTML } from 'linkedom'; +import { act, createElement, type ComponentType, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { ProjectRecord } from '@maka/core/project'; +import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; + +interface RenderModules { + RemoteProjectDirectoryDialog: ComponentType<{ + host?: DesktopRuntimeHostRef; + onClose(): void; + onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; + }>; + SettingsFixture: ComponentType; +} +let components: RenderModules; +let bundleDirectory: string; +let mountedRoot: Root | undefined; +let frames: FrameRequestCallback[] = []; +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Node: globalThis.Node, + Event: globalThis.Event, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +before(async () => { + const repoRoot = resolve(import.meta.dirname, '../../../../..'); + bundleDirectory = await mkdtemp(resolve(repoRoot, 'apps/desktop/dist/main/__tests__/project-restore-')); + const outfile = resolve(bundleDirectory, 'components.mjs'); + await build({ + stdin: { + contents: ` + import { createElement } from 'react'; + import { ProjectsSettingsPage } from './settings/projects-settings-page'; + import { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target'; + export { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; + export function SettingsFixture() { + return createElement(RuntimeHostSettingsTarget, { + host: { profileId: 'remote', hostId: 'host-remote' }, + children: createElement(ProjectsSettingsPage, { + settings: { projects: {} }, runtimeHostStatus: 'ready', runtimeHostTargetVerified: true, + onUpdate: async () => {}, onRetryRuntimeHost: async () => {}, onRemoteHostAdded() {}, + }), + }); + } + `, + resolveDir: resolve(repoRoot, 'apps/desktop/src/renderer'), + }, + outfile, bundle: true, packages: 'external', loader: { '.svg': 'dataurl' }, + platform: 'node', format: 'esm', jsx: 'automatic', target: 'node20', logLevel: 'silent', + plugins: [{ name: 'omit-unrelated-host-profile-settings', setup(builder) { + builder.onResolve({ filter: /runtime-host-profiles-section/ }, () => ({ path: 'profiles', namespace: 'fixture' })); + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: 'export function RuntimeHostProfilesSection() { return null; }' })); + } }], + }); + components = await import(pathToFileURL(outfile).href) as RenderModules; +}); +afterEach(async () => { + try { if (mountedRoot) await act(() => mountedRoot?.unmount()); } + finally { mountedRoot = undefined; frames = []; Object.assign(globalThis, originalGlobals); } +}); +after(async () => { if (bundleDirectory) await rm(bundleDirectory, { recursive: true, force: true }); }); + +const host = { profileId: 'remote', hostId: 'host-remote' }; +const restored: ProjectRecord = { id: 'p', name: 'Project', locations: [], available: true }; +const archived: ProjectRecord = { ...restored, archivedAt: 1 }; +function bridge(overrides: Record = {}) { + Object.assign(window, { maka: { + projects: { + getDirectoryRoots: async () => [{ id: 'root', name: 'Root' }], + listDirectory: async () => [], registerDirectory: async () => archived, + restore: async () => restored, + getSnapshot: async () => ({ projects: [], capabilities: { chooseClientDirectory: true } }), + subscribeChanges: () => () => {}, + ...overrides, + }, + app: { info: async () => ({}) }, + runtimeHostProfiles: { subscribeChanges: () => () => {} }, + } }); +} +async function flushFrames() { + for (let index = 0; index < 5; index++) { + const pending = frames.splice(0); + await act(async () => { for (const callback of pending) callback(0); }); + } +} +async function click(label: string) { + const button = [...document.querySelectorAll('button')].filter( + (candidate) => candidate.textContent === label || candidate.getAttribute('aria-label') === label, + ).at(-1); + assert.ok(button, `missing button ${label}: ${document.body.textContent}`); + await act(async () => button.dispatchEvent(new window.Event('click', { bubbles: true }))); + await flushFrames(); +} + +for (const outcome of ['restore', 'cancel', 'failure', 'normal', 'stale'] as const) { + test(`remote directory registration: ${outcome}`, async () => { + const harness = installRenderer(); + const accepted: ProjectRecord[] = []; + const restores: unknown[][] = []; + bridge({ + registerDirectory: async () => outcome === 'normal' ? restored : archived, + restore: async (...args: unknown[]) => { + restores.push(args); + if (outcome === 'failure') throw new Error('restore failed'); + return restored; + }, + }); + const render = (target: DesktopRuntimeHostRef | undefined = host) => harness.render( + createElement(components.RemoteProjectDirectoryDialog, { + host: target, onClose() {}, onRegistered(project) { accepted.push(project); }, + }), + ); + await render(); + await click('Add this folder'); + if (outcome === 'normal') { + assert.deepEqual(accepted, [restored]); + assert.equal(restores.length, 0); + return; + } + assert.equal(accepted.length, 0, 'must not accept an archived project before confirmation'); + assert.match(document.body.textContent, /Project archived/); + if (outcome === 'stale') await render({ profileId: 'another', hostId: 'another-host' }); + if (outcome === 'cancel') { + const dialog = [...document.querySelectorAll('dialog')].find(el => el.textContent.includes('Project archived')); + assert.ok(dialog); + const cancel = [...dialog.querySelectorAll('button')].find(el => el.textContent === 'Cancel'); + assert.ok(cancel); + await act(async () => cancel.dispatchEvent(new window.Event('click', { bubbles: true }))); + await flushFrames(); + } else await click('Restore'); + if (outcome === 'restore') { + assert.deepEqual(restores, [['p', host]]); + assert.deepEqual(accepted, [restored]); + } else { + assert.equal(accepted.length, 0); + assert.equal(restores.length, outcome === 'failure' ? 1 : 0); + if (outcome === 'failure') assert.ok(document.querySelector('.remoteProjectDirectoryError[role="alert"]')); + const add = [...document.querySelectorAll('button')].find(el => el.textContent === 'Add this folder'); + assert.ok(add); + assert.equal(add.hasAttribute('disabled'), false, 'registration can be retried'); + } + }); +} + +for (const failure of ['add', 'restore'] as const) { + test(`Settings reports ${failure} failure and releases Add`, async () => { + const harness = installRenderer(); + bridge({ + add: async () => { + if (failure === 'add') throw new Error('add failed'); + return { ok: false, reason: 'archived', projectId: 'p' }; + }, + restore: async () => { throw new Error('restore failed'); }, + }); + await harness.render(createElement(components.SettingsFixture)); + await click('Add project'); + if (failure === 'restore') await click('Restore'); + assert.match(document.body.textContent, /Action failed/); + const add = [...document.querySelectorAll('button')].find(el => el.textContent === 'Add project'); + assert.ok(add); + assert.notEqual(add.getAttribute('aria-busy'), 'true'); + assert.equal(add.hasAttribute('disabled'), false); + }); +} +for (const outcome of ['restore', 'cancel', 'refresh-failure'] as const) { + test(`Settings archived Add: ${outcome}`, async () => { + const harness = installRenderer(); + let restores = 0; + let snapshots = 0; + bridge({ + add: async () => ({ ok: false, reason: 'archived', projectId: 'p' }), + restore: async () => { restores++; return restored; }, + getSnapshot: async () => { + snapshots++; + if (snapshots > 1 && outcome === 'refresh-failure') throw new Error('refresh failed'); + return { projects: [], capabilities: { chooseClientDirectory: true } }; + }, + }); + await harness.render(createElement(components.SettingsFixture)); + await click('Add project'); + assert.equal(restores, 0); + await click(outcome === 'cancel' ? 'Cancel' : 'Restore'); + assert.equal(restores, outcome === 'cancel' ? 0 : 1); + assert.ok(snapshots > 1); + if (outcome === 'refresh-failure') assert.match(document.body.textContent, /Action failed/); + }); +} + +function installRenderer() { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, media, onchange: null, + addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, + dispatchEvent: () => false, + }); + const getComputedStyle = () => ({ + direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { this.setAttribute('open', ''); }, + close(this: HTMLElement) { this.removeAttribute('open'); }, + }); + Object.assign(globalThis, { + document, window, matchMedia, getComputedStyle, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => { frames.push(callback); return frames.length; }, cancelAnimationFrame: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + return { + document, + async render(children: ReactNode) { + await act(async () => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children, + }), + }), + }))); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index fef7a9cb70..affa113a6b 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -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({ diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 393ca2f297..4afdb6ae25 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -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, @@ -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; +}) { latestController = useTaskEntryController({ reportError: props.reportError, manageProjects() {}, + ...(props.confirm ? { confirm: props.confirm } : {}), }); return null; } @@ -114,17 +118,23 @@ function renderController( root: ReturnType['root'], services: TaskEntryServices, errors: unknown[] = [], + confirm?: (input: { title: string }) => Promise, ) { root.render( createElement(LocaleProvider, { locale: 'en', children: createElement( + ToastProvider, + null, + createElement( TaskEntryServicesProvider, { services }, createElement(ControllerProbe, { reportError: (error: unknown) => errors.push(error), + confirm, }), ), + ), }), ); } @@ -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 }>(); + 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<{ diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts index 9b5effd22f..f1731403cc 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -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; @@ -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); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e389b888b4..5e5dd4432b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -894,6 +894,11 @@ export interface MakaBridge { getCatalog(): Promise; 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< @@ -1283,7 +1288,9 @@ export interface MakaBridge { getLocalSnapshot(): Promise; 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; listDirectory( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f3c89333c6..36db29d7ac 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1808,9 +1808,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', diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index 304e635416..17fffd7ba9 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -57,7 +57,6 @@ type ToastApi = { export interface AppShellProjectActions { refreshProjects(): Promise; - addProject(): Promise; selectProject(projectId: string): Promise; selectNoProject(): Promise; prepareDefaultProject(): Promise; @@ -148,42 +147,6 @@ export function createAppShellProjectActions(deps: { return applySelectedProject(selected.project, selected.path, notify, host); } - async function addProject(): Promise { - if (!projectCapabilities.chooseClientDirectory) return null; - if (projectPickerPendingRef.current) return null; - const requestId = projectPickerRequestRef.current + 1; - projectPickerRequestRef.current = requestId; - projectPickerPendingRef.current = true; - setProjectPickerPending(true); - const isCurrentProjectPickerRequest = () => - rendererMountedRef.current && projectPickerRequestRef.current === requestId; - try { - const result = await runOnDefaultRuntimeHost(async (host) => { - const added = await window.maka.projects.add(host); - if (!added.ok) return added; - await applySelectedProject(added.project, added.path, true, host); - return added; - }); - if (!isCurrentProjectPickerRequest()) return null; - if (!result.value.ok) return null; - return result.value.project; - } catch (error) { - if (isCurrentProjectPickerRequest()) { - showDefaultProjectError( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), - error, - ); - } - return null; - } finally { - if (projectPickerRequestRef.current === requestId) { - projectPickerPendingRef.current = false; - if (rendererMountedRef.current) setProjectPickerPending(false); - } - } - } - async function selectProject(projectId: string): Promise { try { const project = projects.find((candidate) => candidate.id === projectId); @@ -400,7 +363,6 @@ export function createAppShellProjectActions(deps: { return { refreshProjects, - addProject, selectProject, selectNoProject, prepareDefaultProject, diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 4a3bd62b52..f1e95abc8e 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -32,6 +32,7 @@ import { import { getConversationCopy, type WorkspacePickerModel, + useToast, useUiLocale, } from '@maka/ui'; import { @@ -58,6 +59,13 @@ import type { TaskEntryHostModel } from '../ui/task-entry-host.js'; export interface UseTaskEntryControllerInput { reportError(error: TaskEntryError): void; manageProjects(profileId: string): void; + /** Defaults to the app toast confirm dialog; injected in tests. */ + confirm?(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + }): Promise; } export interface TaskEntryControllerSelectors { @@ -130,6 +138,7 @@ export function useTaskEntryController( input: UseTaskEntryControllerInput, ): TaskEntryController { const locale = useUiLocale(); + const toast = useToast(); const copy = getShellCopy(locale).projectActions; const conversationCopy = getConversationCopy(locale).workspace; const reportError = input.reportError; @@ -289,19 +298,23 @@ export function useTaskEntryController( projectMutationPendingRef.current = true; setPending(true); try { - let result: TaskEntryProjectMutationResult; - try { - result = await service.addProject({ - profileId: host.profile.id, - hostId: host.hostId, - }); - } catch (cause) { - reportError({ - title: copy.selectDirectoryFailedTitle, - description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), - profileId: host.profile.id, + let result: TaskEntryProjectMutationResult = await service.addProject({ + profileId: host.profile.id, + hostId: host.hostId, + }); + if (!result.ok && result.reason === 'archived') { + const confirmed = await (input.confirm ?? toast.confirm)({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, }); - return; + if (!confirmed) return; + result = await service.restoreProject( + { profileId: host.profile.id, hostId: host.hostId }, + result.projectId, + ); + if (!result.ok) return; } if (!result.ok) return; setSelectedProfileId(host.profile.id); @@ -309,11 +322,17 @@ export function useTaskEntryController( new Map(current).set(host.profile.id, result.project.id), ); await refreshAfterProjectMutation(host.profile.id); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: host.profile.id, + }); } finally { projectMutationPendingRef.current = false; setPending(false); } - }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service]); + }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service, toast]); const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index f1514fc19e..dca9296470 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -87,13 +87,15 @@ export interface TaskEntryCatalog { export type TaskEntryProjectMutationResult = | { readonly ok: true; readonly project: ProjectRecord } - | { readonly ok: false; readonly reason: 'cancelled' }; + | { readonly ok: false; readonly reason: 'cancelled' } + | { readonly ok: false; readonly reason: 'archived'; readonly projectId: string }; /** The minimum environment capability needed by Task Entry / Workspace. */ export interface TaskEntryCatalogService { getCatalog(): Promise; subscribeChanges(handler: () => void): TaskEntryUnsubscribe; addProject(host: TaskEntryHostRef): Promise; + restoreProject(host: TaskEntryHostRef, projectId: string): Promise; relinkProject( host: TaskEntryHostRef, projectId: string, diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index 9d9ac6f472..9cf4c7272d 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -51,6 +51,7 @@ export function createFakeTaskEntryServices( getCatalog: async () => ({ defaultProfileId: 'local', hosts: [] }), subscribeChanges: noopSubscription, addProject: async () => ({ ok: false, reason: 'cancelled' }), + restoreProject: async () => ({ ok: false, reason: 'cancelled' }), relinkProject: async () => ({ ok: false, reason: 'cancelled' }), }, ...overrides, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 78ee694394..3df6e7fb2b 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -283,6 +283,10 @@ export type SettingsProjectsCopy = { section: string; sectionHelp: string; addProject: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; defaultBadge: string; setDefault: string; setDefaultTitle: string; @@ -641,6 +645,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { // happens before they set one. sectionHelp: '新任务默认打开此项目;未设置时沿用上次使用的项目。任何任务都能在输入框旁临时切换。', addProject: '添加项目', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', defaultBadge: '默认', setDefault: '设为默认', setDefaultTitle: '新任务默认打开这个项目', @@ -979,6 +987,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { // happens before they set one. sectionHelp: '新任務預設開啟此專案;未設定時沿用上次使用的專案。任何任務都能在輸入框旁臨時切換。', addProject: '新增專案', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', defaultBadge: '預設', setDefault: '設為預設', setDefaultTitle: '新任務預設開啟這個專案', @@ -1335,6 +1347,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: 'New tasks open in the default project; without one, they reuse the project you last used. You can switch any task to a different project next to the input box.', addProject: 'Add project', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', defaultBadge: 'Default', setDefault: 'Set as default', setDefaultTitle: 'Open new tasks in this project', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 2f81a1ad94..7be19b008b 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -198,6 +198,10 @@ type ShellCopy = { projectUpdateFailedFallback: string; catalogUnavailable: string; retryCatalog: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; remoteDirectoryTitle(host: string): string; remoteDirectoryBreadcrumbs: string; remoteDirectoryHome: string; @@ -802,6 +806,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暂时无法更新项目,请稍后重试。', catalogUnavailable: 'Runtime Host 暂时不可用', retryCatalog: '重试加载', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上添加项目`, remoteDirectoryBreadcrumbs: '当前文件夹', remoteDirectoryHome: '主目录', @@ -1303,6 +1311,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暫時無法更新專案,請稍後重試。', catalogUnavailable: 'Runtime Host 暫時不可用', retryCatalog: '重試載入', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上新增專案`, remoteDirectoryBreadcrumbs: '目前資料夾', remoteDirectoryHome: '主目錄', @@ -1806,6 +1818,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: 'The project could not be updated. Try again later.', catalogUnavailable: 'Runtime Hosts unavailable', retryCatalog: 'Retry loading', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', remoteDirectoryTitle: (host: string) => `Add a project on ${host}`, remoteDirectoryBreadcrumbs: 'Current folder', remoteDirectoryHome: 'Home', diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx index 2209482e0f..37c19d8416 100644 --- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx +++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx @@ -25,7 +25,7 @@ import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu' import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { HStack } from '@astryxdesign/core/Stack'; import { Text } from '@astryxdesign/core/Text'; -import { useUiLocale } from '@maka/ui'; +import { useToast, useUiLocale } from '@maka/ui'; import { reportUnexpectedError } from './application/contracts/operation-diagnostics.js'; import { Check, Eye, EyeOff, FolderOpen } from '@maka/ui/icons'; import type { @@ -53,6 +53,7 @@ export function RemoteProjectDirectoryDialog(props: { onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; }) { const locale = useUiLocale(); + const toast = useToast(); const copy = getShellCopy(locale).projectActions; const [roots, setRoots] = useState([]); const [root, setRoot] = useState(); @@ -97,6 +98,7 @@ export function RemoteProjectDirectoryDialog(props: { const sequence = ++request.current; lastLoad.current = target; if (target.kind === 'initial') { + setRegistering(false); setRoots([]); setRoot(undefined); setSegments([]); @@ -162,11 +164,22 @@ export function RemoteProjectDirectoryDialog(props: { setRegistering(true); setError(undefined); try { - const project = await window.maka.projects.registerDirectory({ + let project = await window.maka.projects.registerDirectory({ rootId: root.id, segments, }, host); if (request.current !== sequence) return; + if (project.archivedAt !== undefined) { + const confirmed = await toast.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!confirmed || request.current !== sequence) return; + project = await window.maka.projects.restore(project.id, host); + if (request.current !== sequence) return; + } props.onRegistered(project, host); } catch (cause) { if (request.current !== sequence) return; diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index bbf4486273..7b9dfa3eab 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -92,6 +92,7 @@ export function ProjectsSettingsPage(props: { const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); const [directoryPickerOpen, setDirectoryPickerOpen] = useState(false); + const [adding, setAdding] = useState(false); const directoryPickerTriggerRef = useRef(null); const reloadGeneration = useRef(0); @@ -265,15 +266,32 @@ export function ProjectsSettingsPage(props: { variant="secondary" size="sm" label={copy.addProject} - clickAction={capabilities.chooseHostDirectory - ? () => { - if (props.runtimeHostTargetVerified) setDirectoryPickerOpen(true); - } - : async () => { - if (!props.runtimeHostTargetVerified) return; + isLoading={adding} + onClick={() => { + if (capabilities.chooseHostDirectory) { + if (props.runtimeHostTargetVerified) setDirectoryPickerOpen(true); + return; + } + void runRowAction('add', async () => { + setAdding(true); + try { const result = await window.maka.projects.add(host); - if (result.ok) await reload(); - }} + if (!mountedRef.current) return; + if (!result.ok && result.reason === 'archived') { + const ok = await toast.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!ok || !mountedRef.current) return; + await window.maka.projects.restore(result.projectId, host); + } + } finally { + if (mountedRef.current) setAdding(false); + } + }, copy.actionFailed); + }} /> ) : undefined} > @@ -498,7 +516,7 @@ export function ProjectsSettingsPage(props: { onClose={() => setDirectoryPickerOpen(false)} onRegistered={() => { setDirectoryPickerOpen(false); - void reload(); + void runRowAction('add', async () => {}, copy.actionFailed); }} /> diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index 7c3040560f..533bce7d6d 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -18,6 +18,7 @@ */ import { createHash } from 'node:crypto'; +import { reportUnexpectedOperation } from '@maka/core/redaction'; import type { ProjectRecord } from '@maka/core/project'; import { ProjectArchivedError, @@ -143,6 +144,7 @@ export class HostProjectCatalogCoordinator { if (error instanceof TypeError || isInvalidPathError(error)) { return mutationFailure('invalid_request', 'Project catalog input is invalid'); } + reportUnexpectedOperation(`runtime-host:project-catalog:${input.kind}`, error); this.requestDrain(); return mutationFailure( 'commit_outcome_unknown', diff --git a/packages/runtime-host/src/server/skill-catalog-coordinator.ts b/packages/runtime-host/src/server/skill-catalog-coordinator.ts index 39e53bbcf0..bf17c56b3a 100644 --- a/packages/runtime-host/src/server/skill-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/skill-catalog-coordinator.ts @@ -26,6 +26,7 @@ import type { WorkspaceProjection, } from '../protocol/index.js'; import type { ConnectionContext, SkillCatalogOperationHandlerMap } from './operation-dispatcher.js'; +import { reportUnexpectedOperation } from '@maka/core/redaction'; import type { HostCapabilities } from '@maka/runtime/skills'; import { SkillCatalogRepository, @@ -266,6 +267,7 @@ function repositoryFailure( error: { code: 'invalid_request', message: error.message }, } as OperationOutcome; } + reportUnexpectedOperation(`runtime-host:skill-catalog:${operation}`, error); return { ok: false, error: { code: 'internal_failure', message: 'Skill catalog operation failed' },