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:
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user