diff --git a/crates/ui-egui/src/bug_report.rs b/crates/ui-egui/src/bug_report.rs new file mode 100644 index 00000000..10587e50 --- /dev/null +++ b/crates/ui-egui/src/bug_report.rs @@ -0,0 +1,468 @@ +//! Help ▸ Report a Bug… (issue #1015): a bug report as a text file to attach and a pre-filled GitHub +//! issue. It holds what happened, the System Info rows and the logs. Nothing is sent: the file is +//! written next to the logs and shown in the file manager, the link opens in the browser, and the +//! person submits the issue on GitHub themselves. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The longest pre-filled issue link: GitHub and browsers cut longer ones. +pub const MAX_URL: usize = 8000; +/// Lines of this run's log quoted in the issue itself (the report file has the whole log). +pub const EXCERPT_LINES: usize = 40; +/// The most of one log a report file takes (its end): logs are capped at 16 MB. +const MAX_LOG_BYTES: u64 = 4 << 20; + +/// The choices in the Report a Bug dialog. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ReportOptions { + /// What happened, in the reporter's words. + pub what: String, + /// Also attach the logs of the two runs before this one. + pub previous_logs: bool, + /// Replace the home folder with `~` and photo file names with ``. + pub private: bool, +} + +impl Default for ReportOptions { + fn default() -> Self { + ReportOptions { what: String::new(), previous_logs: false, private: true } + } +} + +/// One log the report can take: its file name, size and last lines (read when the dialog opens). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LogInfo { + pub name: String, + pub path: String, + pub bytes: u64, + /// The last [`EXCERPT_LINES`] lines. + pub tail: String, +} + +/// This run's log (`log_file`) and the two before it (`lightcraft.1.log`, `lightcraft.2.log`), those +/// that exist. None without a log file (the web, `--memory`). +pub fn logs(log_file: Option<&str>) -> Vec { + let Some(current) = log_file.map(std::path::Path::new) else { return Vec::new() }; + let dir = current.parent().unwrap_or(std::path::Path::new(".")); + let paths = [current.to_path_buf(), dir.join("lightcraft.1.log"), dir.join("lightcraft.2.log")]; + paths + .iter() + .filter_map(|path| { + let text = read_end(path, MAX_LOG_BYTES)?; + let bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(text.len() as u64); + let name = path.file_name()?.to_string_lossy().to_string(); + Some(LogInfo { name, path: path.to_string_lossy().to_string(), bytes, tail: last_lines(&text, EXCERPT_LINES).to_string() }) + }) + .collect() +} + +/// The last `max` bytes of a file as text (from the first whole line in them); None when it can't be read. +fn read_end(path: &std::path::Path, max: u64) -> Option { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).ok()?; + let len = file.metadata().ok()?.len(); + let start = len.saturating_sub(max); + file.seek(SeekFrom::Start(start)).ok()?; + let mut bytes = Vec::new(); + file.take(max).read_to_end(&mut bytes).ok()?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + if start == 0 { + return Some(text); + } + // (cut in the middle of a line: start at the next one) + Some(text.split_once('\n').map_or(text.clone(), |(_, rest)| rest.to_string())) +} + +/// The last `n` lines of `text`. +fn last_lines(text: &str, n: usize) -> &str { + let text = text.trim_end_matches('\n'); + let mut start = text.len(); + for _ in 0..n { + match text.get(..start).and_then(|t| t.rfind('\n')) { + Some(i) => start = i, + None => return text, + } + } + text.get(start + 1..).unwrap_or(text) +} + +/// The home folder, for [`redact`]. +pub fn home_dir() -> Option { + ["HOME", "USERPROFILE"].iter().find_map(|v| std::env::var(v).ok()).filter(|h| h.trim_end_matches(['/', '\\']).len() > 1) +} + +/// `text` with the home folder replaced by `~` and the name of every photo file (or its sidecar) by +/// ``, its extension kept: `/Users/ann/Pictures/Ann birthday.CR2` → `~/Pictures/.CR2`. +pub fn redact(text: &str, home: Option<&str>) -> String { + let mut out = text.to_string(); + if let Some(home) = home.map(|h| h.trim_end_matches(['/', '\\'])).filter(|h| h.len() > 1) { + out = out.replace(home, "~"); + let slashed = home.replace('\\', "/"); + if slashed != home { + out = out.replace(&slashed, "~"); + } + } + hide_photo_names(&out) +} + +/// Characters that end a file name on the left besides a path separator and a space. +fn separates(c: char) -> bool { + matches!(c, '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';' | '|' | '=' | '\n' | '\t') +} + +fn is_photo_extension(ext: &str) -> bool { + let ext = ext.to_ascii_lowercase(); + ext == "xmp" || lightcraft_engine::import::EXTENSIONS.contains(&ext.as_str()) +} + +/// Every `name.ext` with a photo (or sidecar) extension becomes `.ext`. After a path separator +/// the name runs back to it, spaces and all (`Pictures/Ann birthday.CR2`); elsewhere it starts after +/// the last space (`sidecar IMG_0001.xmp`). +fn hide_photo_names(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + // where the name before a '.' would start: after the last path separator while no other break + // came after it, else after the last space or break; never before the end of the last name hidden + let (mut after_path, mut after_space, mut hidden_end, mut copied) = (None::, 0usize, 0usize, 0usize); + let chars: Vec<(usize, char)> = text.char_indices().collect(); + for (i, &(at, c)) in chars.iter().enumerate() { + let next = at + c.len_utf8(); + if c == '/' || c == '\\' { + after_path = Some(next); + after_space = next; + } else if separates(c) { + after_path = None; + after_space = next; + } else if c.is_whitespace() { + after_space = next; + } else if c == '.' { + let ext_len = chars.get(i + 1..).unwrap_or(&[]).iter().take_while(|(_, e)| e.is_ascii_alphanumeric()).count(); + let end = chars.get(i + 1 + ext_len).map_or(text.len(), |(b, _)| *b); + let ext = text.get(next..end).unwrap_or(""); + let start = after_path.unwrap_or(after_space).max(hidden_end).max(copied); + let name = text.get(start..at).unwrap_or(""); + if !ext.is_empty() && is_photo_extension(ext) && !name.trim().is_empty() { + out.push_str(text.get(copied..start).unwrap_or("")); + out.push_str(""); + copied = at; + hidden_end = end; + } + } + } + out.push_str(text.get(copied..).unwrap_or("")); + out +} + +/// `text` percent-encoded for a URL query value (RFC 3986 unreserved characters kept). +fn encode(text: &str) -> String { + let mut out = String::with_capacity(text.len() * 3); + for b in text.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// The `key: value` lines of the system rows. +fn system_text(rows: &[(String, String)]) -> String { + rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect() +} + +/// The issue's title: "Bug: " and the first line of what happened, shortened. +fn title(what: &str) -> String { + let first = what.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); + if first.is_empty() { + return "Bug report".into(); + } + let short: String = first.chars().take(80).collect(); + format!("Bug: {short}{}", if first.chars().count() > 80 { "…" } else { "" }) +} + +/// A pre-filled GitHub issue: its title, its text (what happened, the system rows and the end of +/// this run's log) and the link that opens it, at most [`MAX_URL`] long: the log loses lines from +/// its start, then what happened is shortened, until it fits. `attached`: a report file goes with it. +#[derive(Clone, Debug, PartialEq)] +pub struct Issue { + pub title: String, + pub body: String, + pub url: String, +} + +pub fn issue(rows: &[(String, String)], opts: &ReportOptions, log: Option<&LogInfo>, attached: bool) -> Issue { + let home = home_dir(); + let private = |t: &str| if opts.private { redact(t, home.as_deref()) } else { t.to_string() }; + issue_with(rows, opts, log, attached, &private) +} + +fn issue_with(rows: &[(String, String)], opts: &ReportOptions, log: Option<&LogInfo>, attached: bool, private: &dyn Fn(&str) -> String) -> Issue { + let system = private(&system_text(rows)); + let log_lines: Vec = log.map(|l| private(&l.tail).lines().map(str::to_string).collect()).unwrap_or_default(); + let what = private(opts.what.trim()); + let title = title(&what); + let body_of = |what: &str, lines: &[String]| { + let mut b = + format!("### What happened\n\n{}\n\n### System\n\n```\n{system}```\n", if what.is_empty() { "(describe what happened)" } else { what }); + if let (Some(log), false) = (log, lines.is_empty()) { + b.push_str(&format!("\n### End of {}\n\n```\n{}\n```\n", log.name, lines.join("\n"))); + } + if attached { + b.push_str("\nThe full logs are in the attached `lightcraft-report-….txt`.\n"); + } + b + }; + let url_of = |title: &str, body: &str| format!("{}?title={}&body={}", crate::links::FEEDBACK, encode(title), encode(body)); + let mut lines = log_lines.as_slice(); + let mut what = what; + loop { + let body = body_of(&what, lines); + let url = url_of(&title, &body); + if url.len() <= MAX_URL { + return Issue { title, body, url }; + } + if let Some(rest) = lines.get(1..) { + lines = rest; + } else if !what.is_empty() { + // a very long description: keep its start (the report file has all of it) + let keep = what.chars().count() * 9 / 10; + what = what.chars().take(keep).collect(); + } else { + let url = url_of(&title, ""); + return Issue { title, url, body: String::new() }; + } + } +} + +/// The report file's text: what happened, the system rows and each log, whole (up to its last 4 MB). +pub fn file_text(rows: &[(String, String)], opts: &ReportOptions, logs: &[(String, String)], when: &str) -> String { + let home = home_dir(); + let private = |t: &str| if opts.private { redact(t, home.as_deref()) } else { t.to_string() }; + let mut out = format!( + "LightCraft bug report, {when}\n\n== What happened ==\n{}\n\n== System ==\n{}", + private(opts.what.trim()), + private(&system_text(rows)) + ); + for (name, text) in logs { + out.push_str(&format!("\n== {name} ==\n{}", private(text))); + if !out.ends_with('\n') { + out.push('\n'); + } + } + out +} + +/// The logs a report takes: this run's, and the two before it when asked for. +pub fn chosen(logs: &[LogInfo], opts: &ReportOptions) -> Vec { + logs.iter().enumerate().filter(|(i, _)| *i == 0 || opts.previous_logs).map(|(_, l)| l.clone()).collect() +} + +/// The System Info rows (Help ▸ System Info…), also the system part of a bug report. +pub fn system_rows(app: &mut LightcraftApp) -> Vec<(String, String)> { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ( + "GPU".to_string(), + gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "none (CPU rendering)".into(), + }), + ), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ( + "Preview size".to_string(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + rows +} + +/// `app.reportBug`: open the dialog, or with `create: true` (agents, tests) write the report and open +/// the issue without it. Params: the [`ReportOptions`] (`what`, `previousLogs`, `private`). +pub fn command(app: &mut LightcraftApp, p: &Value) -> Result { + let opts: ReportOptions = if p.is_object() { serde_json::from_value(p.clone()).map_err(|e| e.to_string())? } else { ReportOptions::default() }; + let rows = system_rows(app); + let logs = logs(app.services.log_file.as_deref()); + if p.get("create").and_then(Value::as_bool).unwrap_or(false) { + return create(app, &opts, &rows, &logs); + } + let r = json!({"logs": logs.iter().map(|l| json!({"name": l.name, "bytes": l.bytes})).collect::>()}); + app.ui.dialog = Some(crate::state::Dialog::ReportBug { opts, rows, logs }); + Ok(r) +} + +/// Write the report file next to the logs (when there are logs), show it in the file manager and +/// open the pre-filled issue in the browser. Returns the file's path, the link and the logs taken. +pub fn create(app: &mut LightcraftApp, opts: &ReportOptions, rows: &[(String, String)], logs: &[LogInfo]) -> Result { + let logs = chosen(logs, opts); + let when = (app.session.clock)(); + let file = app.services.log_file.as_deref().map(std::path::Path::new).and_then(|log| { + let stamp: String = when.chars().filter(|c| c.is_ascii_digit()).take(14).collect(); + Some(log.parent()?.join(format!("lightcraft-report-{stamp}.txt"))) + }); + let mut path = None; + if let Some(file) = &file { + let full: Vec<(String, String)> = + logs.iter().map(|l| (l.name.clone(), read_end(std::path::Path::new(&l.path), MAX_LOG_BYTES).unwrap_or_default())).collect(); + let text = file_text(rows, opts, &full, &when); + let p = file.to_string_lossy().to_string(); + lightcraft_engine::export::write_file(&p, text.as_bytes())?; + if let Some(reveal) = app.services.reveal.as_mut() { + reveal(&p)?; + } + path = Some(p); + } + let issue = issue(rows, opts, logs.first(), path.is_some()); + if let Some(open) = app.services.open_url.as_mut() { + open(&issue.url)?; + } + Ok(json!({"path": path, "url": issue.url, "title": issue.title, "logs": logs.iter().map(|l| &l.name).collect::>()})) +} + +/// The Report a Bug dialog's body: what happened, the two choices, and a preview of the issue and the +/// report file. +pub fn dialog_body(ui: &mut egui::Ui, t: &crate::theme::Tokens, opts: &mut ReportOptions, rows: &[(String, String)], logs: &[LogInfo]) { + use crate::i18n::tr; + ui.set_min_width(520.0); + ui.label( + egui::RichText::new(tr( + "Nothing is sent. Create Report saves a report file to attach, and opens a new GitHub issue filled in with what is shown below, for you to check and submit.", + )) + .color(t.text_dim), + ); + crate::text_field::TextField::multiline("field:reportBugWhat", &mut opts.what) + .hint(tr("What did you do, what did you expect, and what happened instead?")) + .rows(4) + .width(ui.available_width()) + .show(ui); + let r = ui.checkbox(&mut opts.private, tr("Hide my home folder and photo file names")); + crate::widgets::register(ui.ctx(), "check:reportBugPrivate", r.rect); + if logs.len() > 1 { + let r = ui.checkbox(&mut opts.previous_logs, tr("Also attach the logs of the two previous runs")); + crate::widgets::register(ui.ctx(), "check:reportBugPrevious", r.rect); + } + ui.label( + egui::RichText::new(tr("A raw file? Name the camera model, and share the file through a link (Google Drive, Dropbox…).")).color(t.text_dim), + ); + let chosen = chosen(logs, opts); + let issue = issue(rows, opts, chosen.first(), !chosen.is_empty()); + ui.label(egui::RichText::new(tr("The new issue")).color(t.text_label)); + egui::ScrollArea::vertical().id_salt("reportBugPreview").max_height(200.0).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(issue.body.as_str()).monospace().size(11.0).color(t.text_label)).wrap()); + }); + let files = if chosen.is_empty() { + tr("This session keeps no log, so there is no report file: the issue holds the system info only.").to_string() + } else { + let names: Vec = chosen.iter().map(|l| format!("{} ({:.0} KB)", l.name, l.bytes as f64 / 1024.0)).collect(); + format!("{} {}", tr("The report file holds the same, and these logs whole:"), names.join(", ")) + }; + ui.label(egui::RichText::new(files).color(t.text_dim)); + let r = ui.button(tr("Copy to Clipboard")); + crate::widgets::register(ui.ctx(), "button:reportBugCopy", r.rect); + if r.clicked() { + ui.ctx().copy_text(issue.body); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec<(String, String)> { + vec![("Version".into(), "0.6.0".into()), ("Library".into(), "/Users/ann/Pictures/LightCraft Library".into())] + } + + fn log(tail: &str) -> LogInfo { + LogInfo { name: "lightcraft.log".into(), path: String::new(), bytes: tail.len() as u64, tail: tail.into() } + } + + #[test] + fn redact_hides_the_home_folder_and_photo_names() { + let home = Some("/Users/ann"); + let line = "2026-10-11 WARN import: /Users/ann/Pictures/Ann birthday.CR2: no preview; sidecar IMG_0001.xmp kept"; + assert_eq!(redact(line, home), "2026-10-11 WARN import: ~/Pictures/.CR2: no preview; sidecar .xmp kept"); + // quoted, in brackets, other cases and Windows paths + assert_eq!(redact(r#"opened "C:\Users\ann\a b.HEIC" (DSC_1.nef)"#, Some(r"C:\Users\ann")), r#"opened "~\.HEIC" (.nef)"#); + // other files and plain words keep their names + assert_eq!(redact("wrote /tmp/lightcraft.log and settings.json at 1.5 s", home), "wrote /tmp/lightcraft.log and settings.json at 1.5 s"); + assert_eq!(redact("no home given: /Users/ann/x.jpg", None), "no home given: /Users/ann/.jpg"); + // never cuts inside a character + assert_eq!(redact("写真/桜.jpg ✓", None), "写真/.jpg ✓"); + } + + #[test] + fn the_issue_link_stays_short_with_a_long_log() { + let tail = (0..EXCERPT_LINES).map(|i| format!("{i:03} {}", "x".repeat(400))).collect::>().join("\n"); + let opts = ReportOptions { what: "Export hangs".into(), ..Default::default() }; + let issue = issue_with(&rows(), &opts, Some(&log(&tail)), true, &|t: &str| t.to_string()); + assert!(issue.url.len() <= MAX_URL, "{}", issue.url.len()); + assert!(issue.url.starts_with("https://github.com/storytold/lightcraft/issues/new?title=Bug%3A%20Export%20hangs&body="), "{}", issue.url); + // the newest lines are kept, the oldest dropped + assert!(issue.body.contains(&format!("{:03} ", EXCERPT_LINES - 1)) && !issue.body.contains("000 "), "{}", issue.body); + assert!(issue.body.contains("lightcraft-report-")); + // a description too long for any link is shortened too + let opts = ReportOptions { what: "y".repeat(20_000), ..Default::default() }; + let issue = issue_with(&rows(), &opts, Some(&log(&tail)), false, &|t: &str| t.to_string()); + assert!(issue.url.len() <= MAX_URL && issue.body.contains("yyyy"), "{}", issue.url.len()); + } + + #[test] + fn the_issue_holds_what_happened_the_system_and_the_log() { + let opts = ReportOptions { what: "Crop resets\nafter undo".into(), ..Default::default() }; + let issue = issue_with(&rows(), &opts, Some(&log("line one\nline two")), false, &|t: &str| t.to_string()); + assert_eq!(issue.title, "Bug: Crop resets"); + assert!(issue.body.contains("Crop resets\nafter undo") && issue.body.contains("Version: 0.6.0\n") && issue.body.contains("line two")); + // no log (the web, --memory): system info only + let issue = issue_with(&rows(), &ReportOptions::default(), None, false, &|t: &str| t.to_string()); + assert_eq!(issue.title, "Bug report"); + assert!(issue.body.contains("Version: 0.6.0") && !issue.body.contains("### End of")); + } + + #[test] + fn last_lines_and_the_end_of_a_file() { + assert_eq!(last_lines("a\nb\nc\n", 2), "b\nc"); + assert_eq!(last_lines("a\nb", 5), "a\nb"); + assert_eq!(last_lines("", 3), ""); + let dir = std::env::temp_dir().join(format!("lc-bug-report-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("x.log"); + std::fs::write(&p, "first line\nsecond line\nthird\n").unwrap(); + // from the first whole line inside the last bytes + assert_eq!(read_end(&p, 10).unwrap(), "third\n"); + assert_eq!(read_end(&p, 1000).unwrap(), "first line\nsecond line\nthird\n"); + assert!(read_end(&dir.join("missing.log"), 10).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn encode_keeps_only_unreserved_characters() { + assert_eq!(encode("a b/ü&=#"), "a%20b%2F%C3%BC%26%3D%23"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f80c9f37..7686235e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod bug_report; pub mod control; pub mod credits; pub mod date_picker; @@ -112,6 +113,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_report_bug; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_switch_library; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 58bcb390..7c3f86da 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -235,6 +235,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ &[ "app.discord", "app.feedback", + "app.reportBug", "---", "app.website", "app.github", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e1c8f215..f5f2d93a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -207,6 +207,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.help", "LightCraft Help", Some("F1"), "Help"), ("app.discord", "Join the ArtCraft Discord…", None, "Help"), ("app.feedback", "Send Feedback…", None, "Help"), + ("app.reportBug", "Report a Bug…", None, "Help"), ("app.website", "LightCraft Website", None, "Help"), ("app.github", "LightCraft on GitHub", None, "Help"), ("app.artcraft", "ArtCraft Website", None, "Help"), @@ -1257,50 +1258,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "none (CPU rendering)".into(), - }), - ), - ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), - ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), - ( - "Preview size".to_string(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("Photos".to_string(), info["photos"].to_string()), - ("Albums".to_string(), info["albums"].to_string()), - ]; - if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { - rows.push(("Library".into(), dir.to_string())); - } - rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); - rows.push(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } + let rows = crate::bug_report::system_rows(app); let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); if p.get("open").and_then(Value::as_bool).unwrap_or(true) { app.ui.dialog = Some(Dialog::SystemInfo { rows }); } Ok(r) } + "app.reportBug" => crate::bug_report::command(app, p), "app.shortcuts" => { app.ui.dialog = Some(Dialog::Shortcuts); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fcc05c10..bfac6e64 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -238,6 +238,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", Dialog::SystemInfo { .. } => "System Info", + Dialog::ReportBug { .. } => "Report a Bug", Dialog::DenoiseModel { .. } => "AI Denoise Model", Dialog::FaceModel { info, .. } if info["download"].is_string() => "Download Face Model", Dialog::FaceModel { .. } => "Add Face Model", @@ -290,6 +291,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, Dialog::FaceModel { .. } => 460.0, + Dialog::ReportBug { .. } => 560.0, Dialog::LightroomCatalog { .. } => 440.0, _ => 380.0, }) @@ -343,6 +345,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } + Dialog::ReportBug { opts, rows, logs } => crate::bug_report::dialog_body(ui, &t, opts, rows, logs), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), Dialog::SystemInfo { rows } => { @@ -1244,6 +1247,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::RemoveFolder { .. } => "Remove", Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", Dialog::SynchronizeFolder { .. } => "Synchronize", + Dialog::ReportBug { .. } => "Create Report", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", @@ -1661,6 +1665,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::panels::denoise::install(app, info, *accepted), Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), + Dialog::ReportBug { opts, rows, logs } => crate::bug_report::create(app, opts, rows, logs), Dialog::Cull { reject_below, pick_best } => { let mut p = json!({"pickBest": pick_best}); if *reject_below > 0.0 { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7e63ef43..44d74919 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -907,6 +907,13 @@ pub enum Dialog { SystemInfo { rows: Vec<(String, String)>, }, + /// Help ▸ Report a Bug… (#1015): the choices, and the logs found when it opened. + ReportBug { + opts: crate::bug_report::ReportOptions, + /// The System Info rows when it opened: what the preview shows is what the report holds. + rows: Vec<(String, String)>, + logs: Vec, + }, /// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`. AllMetadata { title: String, diff --git a/crates/ui-egui/src/tests_report_bug.rs b/crates/ui-egui/src/tests_report_bug.rs new file mode 100644 index 00000000..a3164d6f --- /dev/null +++ b/crates/ui-egui/src/tests_report_bug.rs @@ -0,0 +1,124 @@ +//! #1015: Help ▸ Report a Bug… writes a report file next to the logs, shows it in the file manager +//! and opens a pre-filled GitHub issue. Nothing is sent; personal paths and photo names are hidden by +//! default. + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; +use serde_json::json; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(60); + +/// A log folder with this run's log and the one before, which name a photo under the home folder. +fn log_dir(tag: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("lc-report-bug-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let home = crate::bug_report::home_dir().unwrap_or_else(|| "/home/someone".into()); + let current = dir.join("lightcraft.log"); + std::fs::write(¤t, format!("10:00 INFO start\n10:01 WARN import: {home}/Pictures/Ann birthday.CR2: no preview\n")).unwrap(); + std::fs::write(dir.join("lightcraft.1.log"), "09:00 ERROR the run before\n").unwrap(); + (dir, current.to_string_lossy().to_string()) +} + +/// The demo app with a log file, recording what it reveals and which links it opens. +fn app(log_file: Option) -> (Headless, Arc>>, Arc>>) { + let (revealed, opened) = (Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new()))); + let (r, o) = (revealed.clone(), opened.clone()); + let services = Services { + png: None, + log_file, + reveal: Some(Box::new(move |p: &str| { + r.lock().unwrap().push(p.to_string()); + Ok(()) + })), + open_url: Some(Box::new(move |u: &str| { + o.lock().unwrap().push(u.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), services), [1300.0, 900.0], 1.0); + h.settle(SETTLE); + (h, revealed, opened) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +#[test] +fn the_report_file_and_the_issue_hold_what_was_chosen() { + let (dir, log) = log_dir("create"); + let (mut h, revealed, opened) = app(Some(log)); + let r = h.request("engine.execute", json!({"command": "app.reportBug", "params": {"create": true, "what": "Export hangs at 90 %"}}), T); + assert_eq!(r["ok"], true, "{r}"); + let path = r["result"]["path"].as_str().expect("a report file").to_string(); + assert!(path.starts_with(&dir.to_string_lossy().to_string()) && path.ends_with(".txt"), "{path}"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("Export hangs at 90 %") && text.contains("Version: ") && text.contains("== lightcraft.log =="), "{text}"); + // this run's log only, with the home folder and the photo's name hidden + assert!(!text.contains("lightcraft.1.log") && !text.contains("the run before"), "{text}"); + assert!(text.contains("~/Pictures/.CR2") && !text.contains("Ann birthday"), "{text}"); + assert_eq!(*revealed.lock().unwrap(), vec![path.clone()]); + let url = opened.lock().unwrap().last().cloned().expect("the issue opened"); + assert!( + url.starts_with("https://github.com/storytold/lightcraft/issues/new?title=Bug%3A%20Export%20hangs") + && url.len() <= crate::bug_report::MAX_URL, + "{url}" + ); + assert!(!url.contains("birthday"), "{url}"); + + // the previous run's log too, names shown when asked + let r = h.request("engine.execute", json!({"command": "app.reportBug", "params": {"create": true, "previousLogs": true, "private": false}}), T); + let text = std::fs::read_to_string(r["result"]["path"].as_str().unwrap()).unwrap(); + assert!(text.contains("== lightcraft.1.log ==") && text.contains("the run before") && text.contains("Ann birthday.CR2"), "{text}"); + assert_eq!(r["result"]["logs"], json!(["lightcraft.log", "lightcraft.1.log"])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_dialog_previews_the_issue_and_creates_the_report() { + let (dir, log) = log_dir("dialog"); + let (mut h, revealed, opened) = app(Some(log)); + let r = h.request("ui.menu.invoke", json!({"id": "app.reportBug"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::ReportBug { .. }))); + for id in + ["field:reportBugWhat", "check:reportBugPrivate", "check:reportBugPrevious", "button:reportBugCopy", "button:dialogCancel", "button:dialogOk"] + { + assert!(has(&h, id), "no {id}"); + } + let text = h.painted_text().join("\n"); + assert!(text.contains("Create Report") && text.contains("### What happened") && text.contains("lightcraft.log"), "{text}"); + // the preview hides what the report hides + assert!(text.contains(".CR2") && !text.contains("Ann birthday"), "{text}"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Create Report closes the dialog"); + assert_eq!(revealed.lock().unwrap().len(), 1); + assert_eq!(opened.lock().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_log_the_issue_holds_the_system_info_only() { + let (mut h, revealed, opened) = app(None); + let r = h.request("engine.execute", json!({"command": "app.reportBug", "params": {"create": true}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(r["result"]["path"].is_null(), "{r}"); + assert!(revealed.lock().unwrap().is_empty()); + let url = opened.lock().unwrap().last().cloned().expect("the issue opened"); + assert!(url.contains("title=Bug%20report") && url.contains("Version%3A%20"), "{url}"); + let r = h.request("ui.menu.invoke", json!({"id": "app.reportBug"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(!has(&h, "check:reportBugPrevious"), "no previous logs to offer"); + assert!(h.painted_text().join("\n").contains("keeps no log")); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e74742bc..d6eea494 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -574,6 +574,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew` | | | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub | +| MENU-HELP-REPORTBUG | Report a bug | P2 | ✅ | `cmd:app.reportBug`, `crates/ui-egui/src/bug_report.rs` | an extra (issue #1015): Help ▸ Report a Bug… previews, then writes `lightcraft-report-