feat(domain,application,infrastructure,backend,app-tauri,web-server,frontend): restaurer les projets ouverts au redémarrage dans l'ordre mémorisé — #303 (état versionné OpenProjectRestoreState persisté sous <app_data_dir>/open-projects.json via port dédié OpenProjectRestoreStore, distinct du registre projects.json et sans recycler workspace.json; sept use cases cadrés: read/record opened/record closed/save order/restore/retry/remove, mutations sérialisées par mutex applicatif contre les lost updates, tombstones conservés à leur position avec lastFailure et codes stables notFound/inaccessible/store/invalid; prévalidation root par metadata+listing sans interrompre les autres restaurations; activation centralisée BackendCore::activate_project réutilisée par ouverture Tauri/HTTP, création, restore et retry: reconciliation layouts/live-state/background tasks, watcher orchestrateur + serveur MCP, réparation run dirs Claude; cinq commandes exposées Tauri et HTTP avec contrats DTO identiques: restore_open_projects, retry_open_project_restore, remove_project_from_restore, save_open_project_restore_order, read_open_project_restore_state; frontend: DTO de reprise + ProjectGateway étendu, useProjects initialise par listProjects+restoreOpenProjects et reconstruit openTabs dans l'ordre opened sans notification quand tout réussit, notification unique singulier/pluriel avec Voir les détails/Réessayer, détail par projet avec cause française et Retirer de la reprise, retry réussi réinséré à la position mémorisée, onglet conservé si closeProject échoué; saveOpenProjectRestoreOrder exposé non appelé car l'UI ne permet pas encore de réordonnancer; QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p backend open_project_restore_tests 1/1 couvrant absence initiale/ordre/fermeture/inaccessible/retry/notFound/remove/watcher+MCP, cargo test -p web-server authorized_open_project_restore_commands_share_the_tauri_contract 1/1, cargo test -p domain 311+ suites vertes, cargo check -p application -p infrastructure -p backend -p app-tauri -p web-server OK, npm run typecheck OK, npm test ciblé 3 fichiers/37 tests OK, npm run build OK; réserves hors périmètre documentées au carnet: application tests/layout_usecases.rs 2 échecs fixtures plugin package NotFound, infrastructure 1 timing test cancellation process, npm test large 32 échecs web shell/LS7/tickets web — préexistants sans lien avec #303; restant: rebuild AppImage demandé après validation puis validation live fermeture/relance; fichiers tickets/296/*, tickets/299-303/*, counter/index, attachments/ et scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -240,6 +240,30 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("HttpProjectGateway maps the open-project restore command envelopes", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never);
|
||||
const gw = new HttpProjectGateway(http);
|
||||
|
||||
await gw.restoreOpenProjects();
|
||||
await gw.retryOpenProjectRestore("project-1");
|
||||
await gw.removeProjectFromRestore("project-1");
|
||||
await gw.saveOpenProjectRestoreOrder(["project-2", "project-1"]);
|
||||
await gw.readOpenProjectRestoreState();
|
||||
|
||||
expect(spy).toHaveBeenNthCalledWith(1, "restore_open_projects");
|
||||
expect(spy).toHaveBeenNthCalledWith(2, "retry_open_project_restore", {
|
||||
request: { projectId: "project-1" },
|
||||
});
|
||||
expect(spy).toHaveBeenNthCalledWith(3, "remove_project_from_restore", {
|
||||
request: { projectId: "project-1" },
|
||||
});
|
||||
expect(spy).toHaveBeenNthCalledWith(4, "save_open_project_restore_order", {
|
||||
request: { orderedProjectIds: ["project-2", "project-1"] },
|
||||
});
|
||||
expect(spy).toHaveBeenNthCalledWith(5, "read_open_project_restore_state");
|
||||
});
|
||||
|
||||
it("HttpProjectGateway project files use the dedicated REST endpoints", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const request = vi.spyOn(http, "request").mockResolvedValue({
|
||||
|
||||
@ -38,6 +38,8 @@ import type {
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectFileListing,
|
||||
OpenProjectRestoreState,
|
||||
RestoreOpenProjectsOutput,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
@ -97,6 +99,29 @@ export class HttpProjectGateway implements ProjectGateway {
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
await this.http.invoke("close_project", { projectId });
|
||||
}
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
return this.http.invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||
}
|
||||
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput> {
|
||||
return this.http.invoke<RestoreOpenProjectsOutput>("retry_open_project_restore", {
|
||||
request: projectId === undefined ? {} : { projectId },
|
||||
});
|
||||
}
|
||||
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState> {
|
||||
return this.http.invoke<OpenProjectRestoreState>("remove_project_from_restore", {
|
||||
request: { projectId },
|
||||
});
|
||||
}
|
||||
saveOpenProjectRestoreOrder(
|
||||
orderedProjectIds: string[],
|
||||
): Promise<OpenProjectRestoreState> {
|
||||
return this.http.invoke<OpenProjectRestoreState>("save_open_project_restore_order", {
|
||||
request: { orderedProjectIds },
|
||||
});
|
||||
}
|
||||
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||
return this.http.invoke<OpenProjectRestoreState>("read_open_project_restore_state");
|
||||
}
|
||||
readProjectContext(projectId: string): Promise<string> {
|
||||
return this.http.invoke<string>("read_project_context", { projectId });
|
||||
}
|
||||
|
||||
@ -70,6 +70,9 @@ import type {
|
||||
PluginWorkspaceTextFile,
|
||||
Project,
|
||||
ProjectFileListing,
|
||||
OpenProjectRestoreFailure,
|
||||
OpenProjectRestoreState,
|
||||
RestoreOpenProjectsOutput,
|
||||
JsonValue,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
@ -1247,6 +1250,29 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
private projects: Project[] = [];
|
||||
private contexts = new Map<string, string>();
|
||||
private restoreProjectIds: string[] = [];
|
||||
private restoreFailures = new Map<string, OpenProjectRestoreFailure>();
|
||||
|
||||
/** Test/demo helper: defines the ordered projects retained for next launch. */
|
||||
setOpenProjectRestoreOrder(projectIds: string[]): void {
|
||||
this.restoreProjectIds = [...projectIds];
|
||||
}
|
||||
|
||||
/** Test/demo helper: makes one retained project fail until cleared. */
|
||||
setOpenProjectRestoreFailure(
|
||||
projectId: string,
|
||||
failure: Omit<OpenProjectRestoreFailure, "projectId" | "position"> | null,
|
||||
): void {
|
||||
if (failure === null) {
|
||||
this.restoreFailures.delete(projectId);
|
||||
return;
|
||||
}
|
||||
this.restoreFailures.set(projectId, {
|
||||
projectId,
|
||||
position: Math.max(0, this.restoreProjectIds.indexOf(projectId)),
|
||||
...failure,
|
||||
});
|
||||
}
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
return [...this.projects];
|
||||
@ -1283,7 +1309,77 @@ export class MockProjectGateway implements ProjectGateway {
|
||||
return project;
|
||||
}
|
||||
|
||||
async closeProject(): Promise<void> {}
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId);
|
||||
this.restoreFailures.delete(projectId);
|
||||
}
|
||||
|
||||
async restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
const failures: OpenProjectRestoreFailure[] = [];
|
||||
const opened: Project[] = [];
|
||||
this.restoreProjectIds.forEach((projectId, position) => {
|
||||
const configured = this.restoreFailures.get(projectId);
|
||||
const project = this.projects.find((candidate) => candidate.id === projectId);
|
||||
if (configured) failures.push({ ...configured, position });
|
||||
else if (project) opened.push(project);
|
||||
else {
|
||||
failures.push({
|
||||
projectId,
|
||||
name: projectId,
|
||||
root: "",
|
||||
position,
|
||||
code: "notFound",
|
||||
message: "project is absent from the registry",
|
||||
});
|
||||
}
|
||||
});
|
||||
return { opened, failures };
|
||||
}
|
||||
|
||||
async retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput> {
|
||||
const output = await this.restoreOpenProjects();
|
||||
const selected = (id: string) => projectId === undefined || id === projectId;
|
||||
return {
|
||||
opened: output.opened.filter((project) => selected(project.id)),
|
||||
failures: output.failures.filter((failure) => selected(failure.projectId)),
|
||||
};
|
||||
}
|
||||
|
||||
async removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState> {
|
||||
this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId);
|
||||
this.restoreFailures.delete(projectId);
|
||||
return this.readOpenProjectRestoreState();
|
||||
}
|
||||
|
||||
async saveOpenProjectRestoreOrder(
|
||||
orderedProjectIds: string[],
|
||||
): Promise<OpenProjectRestoreState> {
|
||||
const retained = this.restoreProjectIds.filter(
|
||||
(id) => !orderedProjectIds.includes(id),
|
||||
);
|
||||
this.restoreProjectIds = [...orderedProjectIds, ...retained];
|
||||
return this.readOpenProjectRestoreState();
|
||||
}
|
||||
|
||||
async readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
entries: this.restoreProjectIds.map((projectId, position) => {
|
||||
const project = this.projects.find((candidate) => candidate.id === projectId);
|
||||
const failure = this.restoreFailures.get(projectId);
|
||||
return {
|
||||
projectId,
|
||||
name: project?.name ?? failure?.name ?? projectId,
|
||||
root: project?.root ?? failure?.root ?? "",
|
||||
position,
|
||||
lastFailure: failure
|
||||
? { code: failure.code, message: failure.message }
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async readProjectContext(projectId: string): Promise<string> {
|
||||
if (!this.projects.some((p) => p.id === projectId)) {
|
||||
|
||||
@ -24,4 +24,33 @@ describe("TauriProjectGateway invoke payloads", () => {
|
||||
request: { projectId: "proj-1", content: "# next" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the open-project restore commands to their exact request envelopes", async () => {
|
||||
const gateway = new TauriProjectGateway();
|
||||
|
||||
await gateway.restoreOpenProjects();
|
||||
expect(invoke).toHaveBeenCalledWith("restore_open_projects");
|
||||
|
||||
await gateway.retryOpenProjectRestore();
|
||||
expect(invoke).toHaveBeenCalledWith("retry_open_project_restore", {
|
||||
request: {},
|
||||
});
|
||||
await gateway.retryOpenProjectRestore("proj-1");
|
||||
expect(invoke).toHaveBeenCalledWith("retry_open_project_restore", {
|
||||
request: { projectId: "proj-1" },
|
||||
});
|
||||
|
||||
await gateway.removeProjectFromRestore("proj-1");
|
||||
expect(invoke).toHaveBeenCalledWith("remove_project_from_restore", {
|
||||
request: { projectId: "proj-1" },
|
||||
});
|
||||
|
||||
await gateway.saveOpenProjectRestoreOrder(["proj-2", "proj-1"]);
|
||||
expect(invoke).toHaveBeenCalledWith("save_open_project_restore_order", {
|
||||
request: { orderedProjectIds: ["proj-2", "proj-1"] },
|
||||
});
|
||||
|
||||
await gateway.readOpenProjectRestoreState();
|
||||
expect(invoke).toHaveBeenCalledWith("read_open_project_restore_state");
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,12 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { Project, ProjectFileListing } from "@/domain";
|
||||
import type {
|
||||
OpenProjectRestoreState,
|
||||
Project,
|
||||
ProjectFileListing,
|
||||
RestoreOpenProjectsOutput,
|
||||
} from "@/domain";
|
||||
import type { ProjectGateway } from "@/ports";
|
||||
|
||||
export class TauriProjectGateway implements ProjectGateway {
|
||||
@ -28,6 +33,34 @@ export class TauriProjectGateway implements ProjectGateway {
|
||||
await invoke("close_project", { projectId });
|
||||
}
|
||||
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
return invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||
}
|
||||
|
||||
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput> {
|
||||
return invoke<RestoreOpenProjectsOutput>("retry_open_project_restore", {
|
||||
request: projectId === undefined ? {} : { projectId },
|
||||
});
|
||||
}
|
||||
|
||||
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState> {
|
||||
return invoke<OpenProjectRestoreState>("remove_project_from_restore", {
|
||||
request: { projectId },
|
||||
});
|
||||
}
|
||||
|
||||
saveOpenProjectRestoreOrder(
|
||||
orderedProjectIds: string[],
|
||||
): Promise<OpenProjectRestoreState> {
|
||||
return invoke<OpenProjectRestoreState>("save_open_project_restore_order", {
|
||||
request: { orderedProjectIds },
|
||||
});
|
||||
}
|
||||
|
||||
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState> {
|
||||
return invoke<OpenProjectRestoreState>("read_open_project_restore_state");
|
||||
}
|
||||
|
||||
readProjectContext(projectId: string): Promise<string> {
|
||||
return invoke<string>("read_project_context", { projectId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user