Files
OneCAD/crates/ui-egui/src/crash_ui.rs
T
bherbruckandClaude Opus 5.5 296683229d Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push)

* WIP2

* WIP3

* WIP4 wxs fix

* rustfmt

* Rename the remaining identifiers in merged crash-report code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:17:01 -04:00

239 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Crash Reports window (Help > Crash Reports…): the reports `solidcraft_engine::crash`
//! keeps on this computer, each with a reviewed issue draft. It opens by itself after a crash.
use egui::{RichText, vec2};
use solidcraft_engine::crash::{self, Summary};
use crate::SolveApp;
use crate::theme::Tokens;
#[derive(Default)]
pub struct CrashUi {
pub open: bool,
/// The crash folder (default: the engine's, `crash::default_dir`).
pub folder: Option<std::path::PathBuf>,
reports: Vec<Summary>,
dir: Option<String>,
note: String,
}
impl CrashUi {
/// Read the reports again (when the window opens or changes; never every frame).
pub fn refresh(&mut self) {
let dir = self.dir_path();
self.reports = dir.as_deref().map(crash::list).unwrap_or_default();
self.dir = dir.map(|d| d.to_string_lossy().into_owned());
}
}
impl CrashUi {
fn dir_path(&self) -> Option<std::path::PathBuf> {
self.folder.clone().or_else(crash::default_dir)
}
}
fn kind_label(kind: &str) -> &'static str {
match kind {
"command_panic" => "Internal error in a command",
"kernel_panic_recovered" => "Kernel error (recovered)",
"unclean_exit" => "Closed unexpectedly",
_ => "Internal error",
}
}
/// The host found these new reports at startup: tell the user about real crashes (recovered
/// kernel errors stay quiet; they are listed in the window).
pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
let crashes = fresh.iter().filter(|r| r.kind != "kernel_panic_recovered").count();
if crashes == 0 {
return;
}
app.crashes.open = true;
app.crashes.refresh();
app.set_status("SolidCraft closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !app.crashes.open {
return;
}
let t = Tokens::get();
let mut open = true;
let mut report: Option<String> = None;
let mut delete: Option<Option<String>> = None;
crate::frame::window(ctx, "Crash Reports", crate::frame::Width::Wide)
.id(egui::Id::new("sc_crash_reports"))
.open(&mut open)
.default_size(vec2(620.0, 420.0))
.show(ctx, |ui| {
ui.label(
RichText::new(
"Crash reports stay on this computer and are never sent anywhere. Report… opens a draft you review before anything is shared.",
)
.color(t.text_dim),
);
if let Some(d) = app.crashes.dir.clone() {
// A long path is cut short (hover shows it all): it must not widen the window
// past its title bar.
ui.horizontal(|ui| {
if ui.small_button("Copy folder path").clicked() {
ctx.copy_text(d.clone());
}
ui.add(egui::Label::new(RichText::new(&d).monospace().size(11.0)).truncate()).on_hover_text(&d);
});
}
ui.separator();
if app.crashes.reports.is_empty() {
ui.label("No crash reports.");
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for r in &app.crashes.reports {
ui.horizontal(|ui| {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
ui.label(
RichText::new(format!("{} {}{times}", r.time.replace('T', " ").trim_end_matches('Z'), kind_label(&r.kind))).strong(),
);
if let Some(c) = &r.command {
ui.label(RichText::new(c).monospace().size(11.0).color(t.text_dim));
}
});
ui.label(RichText::new(&r.message).size(12.0));
ui.horizontal(|ui| {
if ui.small_button("Report…").clicked() {
report = Some(r.id.clone());
}
if ui.small_button("Copy path").clicked() {
ctx.copy_text(r.path.clone());
}
if ui.small_button("Delete").clicked() {
delete = Some(Some(r.id.clone()));
}
if r.replayable {
ui.label(RichText::new("replayable").size(11.0).color(t.text_dim));
}
});
ui.add_space(6.0);
}
});
if !app.crashes.note.is_empty() {
ui.label(RichText::new(&app.crashes.note).color(t.text_dim));
}
ui.separator();
if !app.crashes.reports.is_empty() && ui.button("Delete all").clicked() {
delete = Some(None);
}
});
app.crashes.open = open;
if let Some(id) = report
&& let Some(dir) = app.crashes.dir_path()
{
match crash::read(&dir, &id) {
Ok(v) => crate::issue_report::begin_crash(app, ctx, &v),
Err(e) => app.crashes.note = e,
}
}
if let Some(which) = delete
&& let Some(dir) = app.crashes.dir_path()
{
let n = crash::clear(&dir, which.as_deref());
app.crashes.note = format!("Deleted {n} report(s).");
app.crashes.refresh();
}
}
/// Help > Crash Reports…
pub fn open(app: &mut SolveApp) {
app.crashes.open = true;
app.crashes.note.clear();
app.crashes.refresh();
}
#[cfg(test)]
mod tests {
use serde_json::json;
fn folder(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-crash-ui-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let id = "20261012-162341-bb60791ed6692f1d";
std::fs::create_dir_all(d.join(id)).unwrap();
let report = json!({
"format": "solidcraft-crash/1", "id": id, "kind": "unclean_exit", "count": 2,
"first_seen": "2026-10-12T16:23:41Z", "message": "SolidCraft ended without a clean exit while running `solid.fillet`",
"location": "unclean exit in solid.fillet", "thread": "(unknown)", "backtrace": [],
"command": {"command": "solid.fillet", "params": {"radius": 2}},
"recent_commands": [{"command": "solid.box", "ok": true}],
"app": {"version": "0.3.2", "commit": "abc1234", "built": "2026-10-12"},
"system": {"os": "linux", "arch": "x86_64", "environment": "Renderer: test"},
});
std::fs::write(d.join(id).join("report.json"), report.to_string()).unwrap();
std::fs::write(d.join(id).join("replay.json"), r#"{"commands":[]}"#).unwrap();
d
}
#[test]
fn a_crash_report_opens_a_reviewed_issue_draft_and_can_be_deleted() {
let d = folder("draft");
let mut h = crate::scenario::Harness::new();
h.app.crashes.folder = Some(d.clone());
let fresh = solidcraft_engine::crash::list(&d);
assert_eq!(fresh.len(), 1);
super::announce(&mut h.app, &fresh);
assert!(h.app.crashes.open, "a real crash opens the window");
assert!(h.app.status.as_ref().is_some_and(|s| s.2), "and says so");
h.frames(4);
h.step(&json!({"widget": "Report…"})).unwrap();
h.frames(2);
let draft = h.app.issue_report.draft.as_ref().expect("Report… opens the draft");
assert!(draft.title.starts_with("Crash: SolidCraft ended without a clean exit"), "{}", draft.title);
for want in [
"while running `solid.fillet`",
"\"radius\": 2",
"solid.box",
"solidcraft-cli crashes replay 20261012-162341-bb60791ed6692f1d",
"Renderer: test",
"Happened 2 time(s)",
] {
assert!(draft.body.contains(want), "missing {want:?} in {}", draft.body);
}
assert!(!draft.body.contains(&d.to_string_lossy().into_owned()), "the draft names no local paths");
h.app.issue_report.draft = None;
h.frames(2);
h.step(&json!({"widget": "Delete all"})).unwrap();
h.frames(2);
assert!(solidcraft_engine::crash::list(&d).is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_long_folder_path_does_not_widen_the_window_past_its_title_bar() {
let d = folder(&format!("{}-long", "very-long-folder-name-".repeat(6)));
let mut h = crate::scenario::Harness::new();
h.app.crashes.folder = Some(d.clone());
super::open(&mut h.app);
h.frames(6);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_crash_reports"))).unwrap();
// The frame's margins come on top of the content's maximum width.
assert!(window.width() <= crate::frame::WINDOW_WIDE_MAX_W + 40.0, "{} wide", window.width());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn recovered_kernel_errors_do_not_interrupt() {
let mut h = crate::scenario::Harness::new();
let quiet = solidcraft_engine::crash::Summary {
id: "x".into(),
kind: "kernel_panic_recovered".into(),
time: String::new(),
message: String::new(),
command: None,
count: 1,
version: String::new(),
path: String::new(),
replayable: false,
};
super::announce(&mut h.app, &[quiet]);
assert!(!h.app.crashes.open);
}
}