fix(frontend,application,backend,web-server,infrastructure): le set de couleurs projet s'applique à tous les projets — #316 (QA verte)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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<dyn ProjectSettingsStore>,
|
||||
}
|
||||
@ -60,13 +62,20 @@ impl UpdateProjectSettings {
|
||||
pub fn new(store: Arc<dyn ProjectSettingsStore>) -> 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<Option<ThemeColor>>,
|
||||
) -> Result<ProjectSettings, AppError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3295,14 +3295,32 @@ impl From<domain::ProjectSettings> 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<String>,
|
||||
/// Missing leaves the colour unchanged; null restores the product default.
|
||||
pub theme_color: Option<Option<String>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UpdateProjectSettingsRequestDto {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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::<String>(object, "themeColor")
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(Self {
|
||||
project_id,
|
||||
theme_color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `launch_agent`.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user