Skip to main content

brep_app/panels/
bug_report.rs

1//! In-app **Submit Bug** report.
2//!
3//! A toolbar button opens this flow. It captures, WITHOUT the report dialog in
4//! the shot:
5//!   * a **screenshot** of the whole application (egui UI + the 3D model) as it
6//!     looked the instant the button was pressed,
7//!   * the current **model** (the `.nbrep` recipe), and
8//!   * a user **description** (+ an optional email), and
9//!   * the session's **diagnostics** — which renderer is actually in use and
10//!     what it is running on, appended to the description (see
11//!     [`compose_description`] for why they travel inside that field),
12//! then POSTs them to the public reports endpoint (`v2.brep.io/api/report`).
13//! ONE code path runs on both native and wasm.
14//!
15//! ## Screenshot-before-dialog
16//! egui/eframe captures a screenshot of the FRAME (the composited surface, so it
17//! includes the 3D viewport, which is an `egui_wgpu` paint callback into egui's
18//! frame — see [`crate::viewport`]). We must not let the dialog appear in that
19//! frame, so the flow is a small state machine:
20//!   1. Button click → snapshot the model, send `ViewportCommand::Screenshot`,
21//!      enter [`Phase::Capturing`]. The dialog is NOT drawn while capturing.
22//!   2. A later frame delivers `Event::Screenshot`; we encode it to PNG, build a
23//!      preview thumbnail, and enter [`Phase::Editing`] — only NOW is the dialog
24//!      drawn, so the captured frame(s) never contain it.
25//!   3. Submit builds a small multipart body by hand (no extra deps) and fires
26//!      it through `ehttp`; the reply marshals back over an mpsc channel +
27//!      `request_repaint`, exactly like [`crate::panels::step_parts`].
28
29use crate::automation::hit_keys::HitKeyDoc;
30use crate::diagnostics::Diagnostics;
31use std::sync::mpsc::Receiver;
32
33use brep_render::engine_state::EngineState;
34use crate::icon_text::IconTextUi as _;
35use eframe::egui;
36
37/// The public reports endpoint the button posts to (fronted
38/// by v2.brep.io). Accepts the multipart fields
39/// `description`,`email`,`model`,`screenshot`.
40const REPORT_URL: &str = "https://v2.brep.io/api/report";
41
42/// The feature name [`crate::offsite`]'s refusal sentence starts with.
43const FEATURE: &str = "Bug report";
44
45/// The server's cap on the `description` field, in CHARACTERS
46/// (`description.chars().take(MAX_DESC)` in the endpoint's own
47/// `routes/reports.rs`). It truncates the TAIL, and the diagnostics block is at
48/// the tail — so [`compose_description`] clamps the user's own text to leave
49/// room rather than letting a very long description silently cut the
50/// diagnostics off.
51const MAX_DESC: usize = 20_000;
52
53/// Frames to wait for the screenshot event before giving up and opening the
54/// dialog anyway (so a device that never delivers the capture can't hang the
55/// flow). ~1s at 60fps; the persistent offscreen 3D means the capture normally
56/// lands within a few frames.
57const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
58
59/// The dialog's width in points, on a window wide enough for it.
60const DIALOG_WIDTH: f32 = 560.0;
61
62/// How many rows of text the description shows before it scrolls instead of
63/// growing.
64const DESCRIPTION_ROWS: usize = 12;
65
66/// The description field's own text margin (egui's default, named so the
67/// field's cap can count it).
68const DESCRIPTION_MARGIN: egui::Margin = egui::Margin::symmetric(4, 2);
69
70/// The height of the dialog's scrolling body on a window with room for it. The
71/// body is exactly this tall — never taller, never shorter — so the dialog is
72/// ONE size for as long as it is open, whatever is typed into it; on a window
73/// too short for it, the body takes what the window has left instead.
74const BODY_HEIGHT: f32 = 460.0;
75
76/// The gap above the button row, and between it and the status line.
77const FOOTER_GAP: f32 = 10.0;
78const STATUS_GAP: f32 = 6.0;
79
80/// Whether the description's Cut / Copy / Paste menu can reach the clipboard.
81/// Native eframe turns those requests into the same input as the keyboard
82/// shortcuts; the web runner does not handle them at all, and a browser only
83/// hands a page the clipboard inside its own copy/paste events — so there the
84/// menu says which keys to press instead.
85const MENU_REACHES_CLIPBOARD: bool = cfg!(not(target_arch = "wasm32"));
86
87/// Where the flow is between "button pressed" and "dialog closed".
88#[derive(Default, PartialEq)]
89enum Phase {
90    /// Nothing in progress.
91    #[default]
92    Idle,
93    /// Screenshot requested; dialog intentionally hidden so it isn't captured.
94    Capturing { frames: u32 },
95    /// Screenshot in hand; dialog open, collecting description + email.
96    Editing,
97    /// POST in flight.
98    Sending,
99}
100
101pub struct BugReportPanel {
102    phase: Phase,
103    /// The problem description (required to submit).
104    description: String,
105    /// Optional reporter email.
106    email: String,
107    /// A short status / error line under the buttons.
108    status: String,
109    /// PNG bytes of the pre-dialog screenshot (UI + 3D), if captured.
110    screenshot_png: Option<Vec<u8>>,
111    /// A preview texture of the screenshot shown in the dialog.
112    thumb: Option<egui::TextureHandle>,
113    /// The model (`.nbrep`) snapshotted at button-press time.
114    model_json: String,
115    /// The session diagnostics block, taken from the app's ONE
116    /// [`Diagnostics`] at button-press time — the same text the Info window
117    /// shows, so a report can never describe a different machine from the one
118    /// the user was reading about.
119    diagnostics: String,
120    /// The in-flight POST reply channel (drained each frame).
121    response_rx: Option<Receiver<Result<(), String>>>,
122    /// Per-frame widget rects for the headed verifier (wasm only).
123    hits: std::collections::HashMap<String, egui::Rect>,
124    /// How the description's scroll area stood on the last drawn frame, for the
125    /// verifier: whether a long report overflows the field and where it is
126    /// scrolled to.
127    description_scroll: DescriptionScroll,
128}
129
130/// The description field's scroll extent, in points.
131#[derive(Clone, Copy, Default, Debug, PartialEq)]
132struct DescriptionScroll {
133    /// The height of the text.
134    content: f32,
135    /// The height of the field that shows it.
136    viewport: f32,
137    /// How far down the text is scrolled.
138    offset: f32,
139}
140
141impl BugReportPanel {
142    pub fn new() -> Self {
143        Self {
144            phase: Phase::Idle,
145            description: String::new(),
146            email: String::new(),
147            status: String::new(),
148            screenshot_png: None,
149            thumb: None,
150            model_json: String::new(),
151            diagnostics: String::new(),
152            response_rx: None,
153            hits: std::collections::HashMap::new(),
154            description_scroll: DescriptionScroll::default(),
155        }
156    }
157
158    /// Toolbar entry point: snapshot the model + the session diagnostics,
159    /// request a screenshot of THIS frame (before the dialog exists), and begin
160    /// capturing. Ignored if a report flow is already in progress.
161    ///
162    /// `diagnostics` is the app's ONE instance — the report renders it here
163    /// rather than collecting anything of its own, which is what keeps the
164    /// submitted text and the Info window's rows the same rows.
165    pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
166        if self.phase != Phase::Idle {
167            return;
168        }
169        self.description.clear();
170        self.email.clear();
171        self.status.clear();
172        self.screenshot_png = None;
173        self.thumb = None;
174        self.response_rx = None;
175        // The model can't change while the modal is open, but snapshot it now so
176        // the report reflects exactly the state the user was looking at.
177        self.model_json = state.history_request_json();
178        self.diagnostics = diagnostics.report_text();
179        ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
180        self.phase = Phase::Capturing { frames: 0 };
181        ctx.request_repaint();
182    }
183
184    /// Draw + drive the flow. Called once per frame at ctx level (like the file
185    /// dialog). Idempotent while [`Phase::Idle`].
186    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
187        self.hits.clear();
188
189        self.poll_capture(ctx);
190        self.drain_response(state);
191
192        if !matches!(self.phase, Phase::Editing | Phase::Sending) {
193            return;
194        }
195
196        let sending = self.phase == Phase::Sending;
197        let mut submit = false;
198        let mut cancel = false;
199        let modal = egui::Modal::new(egui::Id::new("brep-bug-report")).show(ctx, |ui| {
200            let window = ctx.content_rect();
201            ui.set_width(DIALOG_WIDTH.min((window.width() - 40.0).max(240.0)));
202            // Scroll bars that SHOW whenever there is more to scroll. egui's
203            // default floating bars stay invisible until the pointer is over the
204            // field, so a report longer than the field read as text that had run
205            // out of room, with nothing to say there was more.
206            ui.spacing_mut().scroll = egui::style::ScrollStyle::thin();
207            // `icon_label`, not `heading`: U+1F41E is a catalogued COLOUR icon, so
208            // this draws the real artwork inline with the title instead of the
209            // font's monochrome outline.
210            ui.icon_label(
211                egui::RichText::new("\u{1F41E}  Submit a bug report").heading(),
212            );
213            ui.add_space(4.0);
214            ui.label(
215                "Describe what went wrong. Your current model and a screenshot of \
216                 the app (UI + 3D view) are attached automatically.",
217            );
218            ui.add_space(2.0);
219            ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
220            ui.add_space(8.0);
221
222            // Everything between the heading and the buttons is ONE scrolling
223            // body, capped to what the window has left once the heading above
224            // and the footer below are counted. However long the description,
225            // and with the screenshot and the diagnostics expanded, the dialog
226            // stays on screen and Submit stays in it — a dialog that grew with
227            // its contents put the button below the bottom of the window.
228            //
229            // The heading is measured from `min_rect`, which does not depend on
230            // where the centred modal lands, so there is no size/position
231            // feedback. A modal's ui is only as tall as the modal was LAST frame,
232            // so the body gets a child ui whose max rect IS the budget (the
233            // same construction as the command palette's list).
234            //
235            // The body is a FIXED height, not a cap. A capped body still grew
236            // with its text up to the cap, and a modal is re-centred from its
237            // PREVIOUS frame's size: every frame the text grew a row, the dialog
238            // got taller downward before it was moved up, so while the user typed
239            // its bottom edge ran off the window (889.5 on an 800-tall window,
240            // 250 characters in). A body that is one height from the first frame
241            // gives the modal one size, so there is nothing to re-centre and
242            // nothing to overflow; the text scrolls inside it instead.
243            let chrome = ui.min_rect().height();
244            let room = (window.height() - chrome - self.footer_height(ui) - 48.0).max(120.0);
245            let body_height = room.min(BODY_HEIGHT);
246            let budget = egui::Rect::from_min_size(
247                ui.cursor().min,
248                egui::vec2(ui.available_width(), body_height),
249            );
250            ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
251                egui::ScrollArea::vertical()
252                    .id_salt("brep-bug-body")
253                    .min_scrolled_height(body_height)
254                    .max_height(body_height)
255                    .auto_shrink([false, false])
256                    .show(ui, |ui| self.body(ui));
257            });
258
259            ui.add_space(FOOTER_GAP);
260            ui.horizontal(|ui| {
261                let can_submit = !self.description.trim().is_empty() && !sending;
262                let label = if sending { "Sending\u{2026}" } else { "Submit report" };
263                let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
264                self.hit("submit", &submit_btn);
265                if submit_btn.clicked() {
266                    submit = true;
267                }
268                let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
269                self.hit("cancel", &cancel_btn);
270                if cancel_btn.clicked() {
271                    cancel = true;
272                }
273            });
274            // The status line's row is always there, empty or not, so a status
275            // appearing ("Submitting…") does not change the dialog's size either.
276            ui.add_space(STATUS_GAP);
277            ui.weak(if self.status.is_empty() { " " } else { self.status.as_str() });
278        });
279
280        if submit {
281            self.send(ctx);
282        } else if cancel || (modal.should_close() && !sending) {
283            self.reset();
284        }
285    }
286
287    /// The height [`Self::show`] draws under the body: the gap, the button row
288    /// and the status line's row, which is always drawn. Worked out from the
289    /// style the footer is drawn with, so the body's budget is right on the
290    /// first frame.
291    fn footer_height(&self, ui: &egui::Ui) -> f32 {
292        let spacing = ui.spacing();
293        let buttons = spacing.interact_size.y.max(
294            ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y,
295        );
296        let status =
297            STATUS_GAP + ui.text_style_height(&egui::TextStyle::Body) + 2.0 * spacing.item_spacing.y;
298        FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
299    }
300
301    /// The dialog's scrolling body: the description, the email, the attached
302    /// screenshot and the diagnostics.
303    fn body(&mut self, ui: &mut egui::Ui) {
304        ui.label("What happened?");
305        self.description_field(ui);
306
307        ui.add_space(6.0);
308        ui.label("Email (optional)");
309        let email = ui.add(
310            egui::TextEdit::singleline(&mut self.email)
311                .desired_width(f32::INFINITY)
312                .hint_text("so we can follow up — optional"),
313        );
314        self.hit("field:email", &email);
315
316        ui.add_space(8.0);
317        if let Some(tex) = &self.thumb {
318            ui.label("Attached screenshot:");
319            ui.add_space(2.0);
320            // Fit the preview to the dialog width, keeping aspect.
321            let size = tex.size_vec2();
322            let scale = ((DIALOG_WIDTH - 40.0).min(ui.available_width()) / size.x).min(1.0);
323            ui.add(
324                egui::Image::new((tex.id(), size * scale))
325                    .corner_radius(4.0)
326                    .bg_fill(egui::Color32::from_gray(20)),
327            );
328        } else {
329            ui.weak("(screenshot unavailable — the model + description will still be sent)");
330        }
331
332        // What the report will carry about this machine, shown before it is
333        // sent rather than attached invisibly. Collapsed: it is for the
334        // triager, and the user has already read it in the Info window if
335        // they wanted to.
336        ui.add_space(8.0);
337        let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
338            .id_salt("brep-bug-diagnostics")
339            .show(ui, |ui| {
340                ui.add(
341                    egui::Label::new(
342                        egui::RichText::new(&self.diagnostics).monospace().small(),
343                    )
344                    .wrap(),
345                );
346            });
347        self.hits.insert("diagnostics".to_string(), diag.header_response.interact_rect);
348    }
349
350    /// The description. It grows with its text up to [`DESCRIPTION_ROWS`] rows
351    /// and then scrolls inside its own bar, so a long report never grows the
352    /// dialog; the field follows the caret as the user types.
353    ///
354    /// A bare scroll area is enough here because the body it sits in was given
355    /// an explicit budget: the room it measures is that budget, not the size
356    /// the modal happened to have last frame.
357    fn description_field(&mut self, ui: &mut egui::Ui) {
358        let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
359            + DESCRIPTION_MARGIN.sum().y;
360        let scroll = egui::ScrollArea::vertical()
361            .id_salt("brep-bug-description")
362            .max_height(cap)
363            .auto_shrink([false, true])
364            .show(ui, |ui| {
365                ui.add(
366                    egui::TextEdit::multiline(&mut self.description)
367                        .margin(DESCRIPTION_MARGIN)
368                        .desired_rows(5)
369                        .desired_width(f32::INFINITY)
370                        .hint_text("Steps, what you expected, and what actually happened"),
371                )
372            });
373        self.description_scroll = DescriptionScroll {
374            content: scroll.content_size.y,
375            viewport: scroll.inner_rect.height(),
376            offset: scroll.state.offset.y,
377        };
378        let field = scroll.inner;
379        self.hit("field:description", &field);
380        field.context_menu(|ui| self.edit_menu(ui, &field));
381    }
382
383    /// The description's right-click menu: Cut, Copy and Paste. An egui text
384    /// field has no menu of its own, and right-click → Paste is how a lot of
385    /// people paste — on a Mac especially, where the reporter looked for it.
386    ///
387    /// Each entry asks the platform for exactly what its keyboard shortcut
388    /// does, and first hands focus back to the field: clicking the menu took
389    /// focus away, and a text field ignores a paste it is not focused for. The
390    /// platform delivers the action as input on the NEXT frame, by which time
391    /// the field has focus again.
392    fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
393        let entries = [
394            ("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
395            ("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
396            ("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
397        ];
398        for (key, label, command) in entries {
399            let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
400            self.hit(key, &item);
401            if item.clicked() {
402                field.request_focus();
403                ui.ctx().send_viewport_cmd(command);
404                ui.close();
405            }
406        }
407        if !MENU_REACHES_CLIPBOARD {
408            ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
409        }
410    }
411
412    /// While capturing, look for the delivered screenshot; time out gracefully.
413    fn poll_capture(&mut self, ctx: &egui::Context) {
414        let frames = match &mut self.phase {
415            Phase::Capturing { frames } => {
416                *frames += 1;
417                *frames
418            }
419            _ => return,
420        };
421        // eframe injects `Event::Screenshot` into the frame's raw input once the
422        // async framebuffer readback completes. Take the newest one.
423        let shot = ctx.input(|i| {
424            i.raw.events.iter().rev().find_map(|e| match e {
425                egui::Event::Screenshot { image, .. } => Some(image.clone()),
426                _ => None,
427            })
428        });
429        if let Some(img) = shot {
430            self.screenshot_png = encode_png(&img);
431            self.thumb = Some(ctx.load_texture(
432                "brep-bug-shot",
433                (*img).clone(),
434                egui::TextureOptions::LINEAR,
435            ));
436            self.phase = Phase::Editing;
437        } else if frames > CAPTURE_TIMEOUT_FRAMES {
438            self.status = "(screenshot unavailable)".into();
439            self.phase = Phase::Editing;
440        } else {
441            ctx.request_repaint();
442        }
443    }
444
445    /// Drain the POST reply: success closes the dialog with a toast; an error
446    /// stays open so the user can retry.
447    fn drain_response(&mut self, state: &mut EngineState) {
448        let Some(rx) = &self.response_rx else { return };
449        let Ok(result) = rx.try_recv() else { return };
450        self.response_rx = None;
451        match result {
452            Ok(()) => {
453                state.push_notice("Bug report submitted — thank you!".to_string());
454                self.reset();
455            }
456            Err(e) => {
457                self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
458                self.phase = Phase::Editing;
459            }
460        }
461    }
462
463    /// Build the multipart body and fire the POST (native + wasm via ehttp).
464    fn send(&mut self, ctx: &egui::Context) {
465        // On a PLM-hosted app that does not allow the report site, say so
466        // instead of letting the browser refuse the request (crate::offsite).
467        if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
468            self.status = sentence;
469            return;
470        }
471        self.status = "Submitting\u{2026}".into();
472        self.phase = Phase::Sending;
473
474        let (content_type, body) = build_multipart(
475            &compose_description(&self.description, &self.diagnostics),
476            &self.email,
477            &self.model_json,
478            self.screenshot_png.as_deref(),
479        );
480        let mut req = ehttp::Request::post(REPORT_URL, body);
481        // `Request::post` sets text/plain; replace it with our multipart type.
482        req.headers
483            .headers
484            .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
485        req.headers.headers.push(("Content-Type".to_string(), content_type));
486
487        let (tx, rx) = std::sync::mpsc::channel();
488        let ctx = ctx.clone();
489        ehttp::fetch(req, move |result| {
490            let out = match result {
491                Ok(resp) if resp.ok => Ok(()),
492                Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
493                Err(err) => Err(err),
494            };
495            let _ = tx.send(out);
496            ctx.request_repaint();
497        });
498        self.response_rx = Some(rx);
499    }
500
501    /// Back to idle, dropping the screenshot + preview texture.
502    fn reset(&mut self) {
503        self.phase = Phase::Idle;
504        self.description.clear();
505        self.email.clear();
506        self.status.clear();
507        self.diagnostics.clear();
508        self.screenshot_png = None;
509        self.thumb = None;
510        self.response_rx = None;
511        self.description_scroll = DescriptionScroll::default();
512    }
513
514    /// Record a widget's screen rect for the verifier — its VISIBLE part: the
515    /// body and the description both scroll, and a click at the centre of a
516    /// rect that runs past its scroll area's clip lands on nothing.
517    fn hit(&mut self, key: &str, resp: &egui::Response) {
518        self.hits.insert(key.to_string(), resp.interact_rect);
519    }
520
521    /// Logical state for the headed verifier: which phase + whether a shot was
522    /// captured.
523    pub fn state_json(&self) -> String {
524        let phase = match self.phase {
525            Phase::Idle => "idle",
526            Phase::Capturing { .. } => "capturing",
527            Phase::Editing => "editing",
528            Phase::Sending => "sending",
529        };
530        serde_json::json!({
531            "phase": phase,
532            "hasScreenshot": self.screenshot_png.is_some(),
533            "diagnostics": self.diagnostics,
534            "status": self.status,
535            "descriptionScroll": {
536                "content": self.description_scroll.content,
537                "viewport": self.description_scroll.viewport,
538                "offset": self.description_scroll.offset,
539            },
540        })
541        .to_string()
542    }
543
544    /// The published widget hit-rects (egui points) for the headed verifier.
545    pub fn hits_json(&self) -> String {
546        let map: serde_json::Map<String, serde_json::Value> = self
547            .hits
548            .iter()
549            .map(|(k, r)| {
550                (
551                    k.clone(),
552                    serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
553                )
554            })
555            .collect();
556        serde_json::Value::Object(map).to_string()
557    }
558}
559
560/// Encode an egui `ColorImage` (the screenshot) to PNG bytes via the pure-Rust
561/// `image` crate (wasm-safe). `None` on a zero-size image or encode error.
562fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
563    use image::ImageEncoder;
564    let [w, h] = color.size;
565    if w == 0 || h == 0 {
566        return None;
567    }
568    let mut rgba = Vec::with_capacity(w * h * 4);
569    for px in &color.pixels {
570        // Straight (un-premultiplied) sRGBA, matching a normal PNG.
571        rgba.extend_from_slice(&px.to_srgba_unmultiplied());
572    }
573    let mut png = Vec::new();
574    image::codecs::png::PngEncoder::new(&mut png)
575        .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
576        .ok()?;
577    Some(png)
578}
579
580/// The `description` the report actually submits: what the user typed, then the
581/// diagnostics block.
582///
583/// **Why inside `description` and not its own part.** The endpoint
584/// (`v2.brep.io/api/report`) parses multipart into a fixed four-field record
585/// and its match ends `_ => {}` — an unknown part is accepted and silently
586/// DROPPED, never rejected. A `diagnostics` part would therefore submit cleanly
587/// and arrive nowhere, which is the worst of both: a client that looks like it
588/// reports the renderer and a server that never stores it. `description` is
589/// stored verbatim in `report.json` and is what a triager reads first, so the
590/// block goes there until the server grows a field of its own.
591///
592/// APPENDED, never prepended: the server's push notification and its public
593/// report list both summarise a report by its FIRST LINE, and a list where every
594/// row reads "--- diagnostics ---" tells a reader nothing.
595///
596/// The user's own text is clamped so the block survives the server's
597/// [`MAX_DESC`] tail truncation. A description long enough to hit that cap has
598/// already said what it has to say; the diagnostics are the part that cannot be
599/// re-derived later.
600fn compose_description(description: &str, diagnostics: &str) -> String {
601    if diagnostics.is_empty() {
602        return description.chars().take(MAX_DESC).collect();
603    }
604    const SEPARATOR: &str = "\n\n";
605    // `chars`, matching the server's own `chars().take(MAX_DESC)`.
606    let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
607    let room = MAX_DESC.saturating_sub(block_len);
608    let user: String = description.chars().take(room).collect();
609    format!("{user}{SEPARATOR}{diagnostics}")
610}
611
612/// Hand-build a `multipart/form-data` body (avoids ehttp's `multipart` feature,
613/// which pulls `rand`→`getrandom` and would need the wasm `js` feature). The
614/// boundary carries a distinctive ASCII prefix plus the payload lengths so it
615/// can't collide with the (text) model JSON or the PNG bytes.
616fn build_multipart(
617    description: &str,
618    email: &str,
619    model: &str,
620    screenshot: Option<&[u8]>,
621) -> (String, Vec<u8>) {
622    let boundary = format!(
623        "----BREPBugReport{:x}x{:x}Boundary",
624        model.len(),
625        screenshot.map(|s| s.len()).unwrap_or(0)
626    );
627    let mut body = Vec::new();
628    push_text_field(&mut body, &boundary, "description", description);
629    push_text_field(&mut body, &boundary, "email", email);
630    push_file_field(
631        &mut body,
632        &boundary,
633        "model",
634        "model.nbrep",
635        "application/json",
636        model.as_bytes(),
637    );
638    if let Some(png) = screenshot {
639        push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
640    }
641    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
642    (format!("multipart/form-data; boundary={boundary}"), body)
643}
644
645fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
646    body.extend_from_slice(
647        format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
648            .as_bytes(),
649    );
650    body.extend_from_slice(value.as_bytes());
651    body.extend_from_slice(b"\r\n");
652}
653
654fn push_file_field(
655    body: &mut Vec<u8>,
656    boundary: &str,
657    name: &str,
658    filename: &str,
659    content_type: &str,
660    bytes: &[u8],
661) {
662    body.extend_from_slice(
663        format!(
664            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
665             filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
666        )
667        .as_bytes(),
668    );
669    body.extend_from_slice(bytes);
670    body.extend_from_slice(b"\r\n");
671}
672
673/// The hit keys this panel publishes (see `automation::hit_keys`).
674pub static HIT_KEYS: &[HitKeyDoc] = &[
675    HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
676    HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
677    HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
678    HitKeyDoc { panel: "bug", prefix: "menu:paste", meaning: "paste the clipboard into the description, from its right-click menu (native; disabled in the browser)", command: None },
679    HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
680    HitKeyDoc { panel: "bug", prefix: "diagnostics", meaning: "expand the diagnostics block the report will carry (the `diagnostics` command returns the same rows, but expanding a header is not what it does)", command: None },
681    HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
682    HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
683];
684