Merge PR #1128
# Conflicts: # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
commit
e0c1c415e5
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
&[
|
||||
"app.discord",
|
||||
"app.feedback",
|
||||
"app.reportBug",
|
||||
"---",
|
||||
"app.website",
|
||||
"app.github",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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"));
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue