From be4b43ed64fb7d652b86579b720152f7f12fcab6 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 11 Mar 2026 09:57:34 +0100 Subject: [PATCH] fix: SBOM export now triggers browser file download instead of showing JSON Replace the inline
 JSON display with a proper browser download using
Blob + URL.createObjectURL. Clicking "Download" now saves a .json file
(CycloneDX or SPDX format) directly to the user's downloads folder.

Co-Authored-By: Claude Opus 4.6 
---
 compliance-dashboard/Cargo.toml        |  6 ++-
 compliance-dashboard/src/pages/sbom.rs | 62 +++++++++++++++++---------
 2 files changed, 45 insertions(+), 23 deletions(-)

diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml
index 46d5eb2..24e0d23 100644
--- a/compliance-dashboard/Cargo.toml
+++ b/compliance-dashboard/Cargo.toml
@@ -12,7 +12,7 @@ path = "../bin/main.rs"
 workspace = true
 
 [features]
-web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys", "dep:gloo-timers"]
+web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys", "dep:js-sys", "dep:wasm-bindgen", "dep:gloo-timers"]
 server = [
     "dioxus/server",
     "dioxus/router",
@@ -51,7 +51,9 @@ thiserror = { workspace = true }
 
 # Web-only
 reqwest = { workspace = true, optional = true }
-web-sys = { version = "0.3", optional = true }
+web-sys = { version = "0.3", optional = true, features = ["Blob", "BlobPropertyBag", "HtmlAnchorElement", "Url", "Document", "Window"] }
+js-sys = { version = "0.3", optional = true }
+wasm-bindgen = { version = "0.2", optional = true }
 gloo-timers = { version = "0.3", features = ["futures"], optional = true }
 
 # Server-only
diff --git a/compliance-dashboard/src/pages/sbom.rs b/compliance-dashboard/src/pages/sbom.rs
index bae5756..397efbe 100644
--- a/compliance-dashboard/src/pages/sbom.rs
+++ b/compliance-dashboard/src/pages/sbom.rs
@@ -23,7 +23,6 @@ pub fn SbomPage() -> Element {
     // ── Export state ──
     let mut show_export = use_signal(|| false);
     let mut export_format = use_signal(|| "cyclonedx".to_string());
-    let mut export_result = use_signal(|| Option::::None);
 
     // ── Diff state ──
     let mut diff_repo_a = use_signal(String::new);
@@ -213,8 +212,15 @@ pub fn SbomPage() -> Element {
                                     let repo = repo_filter();
                                     let fmt = export_format();
                                     spawn(async move {
-                                        match fetch_sbom_export(repo, fmt).await {
-                                            Ok(json) => export_result.set(Some(json)),
+                                        match fetch_sbom_export(repo.clone(), fmt.clone()).await {
+                                            Ok(json) => {
+                                                let filename = if fmt == "spdx" {
+                                                    format!("sbom-{repo}-spdx.json")
+                                                } else {
+                                                    format!("sbom-{repo}-cyclonedx.json")
+                                                };
+                                                trigger_download(&json, &filename);
+                                            }
                                             Err(e) => tracing::error!("Export failed: {e}"),
                                         }
                                     });
@@ -229,24 +235,6 @@ pub fn SbomPage() -> Element {
                 }
             }
 
-            // ── Export result display ──
-            if let Some(json) = export_result() {
-                div { class: "card sbom-export-result",
-                    div { class: "sbom-export-result-header",
-                        strong { "Exported SBOM" }
-                        button {
-                            class: "btn btn-secondary",
-                            onclick: move |_| export_result.set(None),
-                            "Close"
-                        }
-                    }
-                    pre {
-                        style: "max-height: 400px; overflow: auto; font-size: 12px;",
-                        "{json}"
-                    }
-                }
-            }
-
             // ── SBOM table ──
             match &*sbom.read() {
                 Some(Some(resp)) => {
@@ -685,3 +673,35 @@ fn license_type_class(is_copyleft: bool) -> &'static str {
         "license-permissive"
     }
 }
+
+#[cfg(feature = "web")]
+fn trigger_download(content: &str, filename: &str) {
+    use wasm_bindgen::JsCast;
+    let window = web_sys::window().expect("no window");
+    let document = window.document().expect("no document");
+
+    let blob_parts = js_sys::Array::new();
+    blob_parts.push(&wasm_bindgen::JsValue::from_str(content));
+
+    let mut opts = web_sys::BlobPropertyBag::new();
+    opts.type_("application/json");
+    let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &opts).expect("blob");
+
+    let url = web_sys::Url::create_object_url_with_blob(&blob).expect("object url");
+
+    let a: web_sys::HtmlAnchorElement = document
+        .create_element("a")
+        .expect("create a")
+        .dyn_into()
+        .expect("cast");
+    a.set_href(&url);
+    a.set_download(filename);
+    a.click();
+
+    let _ = web_sys::Url::revoke_object_url(&url);
+}
+
+#[cfg(not(feature = "web"))]
+fn trigger_download(_content: &str, _filename: &str) {
+    // Server-side: no-op (downloads only happen in the browser)
+}