//! Detailed diagnostics (relocated from Status dock tab). use bevy::prelude::*; use bevy_egui::egui; use shared::{ brush_math::{validate_brush, BrushDiagnosticSeverity}, ActorName, BrushDesc, LevelObject, }; use crate::assets::EditorAssets; use crate::diagnostics_bundle::export_diagnostic_bundle; use crate::history::set_brush_with_history; use crate::history::EditorHistory; use crate::project_io::ProjectWorkspace; use crate::scene_io::{SceneIo, SceneIoEventSeverity}; use crate::selection::SelectedEntity; use crate::ui::UiState; #[derive(Resource, Default)] pub struct DiagnosticsPanel { pub open: bool, pub bundle_status: Option, pub project_validation: Option, } #[derive(Resource, Default)] pub struct BrushDiagnosticsPanel { pub open: bool, } pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { let (scene_label, dirty, status, scene_events) = { let scene_io = world.resource::(); ( scene_io.active_path_label(), scene_io.dirty, scene_io.status.clone(), scene_io.events.iter().rev().cloned().collect::>(), ) }; let dirty = if dirty { " (dirty)" } else { "" }; ui.label(format!("Scene: {scene_label}{dirty}")); ui.label(format!("Status: {status}")); ui.label(format!( "History: {}", world.resource::().status )); ui.label(format!( "Assets: {}", world.resource::().status )); let workspace = world.resource::(); ui.label(format!("Project root: {}", workspace.root)); ui.label(format!( "Project settings: {}{}", workspace.settings_path, if workspace.settings_dirty { " (dirty)" } else { "" } )); if workspace.layout_dirty { ui.small("Dock layout modified (unsaved)"); } let mut level_query = world.query_filtered::>(); let count = level_query.iter(world).count(); ui.label(format!("Level objects: {count}")); ui.separator(); ui.horizontal(|ui| { ui.strong("Project validation"); if ui.button("Validate Project").clicked() { let root = world.resource::().root.clone(); let report = scene::validate_project(std::path::Path::new(&root)); let summary = format!( "Project validation: {} dependencies, {} findings, {} blocking errors", report.dependencies.len(), report.findings.len(), report.blocking_error_count() ); world.resource_mut::().set_status(summary); world.resource_mut::().project_validation = Some(report); } }); if let Some(report) = world .resource::() .project_validation .as_ref() .cloned() { let color = if report.is_release_ready() { egui::Color32::from_rgb(125, 210, 145) } else { egui::Color32::from_rgb(255, 137, 129) }; ui.colored_label( color, format!( "{} dependencies, {} blocking errors", report.dependencies.len(), report.blocking_error_count() ), ); for finding in report.findings.iter().take(12) { let actor = finding .owner_actor_id .as_deref() .map(|actor| format!(" / {actor}")) .unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.monospace(format!("{}{}", finding.source_path, actor)); ui.label(&finding.message); if let Some(actor_id) = finding.owner_actor_id.as_deref() { if ui.small_button("Select").clicked() { let mut query = world.query::<(Entity, &shared::ActorId)>(); if let Some(entity) = query .iter(world) .find_map(|(entity, id)| (id.0 == actor_id).then_some(entity)) { world.resource_mut::().0 = Some(entity); world .resource_mut::() .selected_entities .select_replace(entity); } } } }); } if report.findings.len() > 12 { ui.small(format!( "{} additional findings", report.findings.len() - 12 )); } } ui.separator(); ui.horizontal(|ui| { ui.strong("Support bundle"); if ui.button("Export Diagnostic Bundle").clicked() { let status = match export_diagnostic_bundle(world) { Ok(path) => format!("Exported {}", path.display()), Err(error) => format!("Export failed: {error}"), }; world.resource_mut::().bundle_status = Some(status); } }); ui.small("Allowlisted metadata only; scene and asset contents, environment values, credentials, and modal state are excluded."); if let Some(status) = world .resource::() .bundle_status .as_deref() { ui.monospace(status); } ui.separator(); ui.horizontal(|ui| { ui.strong("Scene I/O log"); if ui .add_enabled(!scene_events.is_empty(), egui::Button::new("Clear")) .clicked() { world.resource_mut::().clear_events(); } }); if scene_events.is_empty() { ui.small("No scene I/O operations recorded this session."); } else { egui::ScrollArea::vertical() .id_salt("scene_io_diagnostics_log") .max_height(180.0) .show(ui, |ui| { for event in scene_events { let color = match event.severity { SceneIoEventSeverity::Info => ui.visuals().weak_text_color(), SceneIoEventSeverity::Error => egui::Color32::from_rgb(244, 110, 105), }; ui.horizontal_wrapped(|ui| { ui.colored_label(color, format!("#{:02}", event.id)); ui.colored_label(color, event.message); }); } }); } ui.separator(); ui.small("Native Bevy scenes are saved to assets/levels/*.scn.ron"); ui.small("BRP is enabled on the editor for external tooling."); } pub fn brush_diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) { if !*open { return; } egui::Window::new("Brush Diagnostics") .open(open) .default_width(520.0) .show(ctx, |ui| { brush_diagnostics_ui(world, ui); }); } fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { let rows = brush_diagnostic_rows(world); let invalid_count = rows.iter().filter(|row| row.error_count > 0).count(); ui.label(format!( "{} brush actor(s), {} invalid", rows.len(), invalid_count )); ui.separator(); if rows.is_empty() { ui.small("No brush actors in the current scene."); return; } for row in rows { ui.horizontal_wrapped(|ui| { let color = if row.error_count > 0 { egui::Color32::from_rgb(255, 137, 129) } else if row.warning_count > 0 { egui::Color32::from_rgb(255, 190, 110) } else { egui::Color32::from_rgb(125, 210, 145) }; ui.colored_label(color, row.status_label()); ui.label(row.name.as_str()); if ui.button("Select").clicked() { world.resource_mut::().0 = Some(row.entity); world .resource_mut::() .selected_entities .select_replace(row.entity); } if ui .add_enabled(row.error_count > 0, egui::Button::new("Reset Cube")) .clicked() { set_brush_with_history(world, row.entity, BrushDesc::default()); } }); if let Some(message) = row.first_message.as_ref() { ui.small(message); } } } #[derive(Debug)] struct BrushDiagnosticRow { entity: Entity, name: String, error_count: usize, warning_count: usize, first_message: Option, } impl BrushDiagnosticRow { fn status_label(&self) -> String { if self.error_count > 0 { format!("{} error(s)", self.error_count) } else if self.warning_count > 0 { format!("{} warning(s)", self.warning_count) } else { "valid".into() } } } fn brush_diagnostic_rows(world: &mut World) -> Vec { let mut query = world.query_filtered::<(Entity, Option<&ActorName>, Option<&Name>, &BrushDesc), With>(); let mut rows: Vec<_> = query .iter(world) .map(|(entity, actor_name, name, brush)| { let report = validate_brush(brush); let error_count = report .diagnostics .iter() .filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error) .count(); let warning_count = report .diagnostics .iter() .filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning) .count(); let first_message = report .diagnostics .first() .map(|diagnostic| diagnostic.message.clone()); BrushDiagnosticRow { entity, name: actor_name .map(|name| name.0.clone()) .or_else(|| name.map(|name| name.to_string())) .unwrap_or_else(|| format!("{entity:?}")), error_count, warning_count, first_message, } }) .collect(); rows.sort_by(|a, b| { b.error_count .cmp(&a.error_count) .then(b.warning_count.cmp(&a.warning_count)) .then(a.name.cmp(&b.name)) }); rows } pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) { if !*open { return; } egui::Window::new("Diagnostics") .open(open) .default_width(420.0) .show(ctx, |ui| { diagnostics_ui(world, ui); }); }