ui: Help › Crash Reports lists the saved reports to report, copy or delete (#300)

diagnostics.crash_reports.delete deletes one report or all. The window lists each report
(when, what, how many times) with Report…, Copy Path and Delete, and Delete All; ui.prompt
answers it over the control channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 11:07:46 -04:00
1 parent 4711f9474a
commit 441f03d093
11 files changed
+231 -7

No files matched your search

+3
View File
@@ -349,10 +349,13 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
.on_hover_text("Download this version again, verify it and swap it in, to repair a damaged install")
.clicked();
}
let crashes = ui.button("Crash Reports…").on_hover_text("The crash reports saved on this computer: report, copy or delete them").clicked();
let settings = ui.button("Settings…").on_hover_text("Privacy and updates").clicked();
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
} else if crashes {
app.open_crash_reports();
} else if check {
app.check_for_updates();
} else if reinstall {
+14 -1
View File
@@ -256,6 +256,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}),
crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}),
crate::files::Prompt::Crashed(r) => json!({"crashed": r}),
crate::files::Prompt::CrashReports(r) => json!({"crash_reports": r}),
crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}),
crate::files::Prompt::Consent => json!({"consent": crate::privacy::QUESTION}),
crate::files::Prompt::Settings => json!("settings"),
@@ -449,7 +450,19 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.report_bug(r.first().map(|c| c.id.clone()));
ok(json!({"prompt": "report_bug"}))
}
(crate::files::Prompt::Crashed(_), Some("close" | "cancel")) => {
(crate::files::Prompt::CrashReports(_), Some("report")) => match s("id") {
Some(id) => {
app.report_bug(Some(id.to_string()));
ok(json!({"prompt": "report_bug"}))
}
None => err("missing `id` (a crash report)"),
},
(crate::files::Prompt::CrashReports(_), Some("delete")) => match s("id") {
Some(id) => wrap(app.delete_crash_reports(Some(id))),
None => err("missing `id` (a crash report)"),
},
(crate::files::Prompt::CrashReports(_), Some("delete_all")) => wrap(app.delete_crash_reports(None)),
(crate::files::Prompt::Crashed(_) | crate::files::Prompt::CrashReports(_), Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
+6 -2
View File
@@ -402,6 +402,9 @@ pub enum Prompt {
Crashed(Vec<fieldcraft_engine::crash::Summary>),
/// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]).
ReportBug,
/// Help › Crash Reports: the saved reports, newest first, each to report, copy or delete
/// ([`crate::report`]).
CrashReports(Vec<fieldcraft_engine::crash::Summary>),
}
/// The answer to [`Prompt::Design`].
@@ -940,7 +943,8 @@ impl ModelApp {
"file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover),
"file.sample" => self.prompt = Some(Prompt::Samples),
"file.new" => self.new_model(),
"diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None),
"diagnostics.bug_report" => self.report_bug(None),
"diagnostics.crash_reports" | "diagnostics.crash_report" | "diagnostics.crash_reports.delete" => self.open_crash_reports(),
"solve.run" => self.solve_all(),
"deck.write" if can_save => match preset.get("format").and_then(Value::as_str) {
Some(f) => self.write_deck(f),
@@ -1208,7 +1212,7 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
}
// Drawn by `crate::report::show` and `crate::privacy::show`.
Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings | Prompt::Update) | None => {}
Some(Prompt::Crashed(_) | Prompt::CrashReports(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings | Prompt::Update) | None => {}
}
}
+129
View File
@@ -93,6 +93,33 @@ impl ModelApp {
self.ui.menu = None;
}
/// Help › Crash Reports: the saved reports (`diagnostics.crash_reports`), newest first.
pub fn open_crash_reports(&mut self) {
// Read directly: a listing changes nothing, and the window shows it instead of the reply.
let reports = match self.session.execute("diagnostics.crash_reports", &json!({})) {
Ok(r) => r.get("reports").and_then(Value::as_array).map_or_else(Vec::new, |a| a.iter().filter_map(summary_of).collect()),
Err(e) => {
self.set_status(format!("Crash reports: {e}"), true);
return;
}
};
self.prompt = Some(Prompt::CrashReports(reports));
self.ui.menu = None;
}
/// Delete one crash report, or all (`diagnostics.crash_reports.delete`), and list them again.
pub fn delete_crash_reports(&mut self, id: Option<&str>) -> Result<Value, String> {
let params = match id {
Some(id) => json!({"id": id}),
None => json!({"all": true}),
};
let r = self.run("diagnostics.crash_reports.delete", params)?;
let n = r.get("deleted").and_then(Value::as_u64).unwrap_or(0);
self.set_status(format!("Deleted {n} crash report{}", if n == 1 { "" } else { "s" }), false);
self.open_crash_reports();
Ok(r)
}
/// The host found these crash reports at startup: ask about the real crashes.
pub fn announce_crashes(&mut self, fresh: &[Summary]) {
let crashes: Vec<Summary> = fresh.iter().filter(|r| r.kind != "recovered_panic").cloned().collect();
@@ -178,6 +205,22 @@ impl ModelApp {
}
}
/// A crash report as `diagnostics.crash_reports` lists it.
fn summary_of(v: &Value) -> Option<Summary> {
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
Some(Summary {
id: v.get("id").and_then(Value::as_str)?.to_string(),
kind: s("kind"),
time: s("time"),
message: s("message"),
command: v.get("command").and_then(Value::as_str).map(str::to_string),
count: v.get("count").and_then(Value::as_u64).unwrap_or(1),
version: s("version"),
path: s("path"),
replayable: v.get("replayable").and_then(Value::as_bool).unwrap_or(false),
})
}
fn size(bytes: usize) -> String {
if bytes < 1024 { format!("{bytes} B") } else { format!("{} KB", bytes.div_ceil(1024)) }
}
@@ -228,6 +271,74 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
}
}
Some(Prompt::CrashReports(reports)) => {
enum Act {
Report(String),
Copy(String),
Delete(String),
DeleteAll,
Close,
}
let mut act = None;
crate::frame::window(ctx, "Crash Reports", crate::frame::Width::Wide).id(egui::Id::new("fc_crash_reports")).show(ctx, |ui| {
if reports.is_empty() {
ui.label("No crash reports are saved on this computer.");
} else {
ui.label(
RichText::new("Saved on this computer, newest first. Reporting one opens a GitHub issue you review first; nothing is sent until you submit it.")
.color(t.text_dim),
);
ui.add_space(4.0);
egui::ScrollArea::vertical().max_height((ctx.content_rect().height() * 0.6).max(160.0)).show(ui, |ui| {
for r in &reports {
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new(kind_label(&r.kind)).strong());
let when = if r.count > 1 { format!("{} · {} times", r.time, r.count) } else { r.time.clone() };
ui.label(RichText::new(when).color(t.text_dim));
});
ui.label(RichText::new(&r.message).color(t.text_dim).size(11.0));
ui.horizontal(|ui| {
if ui.button("Report…").clicked() {
act = Some(Act::Report(r.id.clone()));
}
if ui.button("Copy Path").on_hover_text(&r.path).clicked() {
act = Some(Act::Copy(r.path.clone()));
}
if ui.button("Delete").clicked() {
act = Some(Act::Delete(r.id.clone()));
}
});
}
});
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.add_enabled(!reports.is_empty(), egui::Button::new("Delete All")).clicked() {
act = Some(Act::DeleteAll);
}
if ui.button("Close").clicked() {
act = Some(Act::Close);
}
});
});
match act {
Some(Act::Report(id)) => app.report_bug(Some(id)),
Some(Act::Copy(path)) => {
ctx.copy_text(path);
app.set_status("Copied the crash report's folder", false);
}
Some(Act::Delete(id)) => {
let _ = app.delete_crash_reports(Some(&id));
}
Some(Act::DeleteAll) => {
let _ = app.delete_crash_reports(None);
}
Some(Act::Close) => app.prompt = None,
None if ctx.input(|i| i.key_pressed(egui::Key::Escape)) => app.prompt = None,
None => {}
}
}
Some(Prompt::ReportBug) => {
let Some(mut form) = app.report.take() else {
app.prompt = None;
@@ -336,6 +447,24 @@ mod tests {
assert_eq!(d["attachments"], json!([]), "{d}");
}
/// Help › Crash Reports reads each report as `diagnostics.crash_reports` lists it.
#[test]
fn crash_reports_read_the_listing() {
let r = Summary {
id: "20261014-101500-0000000000000001".into(),
kind: "unclean_exit".into(),
time: "2026-10-14T10:15:00Z".into(),
message: "ended".into(),
command: Some("solve.run".into()),
count: 2,
version: "0.5.9".into(),
path: "/x".into(),
replayable: true,
};
assert_eq!(summary_of(&serde_json::to_value(&r).unwrap()), Some(r));
assert_eq!(summary_of(&json!({"kind": "panic"})), None, "a report without an id is left out");
}
fn bug_report_text(d: &Draft) -> String {
d.fields.iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>().join("\n")
}