App: crash reports and a log file; Help › Report a Bug (#107)

The desktop app installs the crash reporter and a log file at start
(both in a temporary folder in --control and CI runs, #266), journals
the window's commands (background jobs included) and every control
request's method, and ends the journal on a clean exit. A start after a
crash asks: "FieldCraft closed unexpectedly", Report It… or Not Now.

Help › Report a Bug (also Tools › Help and ui.start
diagnostics.bug_report) shows exactly what the GitHub bug form will be
filled with: the version, the OS, the last error, and for a crash where
it happened. Share my session adds the replayable commands, Include the
model file the model. Open GitHub Issue opens the form in the browser
through the system's opener; files to attach are saved to
bug-reports/<time>/ and a long session goes on the clipboard. Nothing is
sent without that click and the user's submit on GitHub.

The control channel answers the new prompts (crashed: report, close;
report_bug: set, close) and ui.inspect shows the draft; it never opens
the browser.

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 00:36:18 -04:00
1 parent cb7e3a2711
commit e06c85eb9e
9 files changed
+503 -8

No files matched your search

+2
View File
@@ -190,6 +190,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
recent_path: None,
// Preferences last for the page's run.
prefs_path: None,
// egui opens links itself in the browser.
open_url: None,
}
}
+64 -5
View File
@@ -13,8 +13,8 @@
mod control_server;
use fieldcraft_engine::Session;
use fieldcraft_engine::autosave::{self, Autosaver};
use fieldcraft_engine::{Session, crash, logfile};
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
const USAGE: &str =
@@ -47,6 +47,8 @@ struct App {
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
/// The first frame was drawn (the crash journal says the app is up).
running: bool,
}
impl App {
@@ -61,6 +63,10 @@ impl App {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.running {
self.running = true;
crash::activity("running");
}
self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
self.autosaved_at = self.app.now;
@@ -76,6 +82,8 @@ impl eframe::App for App {
{
log::warn!("autosave: {e}");
}
log::info!("FieldCraft closed");
crash::end_session();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
@@ -96,9 +104,55 @@ fn services() -> Services {
// Beside the recovery folder in the per-user data folder.
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))),
open_url: Some(Box::new(open_url)),
}
}
/// Open a web link in the default browser, with the system's own opener: `open` (macOS),
/// `xdg-open` (Linux and the BSDs), `explorer` (Windows). The link is one argument, never
/// passed through a shell. Only https links are opened.
fn open_url(url: &str) -> Result<(), String> {
if !url.starts_with("https://") {
return Err(format!("not a web link: {}", url.chars().take(80).collect::<String>()));
}
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let mut child = std::process::Command::new(opener).arg(url).spawn().map_err(|e| format!("{opener}: {e}"))?;
// Reaped off the window thread; its exit status says nothing (`explorer` exits with 1 even
// when it opened the link).
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
/// Crash reports and the log file (docs/crash-reports.md): reports of earlier runs that crashed
/// come back, for the window to offer. Both live in the app's data folder (a temporary one in
/// runs driven by automation); `FIELDCRAFT_CRASH_DIR` moves the crash folder.
fn start_reporting() -> Vec<crash::Summary> {
// First the crash reports: an unclean exit's report takes the dead run's lines from the log.
let crashed = crash::default_dir().map(|dir| crash::install(dir, crash::Build::current())).unwrap_or_default();
match fieldcraft_io::recovery::data_dir().map(|d| logfile::install(&d.join("logs"), log::LevelFilter::Info)) {
Some(Ok(path)) => eprintln!("fieldcraft: log {}", path.display()),
Some(Err(e)) => eprintln!("fieldcraft: no log file: {e}"),
None => {}
}
let b = crash::Build::current();
log::info!(
"FieldCraft {} started ({}), crash reports in {}",
b.version,
b.version_line(),
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
);
crash::activity("startup");
crashed
}
/// Command-line options.
#[derive(Debug, Default, PartialEq)]
struct Args {
@@ -152,9 +206,10 @@ fn main() -> eframe::Result {
};
if args.control.is_some() {
// A run driven by automation keeps its autosaves (and recent files and preferences, which
// live beside them) in a temporary folder, never the user's.
// live beside them), its crash reports and its log in a temporary folder, never the user's.
fieldcraft_io::recovery::use_automation_dir();
}
let crashed = start_reporting();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
@@ -177,12 +232,15 @@ fn main() -> eframe::Result {
"FieldCraft",
options,
Box::new(move |cc| {
let mut app = ModelApp::new(Session::default()).with_services(services());
let mut session = Session::default();
// The window's model: its commands go into the crash journal.
session.crash_journal = crash::enabled();
let mut app = ModelApp::new(session).with_services(services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info();
eprintln!("fieldcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
}
if let Some(port) = args.control {
@@ -202,7 +260,8 @@ fn main() -> eframe::Result {
}
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
});
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 }))
app.announce_crashes(&crashed);
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false }))
}),
)
}
+12
View File
@@ -255,6 +255,18 @@ pub fn draft(input: &Input, version: &str) -> Draft {
Draft { title, fields, url, attachments, clipboard }
}
/// Write a draft's attachments to `bug-reports/<time>/` in the app's data folder (the user
/// attaches them from there); returns the folder.
pub fn save_attachments(draft: &Draft) -> Result<std::path::PathBuf, String> {
let root = fieldcraft_io::recovery::data_dir().ok_or("no data folder on this system")?;
let dir = root.join("bug-reports").join(crate::crash::stamp_now());
fieldcraft_io::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for a in &draft.attachments {
fieldcraft_io::fs::write_atomic(&dir.join(&a.name), &a.bytes, false).map_err(|e| e.to_string())?;
}
Ok(dir)
}
/// The fields a link carries (for the review, when a field had to be cut).
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
let query = url.split_once('?').map_or("", |(_, q)| q);
+1
View File
@@ -240,6 +240,7 @@ impl Session {
opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.file_access = std::mem::take(&mut self.file_access);
opened.crash_journal = self.crash_journal;
opened.clear_history();
opened.mark_opened();
*self = opened;
+7
View File
@@ -336,7 +336,14 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator();
let report = ui
.button("Report a Bug…")
.on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
.clicked();
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
}
if let Some(n) = open {
app.ui.menu = None;
app.open_sample(n);
+20 -1
View File
@@ -164,6 +164,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let mods = || egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
// A crash nothing catches (#248) still says which request came last.
fieldcraft_engine::crash::activity(&format!("control {}", req.method));
match req.method.as_str() {
"engine.execute" => {
let Some(id) = s("command") else { return err("missing `command`") };
@@ -202,6 +204,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
crate::files::Prompt::Design(p) => json!({"design": p}),
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::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}),
}),
"recent": app.recent.paths,
"version": crate::VERSION,
@@ -374,6 +378,21 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::Crashed(r), Some("report")) => {
app.report_bug(r.first().map(|c| c.id.clone()));
ok(json!({"prompt": "report_bug"}))
}
(crate::files::Prompt::Crashed(_), Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
// The form's fields; sending opens the browser, which only a click does.
(crate::files::Prompt::ReportBug, Some("set")) => wrap(app.set_report(p)),
(crate::files::Prompt::ReportBug, Some("close" | "cancel")) => {
app.prompt = None;
app.report = None;
ok(Value::Null)
}
(crate::files::Prompt::Recover, Some("recover")) => match s("id") {
Some(id) => {
app.recover(id);
@@ -382,7 +401,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
None => err("missing `id` (from file.recovery.list)"),
},
(_, a) => err(format!(
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close)"
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close)"
)),
}
}
+20 -2
View File
@@ -33,6 +33,8 @@ pub struct Filter {
pub type PickOpen = Box<dyn Fn(&[Filter]) -> Option<String>>;
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
/// The host opens a web link in the user's browser.
pub type OpenUrl = Box<dyn Fn(&str) -> Result<(), String>>;
/// What the host provides: native file pickers and where the recent-files list is kept. The
/// desktop app installs them; tests answer the pickers with closures.
@@ -44,6 +46,9 @@ pub struct Services {
pub recent_path: Option<PathBuf>,
/// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only.
pub prefs_path: Option<PathBuf>,
/// Opens a link in the browser (Help › Report a Bug); without it, egui's own
/// `open_url` (the browser build opens the link itself).
pub open_url: Option<OpenUrl>,
}
const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] };
@@ -275,6 +280,10 @@ pub enum Prompt {
/// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and
/// the deletion refused or (a datum's partitions) done with it.
Delete(crate::menu::DeleteAsk),
/// The last run crashed (its new crash reports, newest first): report it, or not now.
Crashed(Vec<fieldcraft_engine::crash::Summary>),
/// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]).
ReportBug,
}
/// The answer to [`Prompt::Design`].
@@ -376,7 +385,14 @@ impl ModelApp {
if self.ensure_idle().is_err() {
return false;
}
let base = if fresh { Session::default() } else { self.session.clone() };
// A fresh session is the app's own model too (its commands go into the crash journal).
let base = if fresh {
let mut s = Session::default();
s.crash_journal = self.session.crash_journal;
s
} else {
self.session.clone()
};
let (rx, pending) = spawn(base, command, params);
self.set_status(format!("{title}…"), false);
self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false });
@@ -730,6 +746,7 @@ impl ModelApp {
}
"file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover),
"file.sample" => self.prompt = Some(Prompt::Samples),
"diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None),
"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),
@@ -989,7 +1006,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
}
}
None => {}
// Drawn by `crate::report::show`.
Some(Prompt::Crashed(_) | Prompt::ReportBug) | None => {}
}
}
+9
View File
@@ -40,6 +40,7 @@ pub mod menu;
pub mod navigation;
pub mod palette;
pub mod reply;
pub mod report;
pub mod results;
pub mod results_panel;
pub mod samples;
@@ -390,6 +391,10 @@ pub struct ModelApp {
pub queued: std::collections::VecDeque<String>,
/// The question the window is asking (unsaved changes, recover).
pub prompt: Option<files::Prompt>,
/// Help › Report a Bug's form, while open ([`files::Prompt::ReportBug`]).
pub report: Option<report::ReportForm>,
/// The last error the status bar showed (what Report a Bug starts from).
pub last_error: Option<String>,
/// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer.
pub held: Option<files::Opened>,
/// The window draws its own title bar and caption buttons (Windows, Linux).
@@ -468,6 +473,8 @@ impl ModelApp {
loading: None,
queued: std::collections::VecDeque::new(),
prompt: None,
report: None,
last_error: None,
held: None,
custom_titlebar: false,
integrated_titlebar: false,
@@ -783,6 +790,7 @@ impl ModelApp {
let text = text.into();
if error {
log::warn!("{text}");
self.last_error = Some(text.clone());
}
self.status = Some(Status { text, error });
}
@@ -1442,6 +1450,7 @@ impl ModelApp {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui));
palette::popup(self, ui.ctx());
files::show(self, ui.ctx());
report::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
+368
View File
@@ -0,0 +1,368 @@
//! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! unexpectedly — Report it").
//!
//! The form shows exactly what the GitHub issue form will be filled with
//! ([`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. Nothing leaves the computer
//! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
//! submit it there, or not); files too big for the link are saved to a folder to attach from, and
//! a long session is also put on the clipboard.
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;
}
/// 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),
}
}
}
/// Open the draft in the browser: attachments saved to a folder of their own, the session on
/// the clipboard when it is too long for the link.
fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut saved = None;
if !draft.attachments.is_empty() {
match bug_report::save_attachments(draft) {
Ok(dir) => saved = Some(dir),
Err(e) => {
self.set_status(format!("Report a Bug: could not save the files to attach: {e}"), true);
return;
}
}
}
if let Some(text) = &draft.clipboard {
ctx.copy_text(text.clone());
}
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(&draft.url) {
self.set_status(format!("Report a Bug: could not open the browser: {e}"), true);
return;
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(draft.url.clone())),
}
log::info!("bug report opened in the browser ({} bytes, {} attachment(s))", draft.url.len(), draft.attachments.len());
let text = match saved {
Some(dir) => format!("The issue form opened in your browser: attach the files in {} and submit it there.", dir.display()),
None => "The issue form opened in your browser: review it and submit it there.".to_string(),
};
self.set_status(text, false);
self.prompt = None;
self.report = None;
}
}
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 {
match kind {
"unclean_exit" => "FieldCraft closed unexpectedly",
"command_panic" => "A command hit an internal error",
_ => "FieldCraft 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() {
"unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
_ => "FieldCraft 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(
RichText::new(
"A crash report was saved on this computer. Reporting it opens a GitHub issue you review first; nothing is sent until you submit it.",
)
.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::ReportBug) => {
let Some(mut form) = app.report.take() else {
app.prompt = None;
return;
};
let (mut send, 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(
RichText::new(
"This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
)
.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| {
send = ui.button("Open GitHub Issue").clicked();
close = ui.button("Cancel").clicked();
});
ui.add_space(4.0);
egui::CollapsingHeader::new("What is sent").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() {
ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
}
});
});
});
let draft = form.draft().clone();
app.report = Some(form);
if send {
app.send_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}");
}
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);
}
}