From bbe8c1d04b210f31ab8184a53ad8fca453a4fe88 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 13:01:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend,application,backend,web-server,inf?= =?UTF-8?q?rastructure):=20le=20set=20de=20couleurs=20projet=20s'applique?= =?UTF-8?q?=20=C3=A0=20tous=20les=20projets=20=E2=80=94=20#316=20(QA=20ver?= =?UTF-8?q?te)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: update_project_settings remplaçait tout ProjectSettings avec la seule couleur fournie — champ absent et null indistinguables — et des réponses de chargement/sauvegarde obsolètes d'un projet précédent pouvaient réafficher/écraser la couleur lors d'un changement de projet rapide, d'où l'impression que la couleur ne prenait pas sur tous les projets. - DTO tri-état (absent = inchangé, null = défaut produit) avec Deserialize manuel et use case de mise à jour partielle du champ theme_color; commandes Tauri et web-server alignées sur le même contrat. - Garde-fous de course frontend: séquences load/save + révision thème par projet (pub/sub onProjectThemeChanged) dans ProjectSettingsPanel, garde de révision de focus dans ViewWindow. - Tests: infrastructure settings, contrats web-server (inchangé/reset), ProjectSettingsPanel, ViewWindow, projects. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 6 +- crates/application/src/settings.rs | 19 +++- crates/backend/src/dto.rs | 28 +++++- crates/infrastructure/tests/settings.rs | 37 ++++++++ crates/web-server/src/lib.rs | 24 ++++- frontend/src/app/ViewWindow.test.tsx | 44 ++++++++- frontend/src/app/ViewWindow.tsx | 7 +- .../projects/ProjectSettingsPanel.test.tsx | 91 ++++++++++++++++++- .../projects/ProjectSettingsPanel.tsx | 68 ++++++++++++-- .../src/features/projects/projects.test.tsx | 9 ++ 10 files changed, 306 insertions(+), 27 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 288618fb..245dcf3d 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -855,7 +855,7 @@ pub async fn read_project_settings( .map_err(Into::into) } -/// Validates and replaces project-owned settings. +/// Validates and updates project-owned settings. #[tauri::command] pub async fn update_project_settings( request: UpdateProjectSettingsRequestDto, @@ -864,12 +864,12 @@ pub async fn update_project_settings( let project = resolve_project(&request.project_id, &state).await?; let theme_color = request .theme_color - .map(domain::ThemeColor::new) + .map(|value| value.map(domain::ThemeColor::new).transpose()) .transpose() .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; state .update_project_settings - .execute(&project, domain::ProjectSettings { theme_color }) + .execute(&project, theme_color) .await .map(Into::into) .map_err(Into::into) diff --git a/crates/application/src/settings.rs b/crates/application/src/settings.rs index c1069eda..e063297a 100644 --- a/crates/application/src/settings.rs +++ b/crates/application/src/settings.rs @@ -1,7 +1,9 @@ //! Read/update use cases for global and project settings. use crate::AppError; -use domain::{IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, UserSettingsStore}; +use domain::{ + IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, ThemeColor, UserSettingsStore, +}; use std::sync::Arc; /// Reads global machine-local user settings. @@ -50,7 +52,7 @@ impl ReadProjectSettings { self.store.load(&project.root).await.map_err(Into::into) } } -/// Replaces the settings owned by one project. +/// Updates the settings owned by one project. pub struct UpdateProjectSettings { store: Arc, } @@ -60,13 +62,20 @@ impl UpdateProjectSettings { pub fn new(store: Arc) -> Self { Self { store } } - /// Persists and returns the accepted project settings. + /// Applies the supplied fields and returns the accepted project settings. + /// + /// An absent outer option leaves the colour unchanged; an explicit inner + /// `None` restores the product fallback. pub async fn execute( &self, project: &Project, - settings: ProjectSettings, + theme_color: Option>, ) -> Result { - self.store.save(&project.root, &settings).await?; + let mut settings = self.store.load(&project.root).await?; + if let Some(theme_color) = theme_color { + settings.theme_color = theme_color; + self.store.save(&project.root, &settings).await?; + } Ok(settings) } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 3ab13f13..bb2e18f0 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3295,14 +3295,32 @@ impl From for ProjectSettingsDto { } /// Project settings update request. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone)] pub struct UpdateProjectSettingsRequestDto { /// Project registry id. pub project_id: String, - /// New colour, or null to restore the product default. - #[serde(default)] - pub theme_color: Option, + /// Missing leaves the colour unchanged; null restores the product default. + pub theme_color: Option>, +} + +impl<'de> Deserialize<'de> for UpdateProjectSettingsRequestDto { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut value = Value::deserialize(deserializer)?; + let object = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("expected project settings update object"))?; + let project_id = + take_required_string(object, "projectId").map_err(serde::de::Error::custom)?; + let theme_color = take_optional_value::(object, "themeColor") + .map_err(serde::de::Error::custom)?; + Ok(Self { + project_id, + theme_color, + }) + } } /// Request DTO for `launch_agent`. diff --git a/crates/infrastructure/tests/settings.rs b/crates/infrastructure/tests/settings.rs index 4e6fdf34..e6f5947c 100644 --- a/crates/infrastructure/tests/settings.rs +++ b/crates/infrastructure/tests/settings.rs @@ -62,9 +62,46 @@ async fn project_settings_missing_then_valid_round_trip() { }; store.save(&tmp.root(), &settings).await.unwrap(); assert_eq!(store.load(&tmp.root()).await.unwrap(), settings); + assert_eq!( + std::fs::read_to_string(tmp.0.join(".ideai/project-settings.json")).unwrap(), + "{\n \"themeColor\": \"#12AbEF\"\n}\n" + ); assert!(!tmp.0.join(".ideai/CONTEXT.md").exists()); } +#[tokio::test] +async fn project_settings_are_isolated_and_missing_defaults_are_not_persisted() { + let first = TempDir::new(); + let second = TempDir::new(); + let store = FsProjectSettingsStore::new(fs()); + + store + .save( + &first.root(), + &ProjectSettings { + theme_color: Some(ThemeColor::new("#AA3366").unwrap()), + }, + ) + .await + .unwrap(); + + assert_eq!( + store.load(&second.root()).await.unwrap(), + ProjectSettings::default() + ); + assert!(!second.0.join(".ideai/project-settings.json").exists()); + assert_eq!( + store + .load(&first.root()) + .await + .unwrap() + .theme_color + .as_ref() + .map(ThemeColor::as_str), + Some("#AA3366") + ); +} + #[test] fn theme_color_validation_is_strict() { assert!(ThemeColor::new("#112233").is_ok()); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index bdc9cf58..082b3339 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -3920,12 +3920,12 @@ async fn invoke_update_project_settings( let project = resolve_project_readonly(&request.project_id, state).await?; let theme_color = request .theme_color - .map(domain::ThemeColor::new) + .map(|value| value.map(domain::ThemeColor::new).transpose()) .transpose() .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; let output: ProjectSettingsDto = state .update_project_settings - .execute(&project, domain::ProjectSettings { theme_color }) + .execute(&project, theme_color) .await .map_err(ErrorDto::from)? .into(); @@ -10627,6 +10627,26 @@ mod tests { .await; assert_eq!(status, StatusCode::OK); assert_eq!(saved_project["themeColor"], "#AA3366"); + + let (status, unchanged_project) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "update_project_settings", + json!({ "request": { "projectId": project_id } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged_project["themeColor"], "#AA3366"); + + let (status, reset_project) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "update_project_settings", + json!({ "request": { "projectId": project_id, "themeColor": null } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(reset_project["themeColor"], Value::Null); } #[tokio::test] diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 6ceef10c..71260870 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -5,7 +5,7 @@ * is focused and mounts the requested view once one is, without ever opening a * project itself. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import { DIProvider } from "@/app/di"; @@ -31,6 +31,14 @@ function b64url(value: string): string { return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + describe("parseViewWindowParams (#47)", () => { it("parses a valid panel-only query (ignores any legacy project param)", () => { expect(parseViewWindowParams("?panel=tickets")).toEqual({ @@ -162,6 +170,40 @@ describe("PluginLayoutWindow (#143)", () => { }); describe("ViewWindow (#47)", () => { + it("keeps the latest focused project when the initial snapshot is delayed", async () => { + const system = new MockSystemGateway(); + const project = new MockProjectGateway(); + const alpha = await project.createProject("alpha", "/p/a"); + const beta = await project.createProject("beta", "/p/b"); + await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" }); + await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" }); + const focusedProject = new MockFocusedProjectGateway(); + const initialFocus = deferred<{ id: string; name: string; root: string } | null>(); + vi.spyOn(focusedProject, "getFocusedProject").mockReturnValue(initialFocus.promise); + const gateways = { + system, + project, + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + focusedProject, + } as unknown as Gateways; + + const { container } = render( + + + , + ); + await focusedProject.setFocusedProject({ id: beta.id, name: "beta", root: "/p/b" }); + initialFocus.resolve({ id: alpha.id, name: "alpha", root: "/p/a" }); + + const root = container.firstElementChild as HTMLElement; + await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy()); + await waitFor(() => + expect(root.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"), + ); + expect(screen.queryByText("· alpha")).toBeNull(); + }); + it("applies and updates the focused project colour in a detached settings window", async () => { const system = new MockSystemGateway(); const project = new MockProjectGateway(); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index 7a4dc015..499a6b67 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -128,16 +128,19 @@ export function ViewWindow({ panel }: ViewWindowProps) { useEffect(() => { let cancelled = false; let unsubscribe: (() => void) | undefined; + let focusRevision = 0; + const initialRevision = focusRevision; focusedProject .getFocusedProject() .then((project) => { - if (!cancelled) setFocus(project); + if (!cancelled && focusRevision === initialRevision) setFocus(project); }) .catch(() => { - if (!cancelled) setFocus(null); + if (!cancelled && focusRevision === initialRevision) setFocus(null); }); void focusedProject .onFocusedProjectChanged((project) => { + focusRevision += 1; if (!cancelled) setFocus(project); }) .then((un) => { diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx index 5eb7724c..1832968f 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -4,7 +4,19 @@ import { describe, expect, it, vi } from "vitest"; import { createMockGateways } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel"; -import { accessibleProjectAccent, validThemeColor } from "./projectTheme"; +import { + accessibleProjectAccent, + publishProjectThemeChanged, + validThemeColor, +} from "./projectTheme"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} describe("ProjectSettingsPanel", () => { it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => { @@ -55,6 +67,83 @@ describe("ProjectSettingsPanel", () => { ); }); + it("keeps sibling settings surfaces in sync without persisting the blue fallback", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Alpha", "/alpha"); + const update = vi.spyOn(gateways.project, "updateProjectSettings"); + render( + + + + , + ); + + const pickers = (await screen.findAllByLabelText( + "Couleur du projet", + )) as HTMLInputElement[]; + await waitFor(() => + expect(pickers.every((picker) => !picker.disabled)).toBe(true), + ); + expect(pickers.map((picker) => picker.value)).toEqual(["#4c8dff", "#4c8dff"]); + expect(update).not.toHaveBeenCalled(); + + fireEvent.change(pickers[0], { target: { value: "#aa3366" } }); + await waitFor(() => expect(pickers[1].value).toBe("#aa3366")); + }); + + it("does not let a delayed read replace a newer cross-window colour", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Alpha", "/alpha"); + const pendingRead = deferred<{ themeColor: string | null }>(); + vi.spyOn(gateways.project, "readProjectSettings").mockReturnValue( + pendingRead.promise, + ); + render( + + + , + ); + + publishProjectThemeChanged({ projectId: project.id, themeColor: "#22AA66" }); + pendingRead.resolve({ themeColor: "#AA3366" }); + + await waitFor(() => + expect((screen.getByLabelText("Couleur du projet") as HTMLInputElement).value).toBe( + "#22aa66", + ), + ); + }); + + it("does not apply a delayed save response to the newly edited project", async () => { + const gateways = createMockGateways(); + const alpha = await gateways.project.createProject("Alpha", "/alpha"); + const beta = await gateways.project.createProject("Beta", "/beta"); + await gateways.project.updateProjectSettings(beta.id, { themeColor: "#22AA66" }); + const pendingSave = deferred<{ themeColor: string | null }>(); + vi.spyOn(gateways.project, "updateProjectSettings").mockImplementation( + async (projectId, settings) => + projectId === alpha.id ? pendingSave.promise : settings, + ); + const view = render( + + + , + ); + + const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement; + await waitFor(() => expect(picker.disabled).toBe(false)); + fireEvent.change(picker, { target: { value: "#aa3366" } }); + view.rerender( + + + , + ); + await waitFor(() => expect(picker.value).toBe("#22aa66")); + + pendingSave.resolve({ themeColor: "#AA3366" }); + await waitFor(() => expect(picker.value).toBe("#22aa66")); + }); + it("keeps strict colours and raises dark accents to readable contrast", () => { expect(validThemeColor("#12abEF")).toBe("#12ABEF"); expect(validThemeColor("red")).toBeNull(); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx index 2fe9a022..4db4a571 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -5,6 +5,7 @@ import { Button, Panel, Spinner } from "@/shared"; import { ProjectContextPanel } from "./ProjectContextPanel"; import { DEFAULT_PROJECT_COLOR, + onProjectThemeChanged, publishProjectThemeChanged, validThemeColor, } from "./projectTheme"; @@ -17,18 +18,32 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const [busy, setBusy] = useState(true); const [error, setError] = useState(null); const loadSequence = useRef(0); + const saveSequence = useRef(0); + const currentProjectId = useRef(projectId); + const themeRevisionRef = useRef>({}); + currentProjectId.current = projectId; const load = useCallback(async () => { const sequence = ++loadSequence.current; + const loadedProjectId = projectId; + const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0; setBusy(true); setError(null); setThemeColor(null); try { - const settings = await project.readProjectSettings(projectId); - if (loadSequence.current !== sequence) return; + const settings = await project.readProjectSettings(loadedProjectId); + if ( + loadSequence.current !== sequence || + currentProjectId.current !== loadedProjectId || + (themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision + ) return; setThemeColor(validThemeColor(settings.themeColor)); } catch { - if (loadSequence.current !== sequence) return; + if ( + loadSequence.current !== sequence || + currentProjectId.current !== loadedProjectId || + (themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision + ) return; setError("Impossible de charger les paramètres du projet."); } finally { if (loadSequence.current === sequence) setBusy(false); @@ -39,21 +54,58 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { void load(); return () => { loadSequence.current += 1; + saveSequence.current += 1; }; }, [load]); + useEffect( + () => + onProjectThemeChanged( + ({ projectId: changedProjectId, themeColor: next }) => { + themeRevisionRef.current[changedProjectId] = + (themeRevisionRef.current[changedProjectId] ?? 0) + 1; + if (changedProjectId === currentProjectId.current) { + setThemeColor(validThemeColor(next)); + } + }, + ), + [], + ); + async function save(next: string | null) { + const savedProjectId = projectId; + const sequence = ++saveSequence.current; setBusy(true); setError(null); try { - const saved = await project.updateProjectSettings(projectId, { themeColor: next }); + const saved = await project.updateProjectSettings(savedProjectId, { + themeColor: next, + }); const normalized = validThemeColor(saved.themeColor); - setThemeColor(normalized); - publishProjectThemeChanged({ projectId, themeColor: normalized }); + if ( + currentProjectId.current === savedProjectId && + saveSequence.current === sequence + ) { + setThemeColor(normalized); + } + publishProjectThemeChanged({ + projectId: savedProjectId, + themeColor: normalized, + }); } catch { - setError("Impossible d’enregistrer la couleur du projet."); + if ( + currentProjectId.current === savedProjectId && + saveSequence.current === sequence + ) { + setError("Impossible d’enregistrer la couleur du projet."); + } } finally { - setBusy(false); + if ( + currentProjectId.current === savedProjectId && + saveSequence.current === sequence + ) { + setBusy(false); + } } } diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index 621ccc59..31b529bc 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -114,6 +114,7 @@ describe("ProjectsView (with MockProjectGateway)", () => { const project = new MockProjectGateway(); const alpha = await project.createProject("alpha", "/p/a"); const beta = await project.createProject("beta", "/p/b"); + await project.createProject("gamma", "/p/c"); await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" }); await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" }); renderView(project); @@ -134,6 +135,14 @@ describe("ProjectsView (with MockProjectGateway)", () => { expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"), ); + const gammaRow = screen + .getAllByRole("listitem") + .find((item) => within(item).queryByText("gamma"))!; + fireEvent.click(within(gammaRow).getByRole("button", { name: "Open" })); + await waitFor(() => + expect(scope.style.getPropertyValue("--project-theme-color")).toBe(""), + ); + fireEvent.click(screen.getByRole("tab", { name: "alpha" })); await waitFor(() => expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),