forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
4711f9474a
commit
441f03d093
11 files changed
+231
-7
No files matched your search
@@ -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"],
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
crash_dir_for(s, p, Access::Read)
|
||||
}
|
||||
|
||||
fn crash_dir_for(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
|
||||
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<Value> {
|
||||
crash::read(&dir, &id).map_err(EngineError::Params)
|
||||
}
|
||||
|
||||
fn crash_reports_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = opt::<String>(p, "id")?;
|
||||
let all = opt::<bool>(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<bug_report::Input> {
|
||||
@@ -131,3 +160,39 @@ fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
Reference in new issue
Block a user