All checks were successful
CI / Clippy (push) Successful in 4m56s
CI / Security Audit (push) Successful in 1m48s
CI / Tests (push) Successful in 5m36s
CI / Deploy MCP (push) Has been skipped
CI / Format (push) Successful in 6s
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Successful in 3s
642 lines
35 KiB
Rust
642 lines
35 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::*;
|
|
#[allow(unused_imports)]
|
|
use dioxus_free_icons::icons::bs_icons::{BsGear, BsPencil};
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::components::page_header::PageHeader;
|
|
use crate::components::pagination::Pagination;
|
|
use crate::components::toast::{ToastType, Toasts};
|
|
use crate::pages::graph_explorer::GraphExplorerInline;
|
|
|
|
async fn async_sleep_5s() {
|
|
#[cfg(feature = "web")]
|
|
{
|
|
gloo_timers::future::TimeoutFuture::new(5_000).await;
|
|
}
|
|
#[cfg(not(feature = "web"))]
|
|
{
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
pub fn RepositoriesPage() -> Element {
|
|
let mut page = use_signal(|| 1u64);
|
|
let mut show_add_form = use_signal(|| false);
|
|
let mut name = use_signal(String::new);
|
|
let mut git_url = use_signal(String::new);
|
|
let mut branch = use_signal(|| "main".to_string());
|
|
let mut auth_token = use_signal(String::new);
|
|
let mut auth_username = use_signal(String::new);
|
|
let mut show_auth = use_signal(|| false);
|
|
let mut ssh_public_key = use_signal(String::new);
|
|
let mut show_tracker = use_signal(|| false);
|
|
let mut tracker_type_val = use_signal(String::new);
|
|
let mut tracker_owner_val = use_signal(String::new);
|
|
let mut tracker_repo_val = use_signal(String::new);
|
|
let mut tracker_token_val = use_signal(String::new);
|
|
let mut adding = use_signal(|| false);
|
|
let mut toasts = use_context::<Toasts>();
|
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
|
let mut edit_repo_id = use_signal(|| Option::<String>::None);
|
|
let mut edit_name = use_signal(String::new);
|
|
let mut edit_branch = use_signal(String::new);
|
|
let mut edit_tracker_type = use_signal(String::new);
|
|
let mut edit_tracker_owner = use_signal(String::new);
|
|
let mut edit_tracker_repo = use_signal(String::new);
|
|
let mut edit_tracker_token = use_signal(String::new);
|
|
let mut edit_saving = use_signal(|| false);
|
|
let mut edit_webhook_secret = use_signal(|| Option::<String>::None);
|
|
let mut edit_webhook_tracker = use_signal(String::new);
|
|
let mut scanning_ids = use_signal(Vec::<String>::new);
|
|
let mut graph_repo_id = use_signal(|| Option::<String>::None);
|
|
|
|
let mut repos = use_resource(move || {
|
|
let p = page();
|
|
async move {
|
|
crate::infrastructure::repositories::fetch_repositories(p)
|
|
.await
|
|
.ok()
|
|
}
|
|
});
|
|
|
|
rsx! {
|
|
PageHeader {
|
|
title: "Repositories",
|
|
description: "Tracked git repositories",
|
|
}
|
|
|
|
div { style: "margin-bottom: 16px;",
|
|
button {
|
|
class: "btn btn-primary",
|
|
onclick: move |_| show_add_form.toggle(),
|
|
if show_add_form() { "Cancel" } else { "+ Add Repository" }
|
|
}
|
|
}
|
|
|
|
if show_add_form() {
|
|
div { class: "card",
|
|
div { class: "card-header", "Add Repository" }
|
|
div { class: "form-group",
|
|
label { "Name" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "my-project",
|
|
value: "{name}",
|
|
oninput: move |e| name.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Git URL" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
|
value: "{git_url}",
|
|
oninput: move |e| git_url.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Default Branch" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "main",
|
|
value: "{branch}",
|
|
oninput: move |e| branch.set(e.value()),
|
|
}
|
|
}
|
|
|
|
// Private repo auth section
|
|
div { style: "margin-top: 8px;",
|
|
button {
|
|
class: "btn btn-ghost",
|
|
style: "font-size: 12px; padding: 4px 8px;",
|
|
onclick: move |_| {
|
|
let opening = !show_auth();
|
|
show_auth.toggle();
|
|
if opening {
|
|
// Fetch SSH key every time the section opens
|
|
ssh_public_key.set(String::new());
|
|
spawn(async move {
|
|
match crate::infrastructure::repositories::fetch_ssh_public_key().await {
|
|
Ok(key) => ssh_public_key.set(key),
|
|
Err(_) => ssh_public_key.set("(not available)".to_string()),
|
|
}
|
|
});
|
|
}
|
|
},
|
|
if show_auth() { "Hide auth options" } else { "Private repository?" }
|
|
}
|
|
}
|
|
|
|
if show_auth() {
|
|
div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;",
|
|
// SSH deploy key display
|
|
div { style: "margin-bottom: 12px;",
|
|
label { style: "font-size: 12px; color: var(--text-secondary);",
|
|
"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}"
|
|
}
|
|
}
|
|
}
|
|
|
|
// HTTPS auth fields
|
|
p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
|
"For HTTPS URLs: provide an access token (PAT) or username/password"
|
|
}
|
|
div { class: "form-group",
|
|
label { "Auth Token / Password" }
|
|
input {
|
|
r#type: "password",
|
|
placeholder: "ghp_xxxx or personal access token",
|
|
value: "{auth_token}",
|
|
oninput: move |e| auth_token.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Username (optional, defaults to x-access-token)" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "x-access-token",
|
|
value: "{auth_username}",
|
|
oninput: move |e| auth_username.set(e.value()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Issue tracker config section
|
|
div { style: "margin-top: 8px;",
|
|
button {
|
|
class: "btn btn-ghost",
|
|
style: "font-size: 12px; padding: 4px 8px;",
|
|
onclick: move |_| show_tracker.toggle(),
|
|
if show_tracker() { "Hide tracker options" } else { "Issue tracker?" }
|
|
}
|
|
}
|
|
|
|
if show_tracker() {
|
|
div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;",
|
|
p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
|
"Configure an issue tracker to auto-create issues from findings"
|
|
}
|
|
div { class: "form-group",
|
|
label { "Tracker Type" }
|
|
select {
|
|
value: "{tracker_type_val}",
|
|
onchange: move |e| tracker_type_val.set(e.value()),
|
|
option { value: "", "None" }
|
|
option { value: "github", "GitHub" }
|
|
option { value: "gitlab", "GitLab" }
|
|
option { value: "gitea", "Gitea" }
|
|
option { value: "jira", "Jira" }
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Owner / Namespace" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "org-name",
|
|
value: "{tracker_owner_val}",
|
|
oninput: move |e| tracker_owner_val.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Repository / Project" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "repo-name",
|
|
value: "{tracker_repo_val}",
|
|
oninput: move |e| tracker_repo_val.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Tracker Token (PAT)" }
|
|
input {
|
|
r#type: "password",
|
|
placeholder: "ghp_xxxx / glpat-xxxx",
|
|
value: "{tracker_token_val}",
|
|
oninput: move |e| tracker_token_val.set(e.value()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
button {
|
|
class: "btn btn-primary",
|
|
disabled: adding(),
|
|
onclick: move |_| {
|
|
let n = name();
|
|
let u = git_url();
|
|
let b = branch();
|
|
let tok = {
|
|
let v = auth_token();
|
|
if v.is_empty() { None } else { Some(v) }
|
|
};
|
|
let usr = {
|
|
let v = auth_username();
|
|
if v.is_empty() { None } else { Some(v) }
|
|
};
|
|
let tt = { let v = tracker_type_val(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_owner = { let v = tracker_owner_val(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_repo = { let v = tracker_repo_val(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_tok = { let v = tracker_token_val(); if v.is_empty() { None } else { Some(v) } };
|
|
adding.set(true);
|
|
spawn(async move {
|
|
match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr, tt, t_owner, t_repo, t_tok).await {
|
|
Ok(_) => {
|
|
toasts.push(ToastType::Success, "Repository added");
|
|
repos.restart();
|
|
}
|
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
|
}
|
|
adding.set(false);
|
|
});
|
|
show_add_form.set(false);
|
|
show_auth.set(false);
|
|
show_tracker.set(false);
|
|
name.set(String::new());
|
|
git_url.set(String::new());
|
|
auth_token.set(String::new());
|
|
auth_username.set(String::new());
|
|
tracker_type_val.set(String::new());
|
|
tracker_owner_val.set(String::new());
|
|
tracker_repo_val.set(String::new());
|
|
tracker_token_val.set(String::new());
|
|
},
|
|
if adding() { "Validating..." } else { "Add" }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Delete confirmation dialog ──
|
|
if let Some((del_id, del_name)) = confirm_delete() {
|
|
div { class: "modal-overlay",
|
|
div { class: "modal-dialog",
|
|
h3 { "Delete Repository" }
|
|
p {
|
|
"Are you sure you want to delete "
|
|
strong { "{del_name}" }
|
|
"?"
|
|
}
|
|
p { class: "modal-warning",
|
|
"This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts."
|
|
}
|
|
div { class: "modal-actions",
|
|
button {
|
|
class: "btn btn-secondary",
|
|
onclick: move |_| confirm_delete.set(None),
|
|
"Cancel"
|
|
}
|
|
button {
|
|
class: "btn btn-danger",
|
|
onclick: move |_| {
|
|
let id = del_id.clone();
|
|
let name = del_name.clone();
|
|
confirm_delete.set(None);
|
|
spawn(async move {
|
|
match crate::infrastructure::repositories::delete_repository(id).await {
|
|
Ok(_) => {
|
|
toasts.push(ToastType::Success, format!("{name} deleted"));
|
|
repos.restart();
|
|
}
|
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
|
}
|
|
});
|
|
},
|
|
"Delete"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Edit repository dialog ──
|
|
if let Some(eid) = edit_repo_id() {
|
|
div { class: "modal-overlay",
|
|
div { class: "modal-dialog",
|
|
h3 { "Edit Repository" }
|
|
div { class: "form-group",
|
|
label { "Name" }
|
|
input {
|
|
r#type: "text",
|
|
value: "{edit_name}",
|
|
oninput: move |e| edit_name.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Default Branch" }
|
|
input {
|
|
r#type: "text",
|
|
value: "{edit_branch}",
|
|
oninput: move |e| edit_branch.set(e.value()),
|
|
}
|
|
}
|
|
h4 { style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", "Issue Tracker" }
|
|
div { class: "form-group",
|
|
label { "Tracker Type" }
|
|
select {
|
|
value: "{edit_tracker_type}",
|
|
onchange: move |e| edit_tracker_type.set(e.value()),
|
|
option { value: "", "None" }
|
|
option { value: "github", "GitHub" }
|
|
option { value: "gitlab", "GitLab" }
|
|
option { value: "gitea", "Gitea" }
|
|
option { value: "jira", "Jira" }
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Owner / Namespace" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "org-name",
|
|
value: "{edit_tracker_owner}",
|
|
oninput: move |e| edit_tracker_owner.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Repository / Project" }
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "repo-name",
|
|
value: "{edit_tracker_repo}",
|
|
oninput: move |e| edit_tracker_repo.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Tracker Token (leave empty to keep existing)" }
|
|
input {
|
|
r#type: "password",
|
|
placeholder: "Enter new token to change",
|
|
value: "{edit_tracker_token}",
|
|
oninput: move |e| edit_tracker_token.set(e.value()),
|
|
}
|
|
}
|
|
// Webhook configuration section
|
|
if let Some(secret) = edit_webhook_secret() {
|
|
h4 {
|
|
style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);",
|
|
"Webhook Configuration"
|
|
}
|
|
p {
|
|
style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
|
"Add this webhook in your repository settings to enable push-triggered scans and PR reviews."
|
|
}
|
|
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())
|
|
},
|
|
}
|
|
}
|
|
div { class: "form-group",
|
|
label { "Webhook Secret" }
|
|
input {
|
|
r#type: "text",
|
|
readonly: true,
|
|
style: "font-family: monospace; font-size: 12px;",
|
|
value: "{secret}",
|
|
}
|
|
}
|
|
}
|
|
div { class: "modal-actions",
|
|
button {
|
|
class: "btn btn-secondary",
|
|
onclick: move |_| edit_repo_id.set(None),
|
|
"Cancel"
|
|
}
|
|
button {
|
|
class: "btn btn-primary",
|
|
disabled: edit_saving(),
|
|
onclick: move |_| {
|
|
let id = eid.clone();
|
|
let nm = { let v = edit_name(); if v.is_empty() { None } else { Some(v) } };
|
|
let br = { let v = edit_branch(); if v.is_empty() { None } else { Some(v) } };
|
|
let tt = { let v = edit_tracker_type(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_owner = { let v = edit_tracker_owner(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_repo = { let v = edit_tracker_repo(); if v.is_empty() { None } else { Some(v) } };
|
|
let t_tok = { let v = edit_tracker_token(); if v.is_empty() { None } else { Some(v) } };
|
|
edit_saving.set(true);
|
|
spawn(async move {
|
|
match crate::infrastructure::repositories::update_repository(
|
|
id, nm, br, None, None, tt, t_owner, t_repo, t_tok, None,
|
|
).await {
|
|
Ok(_) => {
|
|
toasts.push(ToastType::Success, "Repository updated");
|
|
repos.restart();
|
|
}
|
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
|
}
|
|
edit_saving.set(false);
|
|
edit_repo_id.set(None);
|
|
});
|
|
},
|
|
if edit_saving() { "Saving..." } else { "Save" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match &*repos.read() {
|
|
Some(Some(resp)) => {
|
|
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
|
rsx! {
|
|
div { class: "card",
|
|
div { class: "table-wrapper",
|
|
table {
|
|
thead {
|
|
tr {
|
|
th { "Name" }
|
|
th { "Git URL" }
|
|
th { "Branch" }
|
|
th { "Findings" }
|
|
th { "Last Scanned" }
|
|
th { "Actions" }
|
|
}
|
|
}
|
|
tbody {
|
|
for repo in &resp.data {
|
|
{
|
|
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
|
let repo_id_scan = repo_id.clone();
|
|
let repo_id_del = repo_id.clone();
|
|
let repo_id_edit = repo_id.clone();
|
|
let repo_name_del = repo.name.clone();
|
|
let edit_repo_data = repo.clone();
|
|
let is_scanning = scanning_ids().contains(&repo_id);
|
|
rsx! {
|
|
tr {
|
|
td { "{repo.name}" }
|
|
td {
|
|
style: "font-size: 12px; font-family: monospace;",
|
|
"{repo.git_url}"
|
|
}
|
|
td { "{repo.default_branch}" }
|
|
td { "{repo.findings_count}" }
|
|
td {
|
|
{
|
|
let now = chrono::Utc::now();
|
|
let diff = now.signed_duration_since(repo.updated_at);
|
|
let label = if diff.num_minutes() < 1 {
|
|
"just now".to_string()
|
|
} else if diff.num_hours() < 1 {
|
|
format!("{}m ago", diff.num_minutes())
|
|
} else if diff.num_days() < 1 {
|
|
format!("{}h ago", diff.num_hours())
|
|
} else if diff.num_days() < 30 {
|
|
format!("{}d ago", diff.num_days())
|
|
} else {
|
|
repo.updated_at.format("%Y-%m-%d").to_string()
|
|
};
|
|
rsx! { span { style: "font-size: 12px;", "{label}" } }
|
|
}
|
|
}
|
|
td { style: "display: flex; gap: 4px;",
|
|
button {
|
|
class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" },
|
|
title: "View graph",
|
|
onclick: {
|
|
let rid = repo_id.clone();
|
|
move |_| {
|
|
if graph_repo_id().as_deref() == Some(rid.as_str()) {
|
|
graph_repo_id.set(None);
|
|
} else {
|
|
graph_repo_id.set(Some(rid.clone()));
|
|
}
|
|
}
|
|
},
|
|
Icon { icon: BsDiagram3, width: 16, height: 16 }
|
|
}
|
|
button {
|
|
class: "btn btn-ghost",
|
|
title: "Edit repository",
|
|
onclick: move |_| {
|
|
edit_name.set(edit_repo_data.name.clone());
|
|
edit_branch.set(edit_repo_data.default_branch.clone());
|
|
edit_tracker_type.set(
|
|
edit_repo_data.tracker_type.as_ref().map(|t| t.to_string()).unwrap_or_default()
|
|
);
|
|
edit_tracker_owner.set(edit_repo_data.tracker_owner.clone().unwrap_or_default());
|
|
edit_tracker_repo.set(edit_repo_data.tracker_repo.clone().unwrap_or_default());
|
|
edit_tracker_token.set(String::new());
|
|
edit_webhook_secret.set(None);
|
|
edit_webhook_tracker.set(String::new());
|
|
edit_repo_id.set(Some(repo_id_edit.clone()));
|
|
// Fetch webhook config in background
|
|
let rid = repo_id_edit.clone();
|
|
spawn(async move {
|
|
if let Ok(cfg) = crate::infrastructure::repositories::fetch_webhook_config(rid).await {
|
|
edit_webhook_secret.set(cfg.webhook_secret);
|
|
edit_webhook_tracker.set(cfg.tracker_type);
|
|
}
|
|
});
|
|
},
|
|
Icon { icon: BsPencil, width: 16, height: 16 }
|
|
}
|
|
button {
|
|
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
|
title: "Trigger scan",
|
|
disabled: is_scanning,
|
|
onclick: move |_| {
|
|
let id = repo_id_scan.clone();
|
|
// Add to scanning set
|
|
let mut ids = scanning_ids();
|
|
ids.push(id.clone());
|
|
scanning_ids.set(ids);
|
|
spawn(async move {
|
|
match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await {
|
|
Ok(_) => {
|
|
toasts.push(ToastType::Success, "Scan triggered");
|
|
// Poll until scan completes
|
|
loop {
|
|
async_sleep_5s().await;
|
|
match crate::infrastructure::repositories::check_repo_scanning(id.clone()).await {
|
|
Ok(false) => break,
|
|
Ok(true) => continue,
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
toasts.push(ToastType::Success, "Scan complete");
|
|
repos.restart();
|
|
}
|
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
|
}
|
|
// Remove from scanning set
|
|
let mut ids = scanning_ids();
|
|
ids.retain(|i| i != &id);
|
|
scanning_ids.set(ids);
|
|
});
|
|
},
|
|
if is_scanning {
|
|
span { class: "spinner" }
|
|
} else {
|
|
Icon { icon: BsPlayCircle, width: 16, height: 16 }
|
|
}
|
|
}
|
|
button {
|
|
class: "btn btn-ghost btn-ghost-danger",
|
|
title: "Delete repository",
|
|
onclick: move |_| {
|
|
confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone())));
|
|
},
|
|
Icon { icon: BsTrash, width: 16, height: 16 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Pagination {
|
|
current_page: page(),
|
|
total_pages: total_pages,
|
|
on_page_change: move |p| page.set(p),
|
|
}
|
|
}
|
|
|
|
// Inline graph explorer
|
|
if let Some(rid) = graph_repo_id() {
|
|
div { class: "card", style: "margin-top: 16px;",
|
|
div { class: "card-header", style: "display: flex; justify-content: space-between; align-items: center;",
|
|
span { "Code Graph" }
|
|
button {
|
|
class: "btn btn-sm btn-ghost",
|
|
title: "Close graph",
|
|
onclick: move |_| { graph_repo_id.set(None); },
|
|
Icon { icon: BsX, width: 18, height: 18 }
|
|
}
|
|
}
|
|
GraphExplorerInline { repo_id: rid }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Some(None) => rsx! {
|
|
div { class: "card", p { "Failed to load repositories." } }
|
|
},
|
|
None => rsx! {
|
|
div { class: "loading", "Loading repositories..." }
|
|
},
|
|
}
|
|
}
|
|
}
|