forked from 1b-agent/fieldcraft-upstream
499 lines
23 KiB
Rust
499 lines
23 KiB
Rust
//! Help › Crash Reports, "Report It…" after a crash ("OneCAD Simulation closed unexpectedly —
|
||
//! Report it"), and the Report a Bug form they open.
|
||
//!
|
||
// OneBSpace (OneCAD Simulation): upstream opened the filled GitHub issue form in the browser;
|
||
// the fork's report flow is local-only — the form shows exactly what the draft holds
|
||
// ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
|
||
// default) and, after a crash, where it happened. **Share my session** adds the commands that
|
||
// built the model, **Include the model file** the model as well. **Copy report** puts it all on
|
||
// the clipboard (nothing opens, nothing is sent); attachments are saved to a local folder to
|
||
// attach from wherever the user files their reports.
|
||
|
||
use egui::RichText;
|
||
use fieldcraft_engine::bug_report::{self, Draft, Input};
|
||
use fieldcraft_engine::crash::{self, Summary};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::ModelApp;
|
||
use crate::files::Prompt;
|
||
|
||
/// The Report a Bug form.
|
||
#[derive(Debug, Default)]
|
||
pub struct ReportForm {
|
||
pub title: String,
|
||
pub observed: String,
|
||
/// The crash report being reported (its id).
|
||
pub crash: Option<String>,
|
||
pub share_session: bool,
|
||
pub include_model: bool,
|
||
/// What the draft is built from (the session's commands and model, or the crash report's),
|
||
/// read when the form opens and when the model is first included.
|
||
input: Input,
|
||
model_read: bool,
|
||
/// The draft, and the form state it was made for.
|
||
draft: Option<Draft>,
|
||
drafted: Option<Key>,
|
||
/// Why the input could not be read.
|
||
pub problem: Option<String>,
|
||
}
|
||
|
||
type Key = (String, String, bool, bool);
|
||
|
||
impl ReportForm {
|
||
/// The form and its draft as last made (for `ui.inspect`).
|
||
pub fn drafted_json(&self) -> Option<Value> {
|
||
let mut v = self.draft.as_ref()?.to_json();
|
||
v["share_session"] = json!(self.share_session);
|
||
v["include_model"] = json!(self.include_model);
|
||
v["crash"] = json!(self.crash);
|
||
Some(v)
|
||
}
|
||
|
||
/// The draft for the form as it is now (made again only when something changed).
|
||
pub fn draft(&mut self) -> &Draft {
|
||
let key = (self.title.clone(), self.observed.clone(), self.share_session, self.include_model);
|
||
if self.drafted.as_ref() != Some(&key) {
|
||
self.draft = None;
|
||
self.drafted = Some(key);
|
||
}
|
||
self.draft.get_or_insert_with(|| {
|
||
let mut input = self.input.clone();
|
||
input.title = self.title.clone();
|
||
input.observed = self.observed.clone();
|
||
input.share_session = self.share_session;
|
||
input.include_model = self.include_model;
|
||
bug_report::draft(&input, &crash::Build::current().version_line())
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The input for `form` from the session (or its crash report).
|
||
fn read_input(app: &ModelApp, form: &ReportForm) -> Result<Input, String> {
|
||
let mut p = json!({"share_session": true, "include_model": form.include_model});
|
||
if let Some(id) = &form.crash {
|
||
p["crash"] = json!(id);
|
||
}
|
||
fieldcraft_engine::cmd::diagnostics::bug_report_input(&app.session, &p).map_err(|e| e.to_string())
|
||
}
|
||
|
||
impl ModelApp {
|
||
/// Open Help › Report a Bug, for `crash` (a report's id) or what happened last.
|
||
pub fn report_bug(&mut self, crash: Option<String>) {
|
||
let mut form = ReportForm {
|
||
observed: if crash.is_some() { String::new() } else { self.last_error.clone().unwrap_or_default() },
|
||
crash,
|
||
..Default::default()
|
||
};
|
||
match read_input(self, &form) {
|
||
Ok(input) => form.input = input,
|
||
Err(e) => form.problem = Some(e),
|
||
}
|
||
self.report = Some(form);
|
||
self.prompt = Some(Prompt::ReportBug);
|
||
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();
|
||
if crashes.is_empty() {
|
||
return;
|
||
}
|
||
log::warn!(
|
||
"{} crash report(s) since the last start: {}",
|
||
crashes.len(),
|
||
crashes.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(", ")
|
||
);
|
||
if self.prompt.is_none() {
|
||
self.prompt = Some(Prompt::Crashed(crashes));
|
||
}
|
||
}
|
||
|
||
/// Change the open form (`ui.prompt` `set`): any of title, observed, share_session,
|
||
/// include_model.
|
||
pub fn set_report(&mut self, p: &Value) -> Result<Value, String> {
|
||
let mut form = self.report.take().ok_or("Report a Bug is not open")?;
|
||
if let Some(t) = p.get("title").and_then(Value::as_str) {
|
||
form.title = t.chars().take(500).collect();
|
||
}
|
||
if let Some(t) = p.get("observed").and_then(Value::as_str) {
|
||
form.observed = t.chars().take(20_000).collect();
|
||
}
|
||
if let Some(b) = p.get("share_session").and_then(Value::as_bool) {
|
||
form.share_session = b;
|
||
}
|
||
if let Some(b) = p.get("include_model").and_then(Value::as_bool) {
|
||
form.include_model = b;
|
||
}
|
||
self.include_model_if_asked(&mut form);
|
||
let draft = form.draft().to_json();
|
||
self.report = Some(form);
|
||
Ok(draft)
|
||
}
|
||
|
||
/// Read the model once it is first included.
|
||
fn include_model_if_asked(&mut self, form: &mut ReportForm) {
|
||
if form.include_model && !form.model_read {
|
||
form.model_read = true;
|
||
match read_input(self, form) {
|
||
Ok(input) => form.input = input,
|
||
Err(e) => form.problem = Some(e),
|
||
}
|
||
}
|
||
}
|
||
|
||
// OneBSpace (OneCAD Simulation): the report never leaves the workspace — no GitHub issue
|
||
// form is opened (upstream opened the draft's ISSUE_URL). The draft's text goes to the
|
||
// clipboard and any attachments stay in the local bug-reports folder to attach by hand.
|
||
fn copy_report(&mut self, ctx: &egui::Context, draft: &Draft) {
|
||
let mut text = draft.title.clone();
|
||
for (k, v) in &draft.fields {
|
||
if !v.is_empty() {
|
||
text.push_str(&format!("\n\n{k}:\n{v}"));
|
||
}
|
||
}
|
||
if let Some(session) = &draft.clipboard {
|
||
text.push_str(&format!("\n\nsession:\n{session}"));
|
||
}
|
||
ctx.copy_text(text);
|
||
let note = if draft.attachments.is_empty() {
|
||
"Report copied to the clipboard — paste it into your own issue tracker.".to_string()
|
||
} else {
|
||
match bug_report::save_attachments(draft) {
|
||
Ok(dir) => format!("Report copied to the clipboard: attach the files in {}.", dir.display()),
|
||
Err(e) => format!("Report copied to the clipboard (the files to attach could not be saved: {e})."),
|
||
}
|
||
};
|
||
self.set_status(note, false);
|
||
self.prompt = None;
|
||
self.report = None;
|
||
}
|
||
}
|
||
|
||
/// 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)) }
|
||
}
|
||
|
||
fn kind_label(kind: &str) -> &'static str {
|
||
// OneBSpace (OneCAD Simulation): the fork's name in the crash headings.
|
||
match kind {
|
||
"unclean_exit" => "OneCAD Simulation closed unexpectedly",
|
||
"command_panic" => "A command hit an internal error",
|
||
_ => "OneCAD Simulation hit an internal error",
|
||
}
|
||
}
|
||
|
||
/// The crash question and the Report a Bug form, when open.
|
||
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
|
||
let t = crate::theme::Tokens::get(app.ui.dark);
|
||
match app.prompt.clone() {
|
||
Some(Prompt::Crashed(reports)) => {
|
||
let Some(first) = reports.first() else {
|
||
app.prompt = None;
|
||
return;
|
||
};
|
||
let (mut report, mut close) = (false, false);
|
||
crate::frame::window(ctx, kind_label(&first.kind), crate::frame::Width::Normal).id(egui::Id::new("fc_crashed")).show(ctx, |ui| {
|
||
ui.label(match first.kind.as_str() {
|
||
// OneBSpace (OneCAD Simulation): the fork's name in the crash question.
|
||
"unclean_exit" => "OneCAD Simulation closed unexpectedly the last time it ran.",
|
||
_ => "OneCAD Simulation hit an internal error the last time it ran.",
|
||
});
|
||
ui.label(RichText::new(&first.message).color(t.text_dim));
|
||
if reports.len() > 1 {
|
||
ui.label(RichText::new(format!("And {} more since.", reports.len() - 1)).color(t.text_dim));
|
||
}
|
||
ui.add_space(4.0);
|
||
ui.label(
|
||
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub issue
|
||
// is opened; the user copies the report into their own tracker.
|
||
RichText::new(
|
||
"A crash report was saved on this computer. Reporting it drafts a report that stays in the workspace: copy it into your own issue tracker; nothing is sent anywhere.",
|
||
)
|
||
.color(t.text_dim),
|
||
);
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
report = ui.button("Report It…").clicked();
|
||
close = ui.button("Not Now").clicked();
|
||
});
|
||
});
|
||
if report {
|
||
app.report_bug(Some(first.id.clone()));
|
||
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
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(
|
||
// OneBSpace (OneCAD Simulation): reporting drafts locally — no GitHub
|
||
// issue is opened; the report is copied out by hand.
|
||
RichText::new("Saved on this computer, newest first. Reporting one drafts a report that stays in the workspace: copy it into your own issue tracker.")
|
||
.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;
|
||
return;
|
||
};
|
||
let (mut copy, mut close) = (false, false);
|
||
let commands = form.input.commands.len();
|
||
crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
|
||
ui.label(
|
||
// OneBSpace (OneCAD Simulation): the report stays in the workspace — it is
|
||
// copied to the clipboard, never opened as a GitHub issue in the browser.
|
||
RichText::new("Your report stays in the workspace: copy it and paste it into your own issue tracker. Nothing is sent anywhere.")
|
||
.color(t.text_dim),
|
||
);
|
||
if let Some(p) = &form.problem {
|
||
ui.label(RichText::new(p).color(t.error));
|
||
}
|
||
ui.add_space(4.0);
|
||
ui.label("Title");
|
||
let hint = form.draft().title.clone();
|
||
ui.add(egui::TextEdit::singleline(&mut form.title).hint_text(hint).desired_width(f32::INFINITY));
|
||
ui.label("What happened");
|
||
ui.add(
|
||
egui::TextEdit::multiline(&mut form.observed)
|
||
.hint_text("What you did, and what went wrong")
|
||
.desired_rows(3)
|
||
.desired_width(f32::INFINITY),
|
||
);
|
||
ui.add_space(4.0);
|
||
ui.checkbox(&mut form.share_session, "Share my session")
|
||
.on_hover_text("Attach the commands that built the model, to replay it. They can name your files.");
|
||
ui.label(
|
||
RichText::new(format!("The {commands} command(s) that built the model, the version and the OS; they can name your files."))
|
||
.color(t.text_dim)
|
||
.size(11.0),
|
||
);
|
||
ui.add_enabled_ui(form.share_session, |ui| {
|
||
ui.checkbox(&mut form.include_model, "Include the model file")
|
||
.on_hover_text("Attach the model too (you attach the file yourself).");
|
||
});
|
||
if !form.share_session {
|
||
form.include_model = false;
|
||
}
|
||
app.include_model_if_asked(&mut form);
|
||
let draft = form.draft().clone();
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
// OneBSpace (OneCAD Simulation): "Copy report" instead of "Open GitHub Issue".
|
||
copy = ui.button("Copy report").clicked();
|
||
close = ui.button("Cancel").clicked();
|
||
});
|
||
ui.add_space(4.0);
|
||
egui::CollapsingHeader::new("What is in the report").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
|
||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
|
||
for (k, v) in &draft.fields {
|
||
if !v.is_empty() {
|
||
ui.label(RichText::new(format!("{k}:")).strong().size(11.0));
|
||
ui.label(RichText::new(v).monospace().size(11.0));
|
||
}
|
||
}
|
||
for a in &draft.attachments {
|
||
ui.label(RichText::new(format!("Attach {} ({}): {}", a.name, size(a.bytes.len()), a.what)).size(11.0).color(t.accent));
|
||
}
|
||
if draft.clipboard.is_some() {
|
||
// OneBSpace (OneCAD Simulation): the session is copied with the report.
|
||
ui.label(RichText::new("The session is copied with the report.").size(11.0).color(t.text_dim));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
let draft = form.draft().clone();
|
||
app.report = Some(form);
|
||
// OneBSpace (OneCAD Simulation): the copied report never touches the network.
|
||
if copy {
|
||
app.copy_report(ctx, &draft);
|
||
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
app.prompt = None;
|
||
app.report = None;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use fieldcraft_engine::Session;
|
||
|
||
#[test]
|
||
fn report_a_bug_shows_the_last_error_and_shares_only_when_ticked() {
|
||
let mut app = ModelApp::new(Session::default());
|
||
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||
let _ = app.run("mesh.node.remove", json!({"label": 99}));
|
||
let error = app.last_error.clone().expect("the failure is the last error");
|
||
app.report_bug(None);
|
||
assert_eq!(app.prompt, Some(Prompt::ReportBug));
|
||
let form = app.report.as_mut().unwrap();
|
||
assert_eq!(form.observed, error);
|
||
let d = form.draft().clone();
|
||
assert!(d.attachments.is_empty() && !bug_report_text(&d).contains("mesh.node.add"), "no session unless shared");
|
||
let d = app.set_report(&json!({"share_session": true})).unwrap();
|
||
assert!(d["url"].as_str().unwrap().contains("mesh.node.add"), "{d}");
|
||
let d = app.set_report(&json!({"include_model": true})).unwrap();
|
||
assert_eq!(d["attachments"][0]["name"], "model.fieldcraft", "{d}");
|
||
// The model goes only with the session.
|
||
let d = app.set_report(&json!({"share_session": false})).unwrap();
|
||
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")
|
||
}
|
||
|
||
#[test]
|
||
fn a_crash_at_startup_asks_and_report_it_opens_the_form() {
|
||
let mut app = ModelApp::new(Session::default());
|
||
let quiet = Summary {
|
||
id: "20261014-101500-0000000000000001".into(),
|
||
kind: "recovered_panic".into(),
|
||
time: String::new(),
|
||
message: String::new(),
|
||
command: None,
|
||
count: 1,
|
||
version: String::new(),
|
||
path: String::new(),
|
||
replayable: false,
|
||
};
|
||
app.announce_crashes(std::slice::from_ref(&quiet));
|
||
assert_eq!(app.prompt, None, "recovered panics do not interrupt");
|
||
let crashed = Summary { kind: "unclean_exit".into(), message: "FieldCraft ended without a clean exit".into(), ..quiet };
|
||
app.announce_crashes(std::slice::from_ref(&crashed));
|
||
assert!(matches!(&app.prompt, Some(Prompt::Crashed(r)) if r.len() == 1));
|
||
app.report_bug(Some(crashed.id.clone()));
|
||
let form = app.report.as_ref().unwrap();
|
||
assert_eq!(form.crash.as_deref(), Some(crashed.id.as_str()));
|
||
// The report is not in this test's crash folder: the form says so and still drafts.
|
||
assert!(form.problem.as_deref().is_some_and(|p| p.contains("no crash report")), "{:?}", form.problem);
|
||
}
|
||
}
|