Add graph explorer components, API handlers, and dependency updates
Adds code inspector, file tree components, graph visualization JS, graph API handlers, sidebar navigation updates, and misc improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
93
compliance-dashboard/src/components/code_inspector.rs
Normal file
93
compliance-dashboard/src/components/code_inspector.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::infrastructure::graph::fetch_file_content;
|
||||
|
||||
#[component]
|
||||
pub fn CodeInspector(
|
||||
repo_id: String,
|
||||
file_path: String,
|
||||
node_name: String,
|
||||
node_kind: String,
|
||||
start_line: u32,
|
||||
end_line: u32,
|
||||
on_close: EventHandler<()>,
|
||||
) -> Element {
|
||||
let file_path_clone = file_path.clone();
|
||||
let repo_id_clone = repo_id.clone();
|
||||
let file_content = use_resource(move || {
|
||||
let rid = repo_id_clone.clone();
|
||||
let fp = file_path_clone.clone();
|
||||
async move {
|
||||
if fp.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fetch_file_content(rid, fp).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "code-inspector-panel",
|
||||
// Header
|
||||
div { class: "code-inspector-header",
|
||||
div { class: "code-inspector-title",
|
||||
span { class: "badge badge-info", "SELECTED" }
|
||||
span { class: "code-inspector-file-name", "{file_path}" }
|
||||
}
|
||||
div { class: "code-inspector-meta",
|
||||
span { class: "node-label-badge node-kind-{node_kind}", "{node_kind}" }
|
||||
span { class: "code-inspector-node-name", "{node_name}" }
|
||||
if start_line > 0 && end_line > 0 {
|
||||
span { class: "code-inspector-lines", "L{start_line}–L{end_line}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-sm code-inspector-close",
|
||||
onclick: move |_| on_close.call(()),
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
div { class: "code-inspector-body",
|
||||
match &*file_content.read() {
|
||||
Some(Some(resp)) => {
|
||||
let lines: Vec<&str> = resp.data.content.split('\n').collect();
|
||||
rsx! {
|
||||
div { class: "code-inspector-code",
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
{
|
||||
let line_num = (i + 1) as u32;
|
||||
let is_highlighted = start_line > 0
|
||||
&& line_num >= start_line
|
||||
&& line_num <= end_line;
|
||||
let class_name = if is_highlighted {
|
||||
"code-line code-line-highlight"
|
||||
} else {
|
||||
"code-line"
|
||||
};
|
||||
rsx! {
|
||||
div {
|
||||
class: "{class_name}",
|
||||
id: if is_highlighted && line_num == start_line { "highlighted-line" } else { "" },
|
||||
span { class: "code-line-number", "{line_num}" }
|
||||
span { class: "code-line-content", "{line}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "code-inspector-empty",
|
||||
"Could not load file content."
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading file..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
compliance-dashboard/src/components/file_tree.rs
Normal file
176
compliance-dashboard/src/components/file_tree.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct FileTreeNode {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub node_count: usize,
|
||||
pub children: Vec<FileTreeNode>,
|
||||
}
|
||||
|
||||
/// Build a file tree from a list of graph nodes (serde_json::Value with file_path field).
|
||||
pub fn build_file_tree(nodes: &[serde_json::Value]) -> Vec<FileTreeNode> {
|
||||
// Count nodes per file
|
||||
let mut file_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for node in nodes {
|
||||
if let Some(fp) = node.get("file_path").and_then(|v| v.as_str()) {
|
||||
*file_counts.entry(fp.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build tree structure
|
||||
let mut root_children: BTreeMap<String, FileTreeNode> = BTreeMap::new();
|
||||
|
||||
for (file_path, count) in &file_counts {
|
||||
let parts: Vec<&str> = file_path.split('/').collect();
|
||||
insert_path(&mut root_children, &parts, file_path, *count);
|
||||
}
|
||||
|
||||
let mut result: Vec<FileTreeNode> = root_children.into_values().collect();
|
||||
sort_tree(&mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn insert_path(
|
||||
children: &mut BTreeMap<String, FileTreeNode>,
|
||||
parts: &[&str],
|
||||
full_path: &str,
|
||||
node_count: usize,
|
||||
) {
|
||||
if parts.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let is_leaf = parts.len() == 1;
|
||||
|
||||
let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode {
|
||||
name: name.clone(),
|
||||
path: if is_leaf {
|
||||
full_path.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
is_dir: !is_leaf,
|
||||
node_count: 0,
|
||||
children: Vec::new(),
|
||||
});
|
||||
|
||||
if is_leaf {
|
||||
entry.node_count = node_count;
|
||||
entry.is_dir = false;
|
||||
entry.path = full_path.to_string();
|
||||
} else {
|
||||
entry.is_dir = true;
|
||||
entry.node_count += node_count;
|
||||
let mut child_map: BTreeMap<String, FileTreeNode> = entry
|
||||
.children
|
||||
.drain(..)
|
||||
.map(|c| (c.name.clone(), c))
|
||||
.collect();
|
||||
insert_path(&mut child_map, &parts[1..], full_path, node_count);
|
||||
entry.children = child_map.into_values().collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_tree(nodes: &mut [FileTreeNode]) {
|
||||
nodes.sort_by(|a, b| {
|
||||
// Directories first, then alphabetical
|
||||
b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
|
||||
});
|
||||
for node in nodes.iter_mut() {
|
||||
sort_tree(&mut node.children);
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FileTree(
|
||||
tree: Vec<FileTreeNode>,
|
||||
filter: String,
|
||||
on_file_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div { class: "file-tree",
|
||||
for node in tree.iter() {
|
||||
FileTreeItem {
|
||||
node: node.clone(),
|
||||
depth: 0,
|
||||
filter: filter.clone(),
|
||||
on_file_click: move |path: String| on_file_click.call(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn FileTreeItem(
|
||||
node: FileTreeNode,
|
||||
depth: usize,
|
||||
filter: String,
|
||||
on_file_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut expanded = use_signal(|| depth == 0);
|
||||
|
||||
// Filter: hide nodes that don't match
|
||||
let matches_filter = filter.is_empty()
|
||||
|| node.name.to_lowercase().contains(&filter.to_lowercase())
|
||||
|| node
|
||||
.children
|
||||
.iter()
|
||||
.any(|c| c.name.to_lowercase().contains(&filter.to_lowercase()));
|
||||
|
||||
if !matches_filter {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let padding_left = format!("{}px", 8 + depth * 16);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
div {
|
||||
class: if node.is_dir { "file-tree-item file-tree-dir" } else { "file-tree-item file-tree-file" },
|
||||
style: "padding-left: {padding_left};",
|
||||
onclick: {
|
||||
let path = node.path.clone();
|
||||
let is_dir = node.is_dir;
|
||||
move |_| {
|
||||
if is_dir {
|
||||
expanded.set(!expanded());
|
||||
} else {
|
||||
on_file_click.call(path.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
span { class: "file-tree-icon",
|
||||
if node.is_dir {
|
||||
if expanded() { "\u{25BE}" } else { "\u{25B8}" }
|
||||
} else {
|
||||
"\u{25CB}"
|
||||
}
|
||||
}
|
||||
|
||||
span { class: "file-tree-name", "{node.name}" }
|
||||
|
||||
if node.node_count > 0 {
|
||||
span { class: "file-tree-badge", "{node.node_count}" }
|
||||
}
|
||||
}
|
||||
|
||||
if node.is_dir && expanded() {
|
||||
for child in node.children.iter() {
|
||||
FileTreeItem {
|
||||
node: child.clone(),
|
||||
depth: depth + 1,
|
||||
filter: filter.clone(),
|
||||
on_file_click: move |path: String| on_file_click.call(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod app_shell;
|
||||
pub mod code_inspector;
|
||||
pub mod code_snippet;
|
||||
pub mod file_tree;
|
||||
pub mod page_header;
|
||||
pub mod pagination;
|
||||
pub mod severity_badge;
|
||||
|
||||
@@ -13,6 +13,7 @@ struct NavItem {
|
||||
#[component]
|
||||
pub fn Sidebar() -> Element {
|
||||
let current_route = use_route::<Route>();
|
||||
let mut collapsed = use_signal(|| true);
|
||||
|
||||
let nav_items = [
|
||||
NavItem {
|
||||
@@ -57,11 +58,15 @@ pub fn Sidebar() -> Element {
|
||||
},
|
||||
];
|
||||
|
||||
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
|
||||
|
||||
rsx! {
|
||||
nav { class: "sidebar",
|
||||
nav { class: "{sidebar_class}",
|
||||
div { class: "sidebar-header",
|
||||
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||
h1 { "Compliance Scanner" }
|
||||
if !collapsed() {
|
||||
h1 { "Compliance Scanner" }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
@@ -82,15 +87,25 @@ pub fn Sidebar() -> Element {
|
||||
to: item.route.clone(),
|
||||
class: class,
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
if !collapsed() {
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
style: "padding: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);",
|
||||
"v0.1.0"
|
||||
button {
|
||||
class: "sidebar-toggle",
|
||||
onclick: move |_| collapsed.set(!collapsed()),
|
||||
if collapsed() {
|
||||
Icon { icon: BsChevronRight, width: 14, height: 14 }
|
||||
} else {
|
||||
Icon { icon: BsChevronLeft, width: 14, height: 14 }
|
||||
}
|
||||
}
|
||||
if !collapsed() {
|
||||
div { class: "sidebar-footer", "v0.1.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user