# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
bflatastic 2026-10-13 12:28:36 +07:00
commit e0c1c415e5
9 changed files with 618 additions and 38 deletions

View File

@ -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 `<photo>`.
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<LogInfo> {
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<String> {
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<String> {
["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
/// `<photo>`, its extension kept: `/Users/ann/Pictures/Ann birthday.CR2` → `~/Pictures/<photo>.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 `<photo>.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::<usize>, 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("<photo>");
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<String> = 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<LogInfo> {
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<Value, String> {
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::<Vec<_>>()});
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<Value, String> {
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::<Vec<_>>()}))
}
/// 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<String> = 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/<photo>.CR2: no preview; sidecar <photo>.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 "~\<photo>.HEIC" (<photo>.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/<photo>.jpg");
// never cuts inside a character
assert_eq!(redact("写真/桜.jpg ✓", None), "写真/<photo>.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::<Vec<_>>().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");
}
}

View File

@ -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;

View File

@ -235,6 +235,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
&[
"app.discord",
"app.feedback",
"app.reportBug",
"---",
"app.website",
"app.github",

View File

@ -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<Re
Ok(json!({"text": crate::panels::dialogs::WHATS_NEW}))
}
"app.systemInfo" => {
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::<Vec<_>>());
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)

View File

@ -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<serde_jso
Dialog::DenoiseModel { info, accepted } => 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 {

View File

@ -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<crate::bug_report::LogInfo>,
},
/// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`.
AllMetadata {
title: String,

View File

@ -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(&current, 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<String>) -> (Headless, Arc<Mutex<Vec<String>>>, Arc<Mutex<Vec<String>>>) {
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/<photo>.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("<photo>.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"));
}

View File

@ -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-<time>.txt` next to the logs (what happened, the System Info rows, this run's log and optionally the two before) and reveals it, and opens a new GitHub issue pre-filled with the same and the end of the log (link ≤ 8,000 characters); the home folder and photo file names are hidden by default; nothing is sent. Not yet: an offer after a crash, a duplicate search, reporting without a GitHub account |
| MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | `cmd:app.openLogFolder` | an extra (the reference app has no such item): Help ▸ Open Log Folder reveals `logs/lightcraft.log` in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in `--memory` sessions, which keep no log (issue #260) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
@ -806,6 +807,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision |
| 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged |
| 2026-10-11 | patch | LR-LIB-SMARTALBUM: sidebar counts key on the library revision, so develop edits recount them only while a smart album reads develop state (issue #1057); status unchanged |
| 2026-10-11 | minor | MENU-HELP-REPORTBUG: Help ▸ Report a Bug… writes a report file and opens a pre-filled issue (issue #1015) |
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |

View File

@ -108,6 +108,11 @@
copied photos go) are never looked in; the scan goes 16 folder levels deep and stops after 200,000 files and
folders, and says so. A subfolder LightCraft can't read is skipped with a warning. Agents: `subfolders` on
`library.autoImport`.
### Report a Bug
- Help ▸ Report a Bug… (issue #1015), next to Send Feedback…: describe what happened and LightCraft saves a report
file with the system info and the logs, shows it in Finder or Explorer, and opens a new GitHub issue already filled
in, for you to check, attach the file to and submit. Your home folder and photo file names are hidden unless you
untick the box, and the dialog shows exactly what goes in. Nothing is sent by the app.
### JPEG XL export
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI