collective

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -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`) |diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs--- a/apps/gridcraft-web/src/web.rs+++ b/apps/gridcraft-web/src/web.rs@@ -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()diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs--- a/apps/gridcraft/src/main.rs+++ b/apps/gridcraft/src/main.rs@@ -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> {diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs--- a/apps/gridcraft/src/native_menu.rs+++ b/apps/gridcraft/src/native_menu.rs@@ -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),             ],         ),     ]diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs--- a/crates/ui-egui/src/app/commands.rs+++ b/crates/ui-egui/src/app/commands.rs@@ -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" => {diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs--- a/crates/ui-egui/src/backstage.rs+++ b/crates/ui-egui/src/backstage.rs@@ -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)]diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs--- a/crates/ui-egui/src/backstage/options.rs+++ b/crates/ui-egui/src/backstage/options.rs@@ -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",diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -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() {diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs--- a/crates/ui-egui/src/dialogs/show.rs+++ b/crates/ui-egui/src/dialogs/show.rs@@ -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) {diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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;diff --git a/crates/ui-egui/src/report_issue.rs b/crates/ui-egui/src/report_issue.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/report_issue.rs@@ -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"));+    }+}diff --git a/crates/ui-egui/src/ribbon/automate.rs b/crates/ui-egui/src/ribbon/automate.rs--- a/crates/ui-egui/src/ribbon/automate.rs+++ b/crates/ui-egui/src/ribbon/automate.rs@@ -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() {diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs--- a/crates/ui-egui/src/services.rs+++ b/crates/ui-egui/src/services.rs@@ -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>>>,