From 2534c03e3b139f1c3f72bb385fbc92241918c9cd Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:59:38 +0200 Subject: [PATCH] feat: add CopyButton component and copy-to-clipboard across dashboard New reusable CopyButton component with checkmark feedback after copy. Added copy buttons to: - SSH public key display (add repo modal) - Webhook URL field (edit repo modal) - Webhook secret field (edit repo modal) - Code snippets in finding detail (via enhanced CodeSnippet component) - Suggested fix code blocks - MCP server endpoint URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- compliance-dashboard/assets/main.css | 12 ++++ .../src/components/code_snippet.rs | 19 +++--- .../src/components/copy_button.rs | 45 ++++++++++++++ compliance-dashboard/src/components/mod.rs | 1 + compliance-dashboard/src/pages/mcp_servers.rs | 5 +- .../src/pages/repositories.rs | 62 ++++++++++++------- 6 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 compliance-dashboard/src/components/copy_button.rs diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index c4aec6a..aed1f8c 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -3877,3 +3877,15 @@ tbody tr:last-child td { .notification-item-pkg { font-size: 12px; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; } .notification-item-repo { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; } .notification-item-summary { font-size: 11px; color: var(--text-secondary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + +/* ═══════════════════════════════════════════════════════════════ + COPY BUTTON — Reusable clipboard copy component + ═══════════════════════════════════════════════════════════════ */ +.copy-btn { background: none; border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: center; transition: color 0.15s, border-color 0.15s, background 0.15s; flex-shrink: 0; } +.copy-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-muted); } +.copy-btn-sm { padding: 3px 5px; border-radius: 4px; } +/* Copyable inline field pattern: value + copy button side by side */ +.copyable { display: flex; align-items: center; gap: 6px; } +.copyable code, .copyable .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.code-snippet-wrapper { position: relative; } +.code-snippet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; gap: 8px; } diff --git a/compliance-dashboard/src/components/code_snippet.rs b/compliance-dashboard/src/components/code_snippet.rs index b16f115..95ea14b 100644 --- a/compliance-dashboard/src/components/code_snippet.rs +++ b/compliance-dashboard/src/components/code_snippet.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; +use crate::components::copy_button::CopyButton; + #[component] pub fn CodeSnippet( code: String, @@ -7,15 +9,18 @@ pub fn CodeSnippet( #[props(default)] line_number: u32, ) -> Element { rsx! { - div { - if !file_path.is_empty() { - div { - style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; font-family: monospace;", - "{file_path}" - if line_number > 0 { - ":{line_number}" + div { class: "code-snippet-wrapper", + div { class: "code-snippet-header", + if !file_path.is_empty() { + span { + style: "font-size: 12px; color: var(--text-secondary); font-family: monospace;", + "{file_path}" + if line_number > 0 { + ":{line_number}" + } } } + CopyButton { value: code.clone(), small: true } } pre { class: "code-block", "{code}" } } diff --git a/compliance-dashboard/src/components/copy_button.rs b/compliance-dashboard/src/components/copy_button.rs new file mode 100644 index 0000000..2ea335a --- /dev/null +++ b/compliance-dashboard/src/components/copy_button.rs @@ -0,0 +1,45 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; + +/// A small copy-to-clipboard button that shows a checkmark after copying. +/// +/// Usage: `CopyButton { value: "text to copy" }` +#[component] +pub fn CopyButton(value: String, #[props(default = false)] small: bool) -> Element { + let mut copied = use_signal(|| false); + + let size = if small { 12 } else { 14 }; + let class = if small { + "copy-btn copy-btn-sm" + } else { + "copy-btn" + }; + + rsx! { + button { + class: class, + title: if copied() { "Copied!" } else { "Copy to clipboard" }, + onclick: move |_| { + let val = value.clone(); + // Escape single quotes for JS + let escaped = val.replace('\\', "\\\\").replace('\'', "\\'"); + let js = format!("navigator.clipboard.writeText('{escaped}')"); + document::eval(&js); + copied.set(true); + spawn(async move { + #[cfg(feature = "web")] + gloo_timers::future::TimeoutFuture::new(2000).await; + #[cfg(not(feature = "web"))] + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + copied.set(false); + }); + }, + if copied() { + Icon { icon: BsCheckLg, width: size, height: size } + } else { + Icon { icon: BsClipboard, width: size, height: size } + } + } + } +} diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs index a5e7bce..6deaa91 100644 --- a/compliance-dashboard/src/components/mod.rs +++ b/compliance-dashboard/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod app_shell; pub mod attack_chain; pub mod code_inspector; pub mod code_snippet; +pub mod copy_button; pub mod file_tree; pub mod help_chat; pub mod notification_bell; diff --git a/compliance-dashboard/src/pages/mcp_servers.rs b/compliance-dashboard/src/pages/mcp_servers.rs index 2a47f37..15eea79 100644 --- a/compliance-dashboard/src/pages/mcp_servers.rs +++ b/compliance-dashboard/src/pages/mcp_servers.rs @@ -259,7 +259,10 @@ pub fn McpServersPage() -> Element { div { class: "mcp-detail-row", Icon { icon: BsGlobe, width: 13, height: 13 } span { class: "mcp-detail-label", "Endpoint" } - code { class: "mcp-detail-value", "{server.endpoint_url}" } + div { class: "copyable", + code { class: "mcp-detail-value", "{server.endpoint_url}" } + crate::components::copy_button::CopyButton { value: server.endpoint_url.clone(), small: true } + } } div { class: "mcp-detail-row", Icon { icon: BsHddNetwork, width: 13, height: 13 } diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index a63e693..22ba48b 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -137,11 +137,18 @@ pub fn RepositoriesPage() -> Element { "For SSH URLs: add this deploy key (read-only) to your repository" } div { - style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;", - if ssh_public_key().is_empty() { - "Loading..." - } else { - "{ssh_public_key}" + class: "copyable", + style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;", + code { + style: "font-size: 11px; word-break: break-all; user-select: all;", + if ssh_public_key().is_empty() { + "Loading..." + } else { + "{ssh_public_key}" + } + } + if !ssh_public_key().is_empty() { + crate::components::copy_button::CopyButton { value: ssh_public_key(), small: true } } } } @@ -390,28 +397,37 @@ pub fn RepositoriesPage() -> Element { } div { class: "form-group", label { "Webhook URL" } - input { - r#type: "text", - readonly: true, - style: "font-family: monospace; font-size: 12px;", - value: { - #[cfg(feature = "web")] - let origin = web_sys::window() - .and_then(|w: web_sys::Window| w.location().origin().ok()) - .unwrap_or_default(); - #[cfg(not(feature = "web"))] - let origin = String::new(); - format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker()) - }, + { + #[cfg(feature = "web")] + let origin = web_sys::window() + .and_then(|w: web_sys::Window| w.location().origin().ok()) + .unwrap_or_default(); + #[cfg(not(feature = "web"))] + let origin = String::new(); + let webhook_url = format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker()); + rsx! { + div { class: "copyable", + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px; flex: 1;", + value: "{webhook_url}", + } + crate::components::copy_button::CopyButton { value: webhook_url.clone() } + } + } } } div { class: "form-group", label { "Webhook Secret" } - input { - r#type: "text", - readonly: true, - style: "font-family: monospace; font-size: 12px;", - value: "{secret}", + div { class: "copyable", + input { + r#type: "text", + readonly: true, + style: "font-family: monospace; font-size: 12px; flex: 1;", + value: "{secret}", + } + crate::components::copy_button::CopyButton { value: secret.clone() } } } }