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 });
|
||||
}
|
||||
|
||||
@ -606,6 +606,43 @@ export interface Project {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type OpenProjectRestoreFailureCode =
|
||||
| "notFound"
|
||||
| "inaccessible"
|
||||
| "store"
|
||||
| "invalid";
|
||||
|
||||
export interface OpenProjectRestoreFailure {
|
||||
projectId: string;
|
||||
name: string;
|
||||
root: string;
|
||||
position: number;
|
||||
code: OpenProjectRestoreFailureCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface OpenProjectRestoreEntry {
|
||||
projectId: string;
|
||||
name: string;
|
||||
root: string;
|
||||
position: number;
|
||||
lastFailure: {
|
||||
code: OpenProjectRestoreFailureCode;
|
||||
message: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OpenProjectRestoreState {
|
||||
version: number;
|
||||
entries: OpenProjectRestoreEntry[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface RestoreOpenProjectsOutput {
|
||||
opened: Project[];
|
||||
failures: OpenProjectRestoreFailure[];
|
||||
}
|
||||
|
||||
/** Stable error shape mirrored from the backend `ErrorDto`. */
|
||||
export interface GatewayError {
|
||||
code: string;
|
||||
|
||||
@ -42,7 +42,12 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { Agent, DomainEvent, LayoutInfo } from "@/domain";
|
||||
import type {
|
||||
Agent,
|
||||
DomainEvent,
|
||||
LayoutInfo,
|
||||
OpenProjectRestoreFailure,
|
||||
} from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { ConversationViewer } from "@/features/conversations";
|
||||
import {
|
||||
@ -207,6 +212,19 @@ function rendezvousToastStateLabel(state: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFailureCause(failure: OpenProjectRestoreFailure): string {
|
||||
switch (failure.code) {
|
||||
case "notFound":
|
||||
return "Le projet n’est plus enregistré.";
|
||||
case "inaccessible":
|
||||
return "Le chemin est introuvable ou inaccessible.";
|
||||
case "store":
|
||||
return "L’état du projet n’a pas pu être lu.";
|
||||
case "invalid":
|
||||
return "Les informations mémorisées pour ce projet sont invalides.";
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
||||
@ -247,10 +265,17 @@ export function ProjectsView() {
|
||||
useState<PendingConversationOpen | null>(null);
|
||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
|
||||
const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false);
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
const activeViewerConversation =
|
||||
viewerConversation?.projectId === active?.id ? viewerConversation : null;
|
||||
const showRestoreNotification =
|
||||
vm.restoreNotificationVisible && vm.restoreFailures.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
|
||||
}, [vm.restoreFailures.length]);
|
||||
const projectNameById = useMemo(() => {
|
||||
const names = new Map<string, string>();
|
||||
for (const project of vm.projects) names.set(project.id, project.name);
|
||||
@ -1021,12 +1046,77 @@ export function ProjectsView() {
|
||||
</FloatingWindow>
|
||||
))}
|
||||
|
||||
{/* ── Background-task toasts (above floating windows) ── */}
|
||||
{taskToasts.length > 0 && (
|
||||
<FloatingWindow
|
||||
open={restoreDetailsOpen && vm.restoreFailures.length > 0}
|
||||
title="Projets non rouverts"
|
||||
size="md"
|
||||
onClose={() => setRestoreDetailsOpen(false)}
|
||||
>
|
||||
<ul className="flex flex-col gap-3" aria-label="restore failures">
|
||||
{vm.restoreFailures.map((failure) => (
|
||||
<li
|
||||
key={failure.projectId}
|
||||
className="rounded-md border border-border bg-raised p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-content">{failure.name}</div>
|
||||
<div className="mt-1 break-all text-xs text-muted">{failure.root}</div>
|
||||
<p className="mt-2 text-sm text-muted">{restoreFailureCause(failure)}</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.removeFromRestore(failure.projectId)}
|
||||
>
|
||||
Retirer de la reprise
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FloatingWindow>
|
||||
|
||||
{/* ── Non-blocking notifications (above floating windows) ── */}
|
||||
{(showRestoreNotification || taskToasts.length > 0) && (
|
||||
<div
|
||||
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
||||
style={{ zIndex: zIndex.toast }}
|
||||
>
|
||||
{showRestoreNotification && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-border bg-surface px-3 py-2 shadow-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-content">
|
||||
{vm.restoreFailures.length === 1
|
||||
? "1 projet n’a pas pu être rouvert"
|
||||
: `${vm.restoreFailures.length} projets n’ont pas pu être rouverts`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Fermer la notification"
|
||||
onClick={vm.dismissRestoreNotification}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setRestoreDetailsOpen(true)}>
|
||||
Voir les détails
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.retryRestore()}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{taskToasts.map((toast) => (
|
||||
<button
|
||||
key={toast.id}
|
||||
|
||||
@ -95,6 +95,135 @@ function openMenuItem(menu: string, item: string) {
|
||||
}
|
||||
|
||||
describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
it("silently restores retained projects in their persisted order", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/p/b");
|
||||
project.setOpenProjectRestoreOrder([beta.id, alpha.id]);
|
||||
|
||||
renderView(project);
|
||||
|
||||
await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(2));
|
||||
const tabs = within(tablist()).getAllByRole("tab");
|
||||
expect(tabs.map((tab) => tab.textContent?.replace("×", "").trim())).toEqual([
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/missing/b");
|
||||
const gamma = await project.createProject("gamma", "/p/c");
|
||||
project.setOpenProjectRestoreOrder([alpha.id, beta.id, gamma.id]);
|
||||
project.setOpenProjectRestoreFailure(beta.id, {
|
||||
name: beta.name,
|
||||
root: beta.root,
|
||||
code: "inaccessible",
|
||||
message: "directory metadata failed",
|
||||
});
|
||||
|
||||
renderView(project);
|
||||
|
||||
expect(await screen.findByText("1 projet n’a pas pu être rouvert")).toBeTruthy();
|
||||
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voir les détails" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Projets non rouverts" });
|
||||
expect(within(dialog).getByText("beta")).toBeTruthy();
|
||||
expect(within(dialog).getByText("/missing/b")).toBeTruthy();
|
||||
expect(within(dialog).getByText("Le chemin est introuvable ou inaccessible.")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "close Projets non rouverts" }));
|
||||
project.setOpenProjectRestoreFailure(beta.id, null);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
|
||||
|
||||
await waitFor(() => expect(within(tablist()).getAllByRole("tab")).toHaveLength(3));
|
||||
expect(
|
||||
within(tablist())
|
||||
.getAllByRole("tab")
|
||||
.map((tab) => tab.textContent?.replace("×", "").trim()),
|
||||
).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(screen.queryByText("1 projet n’a pas pu être rouvert")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes a failed project from future restore attempts", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/missing/a");
|
||||
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||
project.setOpenProjectRestoreFailure(alpha.id, {
|
||||
name: alpha.name,
|
||||
root: alpha.root,
|
||||
code: "notFound",
|
||||
message: "missing registry entry",
|
||||
});
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("1 projet n’a pas pu être rouvert");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voir les détails" }));
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole("dialog", { name: "Projets non rouverts" })).getByRole(
|
||||
"button",
|
||||
{ name: "Retirer de la reprise" },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Projets non rouverts")).toBeNull());
|
||||
expect((await project.readOpenProjectRestoreState()).entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the plural restore warning without duplicating the launch notification", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/missing/a");
|
||||
const beta = await project.createProject("beta", "/missing/b");
|
||||
project.setOpenProjectRestoreOrder([alpha.id, beta.id]);
|
||||
for (const retained of [alpha, beta]) {
|
||||
project.setOpenProjectRestoreFailure(retained.id, {
|
||||
name: retained.name,
|
||||
root: retained.root,
|
||||
code: "inaccessible",
|
||||
message: "offline volume",
|
||||
});
|
||||
}
|
||||
|
||||
renderView(project);
|
||||
|
||||
expect(await screen.findByText("2 projets n’ont pas pu être rouverts")).toBeTruthy();
|
||||
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists an explicit close by going through the backend before removing the tab", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||
renderView(project);
|
||||
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("No open tabs.")).toBeTruthy());
|
||||
expect((await project.readOpenProjectRestoreState()).entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a tab open when its backend close fails", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
project.setOpenProjectRestoreOrder([alpha.id]);
|
||||
project.closeProject = async () => {
|
||||
throw { code: "STORE", message: "could not persist close" };
|
||||
};
|
||||
renderView(project);
|
||||
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"could not persist close",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "alpha" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||
renderView();
|
||||
|
||||
|
||||
@ -7,9 +7,13 @@
|
||||
* testable with a mock gateway (ARCHITECTURE §1.3).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GatewayError, Project } from "@/domain";
|
||||
import type {
|
||||
GatewayError,
|
||||
OpenProjectRestoreFailure,
|
||||
Project,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the projects UI needs from this hook. */
|
||||
@ -24,6 +28,10 @@ export interface ProjectsViewModel {
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Retained projects that could not be restored during this launch/retry. */
|
||||
restoreFailures: OpenProjectRestoreFailure[];
|
||||
/** Whether the launch-only non-blocking failure notification is visible. */
|
||||
restoreNotificationVisible: boolean;
|
||||
/** Reloads the known-projects list from the registry. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Creates a project, refreshes the list and opens it as a tab. */
|
||||
@ -32,6 +40,11 @@ export interface ProjectsViewModel {
|
||||
openProject: (projectId: string) => Promise<void>;
|
||||
/** Closes a tab (does not delete the project from the registry). */
|
||||
closeTab: (projectId: string) => Promise<void>;
|
||||
/** Retries one failed restore, or every failure when omitted. */
|
||||
retryRestore: (projectId?: string) => Promise<void>;
|
||||
/** Removes one failure from the persisted next-launch restore set. */
|
||||
removeFromRestore: (projectId: string) => Promise<void>;
|
||||
dismissRestoreNotification: () => void;
|
||||
/** Activates an already-open tab. */
|
||||
activateTab: (projectId: string) => void;
|
||||
}
|
||||
@ -50,6 +63,9 @@ export function useProjects(): ProjectsViewModel {
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
|
||||
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
|
||||
const initializationStarted = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -64,8 +80,37 @@ export function useProjects(): ProjectsViewModel {
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
if (initializationStarted.current) return;
|
||||
initializationStarted.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
void Promise.allSettled([
|
||||
project.listProjects(),
|
||||
project.restoreOpenProjects(),
|
||||
]).then(([listed, restored]) => {
|
||||
const errors: string[] = [];
|
||||
if (listed.status === "fulfilled") setProjects(listed.value);
|
||||
else errors.push(describe(listed.reason));
|
||||
if (restored.status === "fulfilled") {
|
||||
setOpenTabs((current) => {
|
||||
const restoredIds = new Set(restored.value.opened.map((entry) => entry.id));
|
||||
return [
|
||||
...restored.value.opened,
|
||||
...current.filter((entry) => !restoredIds.has(entry.id)),
|
||||
];
|
||||
});
|
||||
setActiveTabId(
|
||||
(current) => current ?? restored.value.opened.at(-1)?.id ?? null,
|
||||
);
|
||||
setRestoreFailures(restored.value.failures);
|
||||
setRestoreNotificationVisible(restored.value.failures.length > 0);
|
||||
} else {
|
||||
errors.push(describe(restored.reason));
|
||||
}
|
||||
setError(errors.length > 0 ? errors.join(" · ") : null);
|
||||
setBusy(false);
|
||||
});
|
||||
}, [project]);
|
||||
|
||||
const addTab = useCallback((p: Project) => {
|
||||
setOpenTabs((prev) =>
|
||||
@ -114,6 +159,7 @@ export function useProjects(): ProjectsViewModel {
|
||||
await project.closeProject(projectId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return;
|
||||
}
|
||||
setOpenTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== projectId);
|
||||
@ -126,6 +172,62 @@ export function useProjects(): ProjectsViewModel {
|
||||
[project],
|
||||
);
|
||||
|
||||
const retryRestore = useCallback(
|
||||
async (projectId?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const positions = new Map(
|
||||
restoreFailures.map((failure) => [failure.projectId, failure.position]),
|
||||
);
|
||||
try {
|
||||
const result = await project.retryOpenProjectRestore(projectId);
|
||||
setOpenTabs((previous) => {
|
||||
const next = [...previous];
|
||||
for (const opened of [...result.opened].sort(
|
||||
(a, b) => (positions.get(a.id) ?? next.length) - (positions.get(b.id) ?? next.length),
|
||||
)) {
|
||||
if (next.some((tab) => tab.id === opened.id)) continue;
|
||||
const position = positions.get(opened.id) ?? next.length;
|
||||
next.splice(Math.min(position, next.length), 0, opened);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setActiveTabId((current) => current ?? result.opened.at(-1)?.id ?? null);
|
||||
setRestoreFailures((previous) =>
|
||||
projectId === undefined
|
||||
? result.failures
|
||||
: [
|
||||
...previous.filter((failure) => failure.projectId !== projectId),
|
||||
...result.failures,
|
||||
].sort((a, b) => a.position - b.position),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[project, restoreFailures],
|
||||
);
|
||||
|
||||
const removeFromRestore = useCallback(
|
||||
async (projectId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await project.removeProjectFromRestore(projectId);
|
||||
setRestoreFailures((previous) =>
|
||||
previous.filter((failure) => failure.projectId !== projectId),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[project],
|
||||
);
|
||||
|
||||
const activateTab = useCallback((projectId: string) => {
|
||||
setActiveTabId(projectId);
|
||||
}, []);
|
||||
@ -136,10 +238,15 @@ export function useProjects(): ProjectsViewModel {
|
||||
activeTabId,
|
||||
error,
|
||||
busy,
|
||||
restoreFailures,
|
||||
restoreNotificationVisible,
|
||||
refresh,
|
||||
createProject,
|
||||
openProject,
|
||||
closeTab,
|
||||
retryRestore,
|
||||
removeFromRestore,
|
||||
dismissRestoreNotification: () => setRestoreNotificationVisible(false),
|
||||
activateTab,
|
||||
};
|
||||
}
|
||||
|
||||
@ -63,6 +63,11 @@ function gateways(overrides: {
|
||||
createProject: vi.fn(),
|
||||
openProject: vi.fn(),
|
||||
closeProject: vi.fn(),
|
||||
restoreOpenProjects: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||
retryOpenProjectRestore: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||
removeProjectFromRestore: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||
saveOpenProjectRestoreOrder: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||
readOpenProjectRestoreState: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||
readProjectContext: vi.fn(async () => "project context"),
|
||||
updateProjectContext: vi.fn(),
|
||||
listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })),
|
||||
|
||||
@ -70,6 +70,8 @@ import type {
|
||||
ProjectMcpToolPermissions,
|
||||
PageDirection,
|
||||
Project,
|
||||
OpenProjectRestoreState,
|
||||
RestoreOpenProjectsOutput,
|
||||
ProjectFileListing,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
@ -577,6 +579,16 @@ export interface ProjectGateway {
|
||||
openProject(projectId: string): Promise<Project>;
|
||||
/** Closes a project by id. */
|
||||
closeProject(projectId: string): Promise<void>;
|
||||
/** Restores the projects retained at the end of the previous app run. */
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput>;
|
||||
/** Retries one retained restore failure, or every failure when omitted. */
|
||||
retryOpenProjectRestore(projectId?: string): Promise<RestoreOpenProjectsOutput>;
|
||||
/** Stops retaining one failed project for later launches. */
|
||||
removeProjectFromRestore(projectId: string): Promise<OpenProjectRestoreState>;
|
||||
/** Persists the visible order while retaining omitted tombstones. */
|
||||
saveOpenProjectRestoreOrder(orderedProjectIds: string[]): Promise<OpenProjectRestoreState>;
|
||||
/** Reads the machine-local ordered restore state. */
|
||||
readOpenProjectRestoreState(): Promise<OpenProjectRestoreState>;
|
||||
/** Reads the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||
readProjectContext(projectId: string): Promise<string>;
|
||||
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||
|
||||
Reference in New Issue
Block a user