From 441f03d093ae015e0a76049753fdad2230524a39 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:38:29 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20Help=20=E2=80=BA=20Crash=20Reports=20list?= =?UTF-8?q?s=20the=20saved=20reports=20to=20report,=20copy=20or=20delete?= =?UTF-8?q?=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 2 +- crates/engine/src/cmd/diagnostics.rs | 67 +++++++++++++- crates/engine/src/cmd/forms.rs | 5 ++ crates/engine/src/cmd/mod.rs | 1 + crates/ui-egui/src/appbar.rs | 3 + crates/ui-egui/src/control.rs | 15 +++- crates/ui-egui/src/files.rs | 8 +- crates/ui-egui/src/report.rs | 129 +++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/crash-reports.md | 4 + docs/features.md | 2 +- 11 files changed, 231 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 8c5a32e..5a9b9ba 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -615,7 +615,7 @@ pub static CATALOGUE: &[Capability] = &[ Tools, "Help", "Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it", - &["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_test", "app.update.check"], + &["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_reports.delete", "diagnostics.crash_test", "app.update.check"], ), ]; diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs index ac977fe..324c63f 100644 --- a/crates/engine/src/cmd/diagnostics.rs +++ b/crates/engine/src/cmd/diagnostics.rs @@ -30,6 +30,16 @@ pub const CRASH_REPORT: CommandSpec = CommandSpec { run: crash_report, }; +pub const CRASH_REPORTS_DELETE: CommandSpec = CommandSpec { + id: "diagnostics.crash_reports.delete", + label: "Delete Crash Reports", + params: r#"{"id": "20261014-101500-…"} or {"all": true}; "dir"?: "…" — deletes one crash report, or all of them, from this computer: how many were deleted"#, + form: Some(super::forms::CRASH_REPORTS_DELETE), + kind: CommandKind::Session, + requires: &[], + run: crash_reports_delete, +}; + pub const BUG_REPORT: CommandSpec = CommandSpec { id: "diagnostics.bug_report", label: "Report a Bug", @@ -51,8 +61,12 @@ pub const CRASH_TEST: CommandSpec = CommandSpec { }; fn crash_dir(s: &Session, p: &Value) -> Result { + crash_dir_for(s, p, Access::Read) +} + +fn crash_dir_for(s: &Session, p: &Value, access: Access) -> Result { if p.get("dir").is_some() { - return Ok(PathBuf::from(file_param(s, p, "dir", Access::Read)?)); + return Ok(PathBuf::from(file_param(s, p, "dir", access)?)); } crash::default_dir().ok_or_else(|| EngineError::Params("no crash folder: set FIELDCRAFT_CRASH_DIR or give `dir`".into())) } @@ -71,6 +85,21 @@ fn crash_report(s: &mut Session, p: &Value) -> Result { crash::read(&dir, &id).map_err(EngineError::Params) } +fn crash_reports_delete(s: &mut Session, p: &Value) -> Result { + let id = opt::(p, "id")?; + let all = opt::(p, "all")?.unwrap_or(false); + let dir = crash_dir_for(s, p, Access::Write)?; + let deleted = match (id.as_deref(), all) { + (Some(id), false) => match crash::clear(&dir, Some(id)) { + 0 => return Err(EngineError::Params(format!("no crash report `{}`", crash::limited(id, 64)))), + n => n, + }, + (None, true) => crash::clear(&dir, None), + _ => return Err(EngineError::Params("give one of `id` (a crash report) or `all: true`".into())), + }; + Ok(json!({"deleted": deleted})) +} + /// The draft input for `p` from the session: its commands and model (when shared), or a crash /// report's replay and model. pub fn bug_report_input(s: &Session, p: &Value) -> Result { @@ -131,3 +160,39 @@ fn crash_test(_: &mut Session, p: &Value) -> Result { _ => Err(EngineError::invalid("mode", "must be panic, recovered or abort")), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `diagnostics.crash_reports.delete` deletes one report by id, then the rest; an unknown or + /// hostile id and a missing choice are errors that delete nothing. + #[test] + fn crash_reports_are_deleted_one_or_all() { + let dir = std::env::temp_dir().join(format!("fieldcraft-crash-delete-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ids = ["20261014-101500-0000000000000001", "20261014-101600-0000000000000002", "20261014-101700-0000000000000003"]; + for id in ids { + std::fs::create_dir_all(dir.join(id)).unwrap(); + std::fs::write(dir.join(id).join("report.json"), r#"{"kind": "panic", "message": "x"}"#).unwrap(); + } + let mut s = Session::default(); + let d = dir.to_string_lossy().to_string(); + let listed = |s: &mut Session| s.execute("diagnostics.crash_reports", &json!({"dir": d})).unwrap()["reports"].as_array().map_or(0, Vec::len); + assert_eq!(listed(&mut s), 3); + for bad in [ + json!({"dir": d}), + json!({"dir": d, "id": "../x"}), + json!({"dir": d, "id": "20261014-101500-00000000000000ff"}), + json!({"dir": d, "id": ids[0], "all": true}), + ] { + assert!(s.execute("diagnostics.crash_reports.delete", &bad).is_err(), "{bad}"); + } + assert_eq!(listed(&mut s), 3); + assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "id": ids[1]})).unwrap()["deleted"], 1); + assert_eq!(listed(&mut s), 2); + assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "all": true})).unwrap()["deleted"], 2); + assert_eq!(listed(&mut s), 0); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 8e402f4..3f53d16 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1518,6 +1518,11 @@ pub const CRASH_REPORT: &[Field] = &[ text("id", "Report").help("The crash report to show.").req(), path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"), ]; +pub const CRASH_REPORTS_DELETE: &[Field] = &[ + text("id", "Report").help("The crash report to delete.").hint("or All"), + flag("all", "All").help("Delete every crash report instead of one."), + path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"), +]; pub const BUG_REPORT: &[Field] = &[ text("title", "Title").help("The issue's title; one is made up when empty."), text("observed", "What happened").help("What went wrong; the last error when empty."), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7484467..4b351ac 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -165,6 +165,7 @@ pub static ALL: &[CommandSpec] = &[ results::CLEAR, diagnostics::CRASH_REPORTS, diagnostics::CRASH_REPORT, + diagnostics::CRASH_REPORTS_DELETE, diagnostics::BUG_REPORT, diagnostics::CRASH_TEST, update::CHECK, diff --git a/crates/ui-egui/src/appbar.rs b/crates/ui-egui/src/appbar.rs index 544fbee..c575dd8 100644 --- a/crates/ui-egui/src/appbar.rs +++ b/crates/ui-egui/src/appbar.rs @@ -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 { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5909ed8..51fcb27 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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) } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 6be4cd9..83c2eaf 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -402,6 +402,9 @@ pub enum Prompt { Crashed(Vec), /// 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), } /// 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 => {} } } diff --git a/crates/ui-egui/src/report.rs b/crates/ui-egui/src/report.rs index d591bf1..9acbe79 100644 --- a/crates/ui-egui/src/report.rs +++ b/crates/ui-egui/src/report.rs @@ -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 { + 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 = 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 { + 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::>().join("\n") } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a6026df..0d1bcf6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `contact`, `tie`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply | | `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) | -| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close`; for `crashed` (the start after a crash: `{crashed: [reports]}`) `report` (opens Report a Bug for the newest) or `close`; for `report_bug` (`{report_bug: draft}`) `set` (any of `title`, `observed`, `share_session`, `include_model`; replies with the draft) or `close` — the browser opens only from a click (docs/crash-reports.md); for `consent` (the first run's question about anonymous usage) `share` or `decline`; for `settings` (Help › Settings…) `set` (any of `usage_sharing`, `usage_automation`; or `update_check`, `update_skip` (a version, `""` for none)) or `close` (docs/privacy.md, docs/updates.md); for `update` (a downloaded and verified update: `{update: state}`) `restart` (saves or asks about unsaved changes first, then closes the window to install) or `later` (installs when the app quits) | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close`; for `crashed` (the start after a crash: `{crashed: [reports]}`) `report` (opens Report a Bug for the newest) or `close`; for `crash_reports` (Help › Crash Reports…, or `ui.start diagnostics.crash_reports`: `{crash_reports: [reports]}`) `report` (with `id`: opens Report a Bug for it), `delete` (with `id`), `delete_all` or `close`; for `report_bug` (`{report_bug: draft}`) `set` (any of `title`, `observed`, `share_session`, `include_model`; replies with the draft) or `close` — the browser opens only from a click (docs/crash-reports.md); for `consent` (the first run's question about anonymous usage) `share` or `decline`; for `settings` (Help › Settings…) `set` (any of `usage_sharing`, `usage_automation`; or `update_check`, `update_skip` (a version, `""` for none)) or `close` (docs/privacy.md, docs/updates.md); for `update` (a downloaded and verified update: `{update: state}`) `restart` (saves or asks about unsaved changes first, then closes the window to install) or `later` (installs when the app quits) | answers the window's question (`ui.inspect`'s `prompt`) | | `ui.settings` | — | opens Help › Settings… (answered with `ui.prompt`); replies with the privacy state as `ui.inspect`'s `privacy` gives it: `available` (this run may share usage), `usage_sharing` (`null` until asked), `usage_automation`, `recording`, `queued` | | `ui.item` | `kind` (`body`, `surface`, or a definition kind as `ui.select` takes it, `datum`, `partition`), `name`, `action`: `edit`, `rename`, `suppress`/`unsuppress` (a datum or partition), `hide`, `show`, `isolate`, `show_all`, `delete`, or `activate`/`deactivate` with `step` | an item menu's entry, as a right click on the item and a click on the entry: runs the same command (`rename` opens the `model.rename` form filled in; `delete` opens the delete prompt, answered with `ui.prompt`) | | `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `submit?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | diff --git a/docs/crash-reports.md b/docs/crash-reports.md index d1034fa..16d0d84 100644 --- a/docs/crash-reports.md +++ b/docs/crash-reports.md @@ -7,6 +7,10 @@ this computer (anonymous usage, if you agreed to share it, never carries them: s [privacy.md](privacy.md)). The only way they leave it is **Help › Report a Bug** (or **Report It…** after a crash), which opens a GitHub issue form in your browser; you read it there and submit it, or not. +**Help › Crash Reports…** lists every saved report (when, what, how many times), each with +**Report…**, **Copy Path** and **Delete**, and **Delete All** (`diagnostics.crash_reports`, +`diagnostics.crash_reports.delete`). + What a bug report carries: | Always (you can edit it) | Only with **Share my session** | Only with **Include the model file** as well | diff --git a/docs/features.md b/docs/features.md index 9293ae6..7eda4a0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -165,4 +165,4 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | View | Show All — Show every item hidden in the viewport (hide, show and isolate items from the browser) | ready | `view.show_all`, (`view.hide`), (`view.show`), (`view.isolate`) | | History | Undo — Undo the last command that changed the model | ready | `edit.undo` | | History | Redo — Redo the command undone last | ready | `edit.redo` | -| Help | Report a Bug — Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it | ready | `diagnostics.bug_report`, `diagnostics.crash_reports`, `diagnostics.crash_report`, `diagnostics.crash_test`, `app.update.check` | +| Help | Report a Bug — Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it | ready | `diagnostics.bug_report`, `diagnostics.crash_reports`, `diagnostics.crash_report`, `diagnostics.crash_reports.delete`, `diagnostics.crash_test`, `app.update.check` |