Browse / github.com/storytold/gridcraft / patch
Report an Issue…: open a prefilled GitHub bug form (#2643)
add menu item for send issue (all artcraft apps) The feedback will be more extensive and richer.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #2674, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +112 −12 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bb28c85c60ba1504c1b84aaf
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md apps/gridcraft-web/src/web.rs apps/gridcraft/src/main.rs apps/gridcraft/src/native_menu.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/backstage.rs crates/ui-egui/src/backstage/options.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/show.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/report_issue.rs crates/ui-egui/src/ribbon/automate.rs crates/ui-egui/src/services.rs
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---|+| 2026-10-27 | Report an Issue… (#2643): the Automate tab, File › Account and the macOS Help menu open a new GitHub issue with the version, platform and the workbook's file type filled in (never its name or contents); when no browser opens, a dialog shows the address to copy (`help.reportIssue`) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -229,10 +229,14 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { if let Some(w) = web_sys::window() && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url) {- // With `noopener` the new browsing context can't hold a reference back to the- // editor window and navigate it while the user reads the linked page.- let _ = w.open_with_url_and_target(&url, "_blank,noopener");+ // `None` means a pop-up blocker stopped it. The opener is cut at once so the new+ // browsing context can't navigate the editor window while the user reads the page.+ if let Ok(Some(opened)) = w.open_with_url_and_target(&url, "_blank") {+ let _ = opened.set_opener(&JsValue::NULL);+ return true;+ } }+ false })), inbox: Some(inbox), ..Default::default()@@ -203,13 +203,13 @@ fn services() -> Services { } } -/// Opens a URL or file with the system's default application.-fn open_with_system(target: &str) {+/// Opens a URL or file with the system's default application; `false` when nothing opened.+fn open_with_system(target: &str) -> bool { // Last line of defence behind `link.open` and the click prompt: the system only ever gets // http, https and mailto addresses. let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else { log::warn!("refused to open {target:?}: only http, https and mailto links are opened");- return;+ return false; }; let target = target.as_str(); let cmd = if cfg!(target_os = "macos") {@@ -219,9 +219,7 @@ fn open_with_system(target: &str) { } else { "xdg-open" };- if let Err(e) = std::process::Command::new(cmd).arg(target).spawn() {- log::warn!("{cmd} {target}: {e}");- }+ std::process::Command::new(cmd).arg(target).spawn().inspect_err(|e| log::warn!("{cmd} {target}: {e}")).is_ok() } fn icon() -> Option<egui::IconData> {@@ -186,6 +186,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> { ("Contributors…", "dialog:about:Contributors", None), ("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None), ("Join the ArtCraft Discord…", "url:https://discord.gg/artcraft", None),+ ("Report an Issue…", "help.reportIssue", None), ], ), ]@@ -76,6 +76,9 @@ impl SheetApp { } pub fn run_or_alert(&mut self, id: &str, params: Json) {+ if id == "help.reportIssue" {+ return crate::report_issue::run_menu(self);+ } // A menu/shortcut opens the settings first; the Print page's button submits explicitly. if id == "file.print" { if self.services.print_native.is_some() {@@ -128,6 +131,7 @@ impl SheetApp { self.check_for_updates(); Ok(Json::Null) }+ "help.reportIssue" => Ok(crate::report_issue::run(self)), "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), "ui.printSubmit" => self.submit_print(p.clone()), "ui.ribbonTab" => {@@ -23,7 +23,8 @@ use crate::theme::{self, Tokens}; mod options; mod print; -use options::{options, platform};+use options::options;+pub(crate) use options::platform; use print::print; /// Width of the navigation column.@@ -817,6 +818,18 @@ fn account(app: &mut SheetApp, ui: &mut Ui) { dim(ui, lang.tr("Version, contributors and the AI models that helped")); }); });+ ui.add_space(10.0);+ ui.horizontal(|ui| {+ if square_button(ui, Icon::Info, lang.tr("Report an Issue…")).clicked() {+ app.run_or_alert("help.reportIssue", json!({}));+ }+ ui.add_space(12.0);+ ui.vertical(|ui| {+ ui.add_space(14.0);+ ui.label(RichText::new(lang.tr("Report an Issue…")).font(theme::ui_bold(13.5)).color(t.text));+ dim(ui, lang.tr("Opens a new GitHub issue with the version and system filled in"));+ });+ }); } #[cfg(test)]@@ -163,7 +163,7 @@ fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale } /// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".-pub(super) fn platform() -> String {+pub(crate) fn platform() -> String { let os = match std::env::consts::OS { "macos" => "macOS", "windows" => "Windows",@@ -899,6 +899,7 @@ impl Dialog { "manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})), "commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})), "agents" => Dialog::custom("agents", "Agent Control", json!({})),+ "failedLink" => Dialog::custom("failedLink", "Couldn't open your web browser", json!({"url": p("url")})), "about" => { let mut d = Dialog::custom("about", "About GridCraft", json!({})); d.tab = match p("tab").as_str() {@@ -169,6 +169,16 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); } "about" => about(ui, &mut d),+ "failedLink" => {+ let url = d.values.get("url").and_then(Json::as_str).unwrap_or_default();+ ui.label("Open this address in a browser to report the issue:");+ // Read-only: a `&str` can be selected but not edited, so Copy always copies the address shown.+ let mut shown = url;+ ui.add(egui::TextEdit::multiline(&mut shown).desired_width(f32::INFINITY).desired_rows(4));+ if ui.button("Copy").clicked() {+ ctx.copy_text(url.to_string());+ }+ } "journal" => { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (id, p) in app.session.journal.iter().rev().take(500) {@@ -32,6 +32,7 @@ pub mod panes; pub mod paper_view; pub mod pivot_pane; pub mod printing;+mod report_issue; pub mod ribbon; pub mod selection_glide; pub mod tabs;new file mode 100644@@ -0,0 +1,62 @@+//! Help ▸ Report an Issue…: opens a new GitHub issue with the version and system filled in. Nothing+//! is created until the user submits it.++use gridcraft_engine::io::FileKind;+use serde_json::json;++use crate::SheetApp;++/// The new-issue address. `version` and `environment` (`os` too, for the other open form) are the bug form's field ids, and `body`+/// fills a blank issue where no form exists. The document is described by its file extension+/// alone, never a name, path or contents. Plain strings in, so it can move to a shared crate.+pub(crate) fn issue_url(version: &str, os: &str, document_ext: Option<&str>) -> String {+ let short = |s: &str| s.chars().filter(|c| !c.is_control()).take(16).collect::<String>();+ let ext: String = document_ext.unwrap_or("none").chars().filter(char::is_ascii_alphanumeric).take(8).collect();+ let (version, environment) = (short(version), format!("{}; document: {ext}", short(os)));+ let encode = |s: &str| {+ let keep = |b: &u8| b.is_ascii_alphanumeric() || b"-_.~".contains(b);+ s.bytes().map(|b| if keep(&b) { char::from(b).to_string() } else { format!("%{b:02X}") }).collect::<String>()+ };+ let body = format!("GridCraft {version}\n{environment}");+ let (v, e, b) = (encode(&version), encode(&environment), encode(&body));+ format!("https://github.com/storytold/gridcraft/issues/new?template=bug_report.yml&version={v}&environment={e}&os={e}&body={b}")+}++/// `help.reportIssue`: opens the issue form. Returns `{url, opened}` and never opens a dialog;+/// [`run_menu`] shows the address when no browser opened.+pub(crate) fn run(app: &mut SheetApp) -> serde_json::Value {+ // Only a format GridCraft opens is named: an odd extension could be private text.+ let ext = app+ .session+ .active()+ .and_then(|d| d.path.as_deref())+ .filter(|p| FileKind::from_path(p).is_some())+ .and_then(|p| std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase));+ let url = issue_url(env!("CARGO_PKG_VERSION"), &crate::backstage::platform(), ext.as_deref());+ let opened = app.services.open_url.as_ref().is_some_and(|open| open(&url));+ json!({"url": url, "opened": opened})+}++/// The menu, ribbon and Account entries: like [`run`], plus the address to copy when no browser opened.+pub(crate) fn run_menu(app: &mut SheetApp) {+ let result = run(app);+ if result["opened"] != true {+ app.open_dialog("failedLink", json!({"url": result["url"]}));+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn the_address_carries_version_and_system_only_and_stays_short() {+ let url = issue_url("0.1.0", "Linux (x86_64)", Some("xlsx"));+ assert!(url.contains("&version=0.1.0&environment=Linux%20%28x86_64%29%3B%20document%3A%20xlsx&os=Linux%20%28x86_64%29%3B%20document%3A%20xlsx&body=GridCraft%200.1.0%0A"), "{url}");+ // Hostile text and an unusual extension can't add a parameter, leak, or grow the address.+ let url = issue_url(&"\u{1F600}".repeat(99), &"&#=".repeat(99), Some("x.y/Z"));+ assert_eq!(url.matches('?').count() + url.matches('#').count(), 1, "{url}");+ assert!(url.is_ascii() && url.contains("document%3A%20xyZ") && url.len() < 2048, "{} bytes: {url}", url.len());+ assert!(issue_url("1", "w", None).contains("document%3A%20none"));+ }+}@@ -25,6 +25,10 @@ pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { app.run_or_alert("app.checkForUpdates", json!({})); }+ let __k = big_button(ui, Icon::Script, "Report an\nIssue…", "Open a new GitHub issue with the version and system filled in", false);+ if __k.clicked() {+ app.run_or_alert("help.reportIssue", json!({}));+ } let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); control_keytip(app, ui, &__k, "about"); if __k.clicked() {@@ -14,7 +14,8 @@ pub struct Services { /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot /// insert into a subsequent dialog or open the image as a workbook. pub pick_picture_async: Option<Box<dyn Fn(Inbox)>>,- pub open_url: Option<Box<dyn Fn(&str)>>,+ /// Opens an http, https or mailto address in the user's browser; `false` when none opened.+ pub open_url: Option<Box<dyn Fn(&str) -> bool>>, /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the /// write (including any asynchronous fallback); an immediate error uses egui's text path. pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,