Blacksite/crates/editor/src/ui/diagnostics.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

327 lines
11 KiB
Rust

//! 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<String>,
pub project_validation: Option<scene::ProjectValidationReport>,
}
#[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::<SceneIo>();
(
scene_io.active_path_label(),
scene_io.dirty,
scene_io.status.clone(),
scene_io.events.iter().rev().cloned().collect::<Vec<_>>(),
)
};
let dirty = if dirty { " (dirty)" } else { "" };
ui.label(format!("Scene: {scene_label}{dirty}"));
ui.label(format!("Status: {status}"));
ui.label(format!(
"History: {}",
world.resource::<EditorHistory>().status
));
ui.label(format!(
"Assets: {}",
world.resource::<EditorAssets>().status
));
let workspace = world.resource::<ProjectWorkspace>();
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::<Entity, With<LevelObject>>();
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::<ProjectWorkspace>().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::<SceneIo>().set_status(summary);
world.resource_mut::<DiagnosticsPanel>().project_validation = Some(report);
}
});
if let Some(report) = world
.resource::<DiagnosticsPanel>()
.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::<SelectedEntity>().0 = Some(entity);
world
.resource_mut::<UiState>()
.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::<DiagnosticsPanel>().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::<DiagnosticsPanel>()
.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::<SceneIo>().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::<SelectedEntity>().0 = Some(row.entity);
world
.resource_mut::<UiState>()
.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<String>,
}
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<BrushDiagnosticRow> {
let mut query =
world.query_filtered::<(Entity, Option<&ActorName>, Option<&Name>, &BrushDesc), With<LevelObject>>();
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);
});
}