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:
2026-09-10 08:14:28 +02:00
parent 3911ce1f4d
commit 8df80ae67e
25 changed files with 1812 additions and 134 deletions

View File

@ -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}

View File

@ -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();

View File

@ -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,
};
}